fix(#4461): make code-review summary extraction shell-safe (#4533)

* fix(#4461): make code-review summary extraction shell-safe

Emitted-Drift-Ack-Growth: code-review.md — use a literal heredoc for shell-safe SUMMARY parsing

* chore: add changeset for #4533

* fix(#4461): keep heredoc outside command substitution

* chore: rerun CI after Windows timeout

* test(#4461): execute the summary heredoc adversarially

* test(#4461): normalize adversarial paths for Git Bash

* test(#4461): keep adversarial fixture valid on Windows

* test(#4461): scope heredoc regression claim

---------

Co-authored-by: Tom Boucher <trekkie@nomorestars.com>
This commit is contained in:
Michel Moreira
2026-09-16 04:23:11 -03:00
committed by GitHub
parent 740ba0d8a3
commit 49f313d611
3 changed files with 123 additions and 38 deletions

View File

@@ -139,6 +139,80 @@ function parseFrontmatterCritical(frontmatter) {
// file list, and must strip em-dash descriptions and parentheticals.
// ---------------------------------------------------------------------------
describe('Bug 1 — compute_file_scope SUMMARY parser', () => {
test('#4461: the shipped compute_file_scope bash fence parses verbatim', () => {
const src = readFileNormalized(WORKFLOW_PATH);
const stepStart = src.indexOf('<step name="compute_file_scope">');
assert.notStrictEqual(stepStart, -1, 'compute_file_scope step must exist');
const marker = src.indexOf('EXTRACTED=$(', stepStart);
assert.notStrictEqual(marker, -1, 'Tier-2 SUMMARY extractor must exist');
const fenceStart = src.lastIndexOf('```bash\n', marker);
const fenceEnd = src.indexOf('\n```', marker);
assert.ok(fenceStart !== -1 && fenceEnd !== -1, 'Tier-2 SUMMARY bash fence must be complete');
const script = src.slice(fenceStart + '```bash\n'.length, fenceEnd);
const result = runHook('-n', ['-c', script], {
interpreter: 'bash',
timeoutMs: PROBE_TIMEOUT_MS,
});
assert.equal(result.exitCode, 0, `bash rejected the shipped fence:\n${result.stderr}`);
});
test('#4461: the shipped extractor helper treats adversarial SUMMARY text and argv as inert data', () => {
const src = readFileNormalized(WORKFLOW_PATH);
const helperStart = src.indexOf(' extract_summary_files() {');
// Anchor on the real loop gate, not the whitespace-only separator above it:
// editors are entitled to trim trailing spaces without changing behavior.
const helperEnd = src.indexOf('\n if [ -n "$SUMMARIES" ]; then', helperStart);
assert.ok(helperStart !== -1 && helperEnd !== -1, 'extract_summary_files helper must be extractable');
const helper = src.slice(helperStart, helperEnd);
const dir = createTempDir('gsd-4461-adversarial-');
try {
const sentinel = path.join(dir, 'MUST-NOT-EXIST');
// This directly executes the shipped helper's argv boundary. It does
// not claim the workflow's outer SUMMARY-list iteration preserves
// whitespace; that pre-existing shell-word-splitting behavior remains
// tracked separately by #4109.
// Double quotes are not legal in Windows filenames. Keep the argv
// adversarial with shell syntax, whitespace, and a quote that is valid
// on every supported filesystem; the SUMMARY payload below exercises
// a literal double quote independently.
const summary = path.join(dir, "SUMMARY $(not-a-command) 'quoted'.md");
// Git Bash launches the native Windows Node binary in CI. Forward-slash
// absolute paths survive that argv boundary on both platforms, while a
// raw drive path's backslashes are MSYS quoting syntax rather than data.
const shellSentinel = sentinel.replace(/\\/g, '/');
const shellSummary = summary.replace(/\\/g, '/');
const dollarPath = `src/$(touch ${shellSentinel}).js`;
const backtickPath = `src/\`touch ${shellSentinel}\`.js`;
const quotePath = 'src/"quoted".js';
fs.writeFileSync(summary, [
'---',
'key-files:',
' created:',
` - ${dollarPath}`,
' modified:',
` - ${backtickPath}`,
` - ${quotePath}`,
'---',
'',
].join('\n'));
const script = ['set -eu', helper, 'extract_summary_files "$SUMMARY_PATH"'].join('\n');
const result = toLegacyResult(runHook('-c', [script, 'bash'], {
interpreter: 'bash',
env: { ...process.env, SUMMARY_PATH: shellSummary },
timeoutMs: PROBE_TIMEOUT_MS,
}));
assert.equal(result.status, 0, result.stderr);
assert.deepStrictEqual(result.stdout.trim().split('\n'), [dollarPath, backtickPath, quotePath]);
assert.ok(!fs.existsSync(sentinel), 'SUMMARY payload must never execute command substitutions');
} finally {
cleanup(dir);
}
});
test('extracts only key-files.created and key-files.modified entries', () => {
const yaml = [
'key-files:',