fix(#4652): confine every boundary that joins argv to a managed root
Phase 2 of epic #4636, absorbing #4327 and #4354. Implements ADR-4650 decision 3: containment is a boundary concern — the predicate runs where external input enters, not at whichever interior call site remembered. Four boundaries now validate against their managed root and reject with a USAGE-shaped error before touching the filesystem: todo complete <name> -> todosDir(cwd) check predicate --phase-dir <dir> -> projectDir check decision-coverage-plan <dir> -> projectDir (via resolvePath) check gap-analysis.plan-post <dir> -> projectDir #4327 understated its own severity. It reports that a traversal name "resolves outside the todos root", which reads as an information leak. Measured, it was destructive: the command exited 0, MOVED the outside file into completed/, and unlinked the original. cmdTodoComplete ends in fs.unlinkSync(sourcePath), so an unconfined name consumed across the boundary rather than merely reading across it. Validation now precedes every fs call — existsSync, readFileSync, ensureDir, writeSync, unlinkSync — and both halves of the move are confined, so neither source nor destination can land outside the root. --dry-run is rejected on the same terms; a preview must not leak a resolved outside path either. #4354 reproduces exactly: a BLOCKING gate returned block:false sourced entirely from a SECURITY.md in a caller-chosen directory outside the project. THE HARDER HALF, found by the isolated adversarial review of the first attempt: validating a path and then using a DIFFERENT one closes nothing. The first fix validated `--phase-dir` joined against `--cwd`, then passed the RAW unjoined value into the predicate context. gate-predicate-evaluator uses it as-is and findPhaseArtifact resolves a relative path against the REAL process cwd — so validation and the read used two different roots whenever process.cwd() differed from --cwd. Reproduced: running from a directory holding a plan with `secret_field: LEAKED_VALUE`, a predicate declared against an empty --cwd project exited 0 and returned "actual":"LEAKED_VALUE". The rule now applied at all three router sites: **use the validated resolved path, never the raw input.** Independently re-verified after the fix — the lookup resolves in the --cwd project and no value leaks. gate-predicate-evaluator.cts is untouched and still imports no fs. Confining in the router is what keeps that pure-leaf contract intact AND covers ${PHASE_DIR} interpolation into command-exit-zero, which an evaluator-local fix would have missed entirely. Also fixed, same review: `todo complete .` and `..` passed containment (they resolve to the pending dir, which IS inside the root) and then threw an uncaught EISDIR with an absolute-path stack trace. Now a clean USAGE rejection naming the real reason — "todo name is not a file" — rather than borrowing the escape message, which would have stated something false. Ripples discharged BEFORE the verification checkpoint rather than after, per the Phase 1 retrospective: docs/reference/gate-predicates.md and docs/CLI-TOOLS.md document the new constraints, CONTEXT.md records why containment lives at the router rather than the evaluator, the changeset is written, and the install-tree goldens were regenerated to confirm unchanged (no new shipped file) rather than assumed. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -255,6 +255,44 @@ describe('check predicate --phase-dir — containment boundary (#4354)', () => {
|
||||
assert.strictEqual(parsed.block, false, 'in-project phase-dir evaluation must still pass');
|
||||
});
|
||||
|
||||
test('[regression #4652] a relative --phase-dir must resolve against --cwd, not the real process cwd, and must not leak the outside file', () => {
|
||||
// The real process cwd (outsideDir) contains a foreign SECURITY.md; the
|
||||
// CLI is told --cwd projDir with a relative --phase-dir '.'. Before #4652,
|
||||
// cmdCheckPredicate validated the joined (projDir + '.') path but passed
|
||||
// the RAW, un-joined '.' into ctx.phaseDir, which findPhaseArtifact then
|
||||
// resolved against the real process cwd (outsideDir) — leaking foreign
|
||||
// frontmatter. The fix must reject this, and the leaked value must never
|
||||
// appear in the output.
|
||||
fs.writeFileSync(
|
||||
path.join(outsideDir, 'SECURITY.md'),
|
||||
'---\nstatus: LEAKED_VALUE\n---\n# Security\n',
|
||||
);
|
||||
|
||||
const predicate = JSON.stringify({
|
||||
kind: 'artifact-frontmatter-equals',
|
||||
artifact: 'SECURITY.md',
|
||||
field: 'status',
|
||||
equals: 'NOPE',
|
||||
});
|
||||
|
||||
const result = runGsdTools(
|
||||
['--json-errors', 'check', 'predicate', '--cwd', projDir, '--predicate', predicate, '--phase-dir', '.', '--raw'],
|
||||
outsideDir,
|
||||
);
|
||||
|
||||
const combinedOutput = `${result.output || ''}${result.error || ''}`;
|
||||
assert.ok(
|
||||
!combinedOutput.includes('LEAKED_VALUE'),
|
||||
`the outside file's frontmatter value must never leak into the output (got: ${combinedOutput})`,
|
||||
);
|
||||
assert.strictEqual(
|
||||
result.success && JSON.parse(result.output).block === false,
|
||||
false,
|
||||
`a relative --phase-dir must not resolve against the real process cwd and must not pass ` +
|
||||
`(currently: ${combinedOutput})`,
|
||||
);
|
||||
});
|
||||
|
||||
test('[regression] no --phase-dir at all still falls back to cwd and evaluates', () => {
|
||||
fs.writeFileSync(
|
||||
path.join(projDir, 'SECURITY.md'),
|
||||
|
||||
Reference in New Issue
Block a user