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:
sim
2026-09-12 09:33:32 -04:00
parent 3925839f2a
commit 374300da17
9 changed files with 132 additions and 7 deletions

View File

@@ -777,7 +777,7 @@ describe('resolvePath / check decision-coverage-plan — containment boundary (#
contextPath,
'# Phase Context\n\n<decisions>\n## Implementation Decisions\n\n- **D-01:** Use pattern X\n</decisions>\n',
);
fs.writeFileSync(path.join(phaseDir, '01-PLAN.md'), '# Plan\n\nImplements D-01.\n');
fs.writeFileSync(path.join(phaseDir, '01-PLAN.md'), '# Plan\n\n## tasks\n\n- D-01: Use pattern X\n');
const relPhaseDir = path.relative(tmpDir, phaseDir);
const result = runDecisionCoveragePlan([], relPhaseDir, contextPath, tmpDir);
@@ -792,7 +792,7 @@ describe('resolvePath / check decision-coverage-plan — containment boundary (#
contextPath,
'# Phase Context\n\n<decisions>\n## Implementation Decisions\n\n- **D-01:** Use pattern X\n</decisions>\n',
);
fs.writeFileSync(path.join(phaseDir, '01-PLAN.md'), '# Plan\n\nImplements D-01.\n');
fs.writeFileSync(path.join(phaseDir, '01-PLAN.md'), '# Plan\n\n## tasks\n\n- D-01: Use pattern X\n');
const result = runDecisionCoveragePlan([], phaseDir, contextPath, tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);

View File

@@ -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'),

View File

@@ -871,6 +871,25 @@ describe('todo complete — containment boundary (#4327)', () => {
});
}
// '.' and '..' resolve to the pending dir itself (which IS inside the
// root, so containment passes) but are not a todo name — before #4652
// this fell through to an uncaught EISDIR with an absolute-path stack
// trace instead of a clean rejection.
for (const name of ['.', '..']) {
test(`[regression #4652] "todo complete ${name}" is rejected cleanly (no uncaught EISDIR / stack trace)`, () => {
const result = runGsdTools(['todo', 'complete', name], tmpDir);
assert.strictEqual(result.success, false, `"${name}" must be rejected`);
assert.ok(
!/at\s+\S+\s+\(.*\.c?ts?:\d+/.test(result.error || ''),
`rejection must not leak a stack trace (got: ${result.error})`,
);
assert.ok(
!(result.error || '').includes('EISDIR'),
`rejection must be a clean USAGE error, not an uncaught EISDIR (got: ${result.error})`,
);
});
}
test('[RED #4327] an absolute path outside the project is rejected', () => {
const outsideDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-todo-outside-'));
try {