Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -95,8 +95,9 @@ Two conventions hold across the real file/stdout handoffs:
HTML by `split().join()` on comment markers (`/*__GRAPH_DATA__*/`, `/*__LCP_DATA__*/`,
`/*__RUN_DATA__*/`, `/*__FORCE_GRAPH_LIB__*/`) in `assets/*.template.html`, deliberately not regex
(the 1.3 MB vendored `assets/3d-force-graph.min.js` could match one). Renaming a marker without
updating its builder fails loudly — keep it that way. Only `build_run_report.mjs` escapes
`</script>` in injected data; the other two inject raw JSON/text into a `<script>` block.
updating its builder fails loudly — keep it that way. All three replace every `<` in the
injected data with `\u003c` (blocks `</script` and `<!--<script` breakout); `check_invariants.mjs`
builds each one from a hostile payload to keep it that way.

## Cross-file invariants

Expand Down
4 changes: 3 additions & 1 deletion skills/browsergnome/scripts/build_lcp_map.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -152,9 +152,11 @@ async function main() {
process.exit(1);
}

// Data lands inside a <script> block: a raw `<` in any value (bundle module/chunk names, URLs) can form `</script`
// or `<!--<script` and end or derail it. `\u003c` parses back to the same character.
const html = template
.split(LIB_MARKER).join(lib)
.split(DATA_MARKER).join(dataJson);
.split(DATA_MARKER).join(dataJson.replace(/</g, '\\u003c'));

fs.writeFileSync(outFile, html);
const outAbs = path.resolve(outFile);
Expand Down
5 changes: 4 additions & 1 deletion skills/browsergnome/scripts/build_perf_map.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -65,9 +65,12 @@ function main() {

// Literal split/join (not regex) so the 1.3MB minified lib can't be
// misinterpreted as a replacement pattern.
//
// Data lands inside a <script> block: a raw `<` in any value (scanned file paths) can form `</script`
// or `<!--<script` and end or derail it. `\u003c` parses back to the same character.
const html = template
.split(LIB_MARKER).join(lib)
.split(DATA_MARKER).join(graphRaw.trim());
.split(DATA_MARKER).join(graphRaw.trim().replace(/</g, '\\u003c'));

fs.writeFileSync(outFile, html);
const outAbs = path.resolve(outFile);
Expand Down
5 changes: 3 additions & 2 deletions skills/browsergnome/scripts/build_run_report.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -51,8 +51,9 @@ function main() {
process.exit(1);
}

// Escape </script> so it can't close the script block if it appears in data values
const safeData = rawData.replace(/<\/script>/gi, '<\\/script>');
// Data lands inside a <script> block: a raw `<` in any value (agent-written run state) can form `</script`
// or `<!--<script` and end or derail it. `\u003c` parses back to the same character.
const safeData = rawData.replace(/</g, '\\u003c');
const [before, after] = template.split(DATA_MARKER);
const out = before + safeData + after;

Expand Down
22 changes: 22 additions & 0 deletions skills/browsergnome/scripts/check_invariants.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -21,9 +21,11 @@
*/

import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import process from 'node:process';
import { fileURLToPath } from 'node:url';
import { spawnSync } from 'node:child_process';
import { checkVersionPin, checkProviderPin, checkThirdPartyFlag } from './doctor.mjs';

const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../../..');
Expand Down Expand Up @@ -166,6 +168,26 @@ function main() {
report('Perf Map calibration SHA (pages.yml <-> perf-map.md)', calibration.ok,
calibration.ok ? calibration.workflowSha.slice(0, 8) : `workflow=${calibration.workflowSha ?? '?'} docs=${calibration.docsSha ?? '?'}`);

// 5. HTML builders escape every `<` in the data they inject into a <script> block
const HOSTILE = '</script><!--<script>';
const ESCAPED = HOSTILE.replace(/</g, '\\u003c');
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'bgn-escape-'));
const builders = [
['build_perf_map.mjs', { nodes: [{ id: HOSTILE, label: HOSTILE }], links: [] }],
['build_lcp_map.mjs', { url: HOSTILE, nodes: [{ class: 'module', label: HOSTILE, ms: 1, confidence: 'measured' }] }],
['build_run_report.mjs', { target: HOSTILE, iterations: [] }],
];
for (const [script, data] of builders) {
const inFile = path.join(tmp, 'in.json');
const outFile = path.join(tmp, 'out.html');
fs.writeFileSync(inFile, JSON.stringify(data));
// cwd = tmp: no package.json/.bgn there, so build_lcp_map resolves no prod URL and stays offline
const run = spawnSync(process.execPath, [path.join(repoRoot, 'skills/browsergnome/scripts', script), inFile, '--out', outFile], { cwd: tmp, encoding: 'utf8' });
const ok = run.status === 0 && fs.readFileSync(outFile, 'utf8').includes(ESCAPED);
report(`${script} escapes \`<\` in injected data`, ok, ok ? undefined : run.stderr.trim() || 'hostile payload not escaped');
}
fs.rmSync(tmp, { recursive: true, force: true });

console.log(`\n ${pass} passed, ${fail} failed`);
process.exit(fail ? 1 : 0);
}
Expand Down
Loading