test(#4652): failing-first coverage for the four unconfined boundaries

Phase 2 of epic #4636, absorbing #4327 and #4354. Tests only; no fix. These
MUST fail.

Four CLI boundaries join externally-supplied input to a managed root with no
containment validation. Each was driven through the real CLI and confirmed
unconfined before the assertions were written:

  todo complete <name>                     src/commands.cts cmdTodoComplete
  check predicate --phase-dir <dir>        check-command-router cmdCheckPredicate
  check decision-coverage-plan <dir>       check-command-router resolvePath
  check gap-analysis.plan-post <dir>       check-command-router

Boundary 1 is worse than the issue describes. #4327 reports that a traversal
name "resolves outside the todos root", which reads as an information leak.
Measured, it is destructive: `todo complete ../../../../b1out/leak.md` exited
0, MOVED the outside file into completed/, and unlinked the original. The file
was gone. cmdTodoComplete ends in fs.unlinkSync(sourcePath), so an unconfined
name does not merely read across the boundary, it consumes across it.

Boundary 2 reproduces #4354 exactly: a BLOCKING gate returned
{"block":false,"details":{"match":true}} sourced entirely from a SECURITY.md
in a directory the caller chose, outside the project.

Boundaries 3 and 4 are not named in the epic. Both accepted an outside phase
dir and exited 0.

Rows that exist because they are the ones nobody enumerates:

- ORDERING. A real file is created outside the todos root, then the traversal
  name targeting it is asserted rejected AND the outside file asserted still
  present and unmoved. #4327 notes the existence check and the move target
  BOTH follow the unvalidated join, so a rejection that lands after the read
  has already leaked — and, per the finding above, after the unlink has
  already destroyed.
- `a/../../b.md` — looks balanced, resolves outside.
- --dry-run must reject too; a preview must not leak a resolved outside path.
- ${PHASE_DIR} interpolation into a command-exit-zero predicate is the SECOND
  predicate kind, which a fix inside gate-predicate-evaluator.cts would miss.
- An absolute path INSIDE the project must still be accepted at every
  boundary — absolute is not a synonym for escaping.

Cross-boundary rows loop over one shared list of escaping inputs and assert
all four reject with the same shape, so four sites adopting one predicate
cannot drift into four rejection contracts.

Property tests cover BOTH directions — outside is always rejected, inside is
always accepted. A property asserting only rejection is satisfied by a
predicate that rejects everything, which is the degenerate-implementation trap
found in Phase 1's review. Both are seeded.

Regressions fold into the owning module suites rather than a new
tests/fix-NNNN-*.test.cjs, per scripts/lint-regression-test-names.cjs.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
sim
2026-09-12 09:09:20 -04:00
parent c99d7bb2be
commit 3925839f2a
4 changed files with 542 additions and 2 deletions

View File

@@ -13,6 +13,7 @@ const { test, describe, after, beforeEach, afterEach } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('fs');
const path = require('path');
const os = require('os');
const { runGsdTools, createTempProject, createTempDir, cleanup } = require('./helpers.cjs');
const { splitLines } = require('../gsd-core/bin/lib/text-lines.cjs');
const fc = require('./helpers/fast-check-setup.cjs');
@@ -798,6 +799,151 @@ describe('todo complete command', () => {
});
});
// ─────────────────────────────────────────────────────────────────────────────
// todo complete — containment boundary (#4327)
//
// cmdTodoComplete joins the externally-supplied `filename` into
// todosDir(cwd)/pending with NO containment validation (src/commands.cts).
// These tests prove the boundary is currently unconfined — a traversal name
// is neither rejected before the existence check nor before the move.
// ─────────────────────────────────────────────────────────────────────────────
describe('todo complete — containment boundary (#4327)', () => {
let tmpDir;
let pendingDir;
beforeEach(() => {
tmpDir = createTempProject();
pendingDir = path.join(tmpDir, '.planning', 'todos', 'pending');
fs.mkdirSync(pendingDir, { recursive: true });
});
afterEach(() => {
cleanup(tmpDir);
});
// ── Regressions: normal completion must keep working ─────────────────────
test('[regression] a valid existing todo name completes and the file moves to completed/', () => {
fs.writeFileSync(path.join(pendingDir, 'ok-name.md'), '---\nstatus: pending\n---\n');
const result = runGsdTools(['todo', 'complete', 'ok-name.md'], tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
assert.ok(!fs.existsSync(path.join(pendingDir, 'ok-name.md')), 'removed from pending');
assert.ok(
fs.existsSync(path.join(tmpDir, '.planning', 'todos', 'completed', 'ok-name.md')),
'present in completed',
);
});
test('[regression] a name with dots like "2026-09-12.some.todo.md" completes', () => {
fs.writeFileSync(path.join(pendingDir, '2026-09-12.some.todo.md'), '---\nstatus: pending\n---\n');
const result = runGsdTools(['todo', 'complete', '2026-09-12.some.todo.md'], tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
assert.ok(
fs.existsSync(path.join(tmpDir, '.planning', 'todos', 'completed', '2026-09-12.some.todo.md')),
'dotted-name todo completes',
);
});
test('[regression] a missing name still produces the existing "Todo not found" error', () => {
const result = runGsdTools(['todo', 'complete', 'does-not-exist.md'], tmpDir);
assert.ok(!result.success, 'must still fail');
assert.ok(result.error.includes('not found'), 'error must still mention "not found"');
});
// ── MUST BE REJECTED — currently unconfined (RED) ─────────────────────────
const ESCAPING_NAMES = ['../../escaped', '../sibling.md', 'sub/name.md', 'a/../../b.md'];
for (const name of ESCAPING_NAMES) {
test(`[RED #4327] "todo complete ${name}" must be rejected (currently unconfined)`, () => {
const targetPath = path.join(pendingDir, name);
fs.mkdirSync(path.dirname(targetPath), { recursive: true });
fs.writeFileSync(targetPath, '---\nstatus: pending\n---\nSENTINEL\n');
const result = runGsdTools(['todo', 'complete', name], tmpDir);
assert.strictEqual(
result.success,
false,
`"${name}" must be rejected as an escaping/invalid todo name (currently ` +
`${result.success ? 'SUCCEEDED — unconfined join, no containment check' : 'failed for an unrelated reason'})`,
);
});
}
test('[RED #4327] an absolute path outside the project is rejected', () => {
const outsideDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-todo-outside-'));
try {
const outsideFile = path.join(outsideDir, 'evil.md');
fs.writeFileSync(outsideFile, '---\nstatus: pending\n---\nSENTINEL\n');
const result = runGsdTools(['todo', 'complete', outsideFile], tmpDir);
assert.strictEqual(result.success, false, 'an absolute path outside the project must be rejected');
} finally {
cleanup(outsideDir);
}
});
// ── CRITICAL ORDERING (#4327): the existence check AND the move target ────
// both follow the unvalidated join, so a rejection that happens after the
// read has already leaked. Prove the outside file is neither read-through
// nor moved/deleted by a (today, absent) rejection.
test('[RED #4327] CRITICAL ORDERING: a traversal name resolving to a real outside file is rejected WITHOUT the outside file being moved or deleted', () => {
const outsideDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-todo-outside-'));
try {
const outsideFile = path.join(outsideDir, 'leak-target.md');
const sentinel = '---\nstatus: pending\n---\nSENTINEL-LEAK\n';
fs.writeFileSync(outsideFile, sentinel);
const relName = path.relative(pendingDir, outsideFile);
const result = runGsdTools(['todo', 'complete', relName], tmpDir);
assert.strictEqual(
result.success,
false,
`traversal name "${relName}" resolving to ${outsideFile} must be rejected`,
);
assert.ok(
fs.existsSync(outsideFile),
'the outside file must still exist — a rejected completion must not move/delete it',
);
assert.strictEqual(
fs.readFileSync(outsideFile, 'utf-8'),
sentinel,
'the outside file content must be byte-for-byte untouched',
);
const completedDir = path.join(tmpDir, '.planning', 'todos', 'completed');
if (fs.existsSync(completedDir)) {
assert.ok(
!fs.readdirSync(completedDir).includes('leak-target.md'),
'the outside file must never land inside completed/',
);
}
} finally {
cleanup(outsideDir);
}
});
test('[RED #4327] "todo complete <traversal> --dry-run" must be rejected — a dry run must not leak a resolved outside path', () => {
const outsideDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-todo-outside-'));
try {
const outsideFile = path.join(outsideDir, 'dry-leak.md');
fs.writeFileSync(outsideFile, '---\nstatus: pending\n---\n');
const relName = path.relative(pendingDir, outsideFile);
const result = runGsdTools(['todo', 'complete', relName, '--dry-run'], tmpDir);
assert.strictEqual(
result.success,
false,
`dry-run completion of traversal name "${relName}" resolving to ${outsideFile} must be rejected`,
);
} finally {
cleanup(outsideDir);
}
});
});
// ─────────────────────────────────────────────────────────────────────────────
// todo match-phase command
// ─────────────────────────────────────────────────────────────────────────────