`cmdVerifyKeyLinks` resolved `from:` and `to:` with `path.join(cwd, <value>)` where the value comes verbatim from plan YAML. `path.join` normalizes `../` rather than rejecting it, so a plan travelling with a repository could name any file the process can read, and the command reports whether the link's `pattern` matched it — an arbitrary-file-read oracle reachable from `verify-phase`. Both reads now go through `validatePath` in src/security.cts, the existing realpath-based confinement seam already used at 11 call sites. Not a missing capability — a bypassed one. Two defects in that seam, found by adversarial review and fixed here because they affect all 11 callers: 1. A dangling in-project symlink escaped confinement. A link to an EXISTING outside path was refused (realpath lands outside) while a link to a MISSING outside path took the parent-resolution fallback and was accepted — an existence oracle for arbitrary absolute paths. lstat succeeds on a dangling link and throws ENOENT on a truly absent path; an unresolvable link is now refused. A symlink resolving inside the project is still accepted. 2. A canonicalized base was compared against an uncanonicalized path when a file and its parent were both missing, wrongly refusing legitimate in-project paths on any non-canonical cwd (every macOS temp dir). This was a live regression in this PR: the wave-pending classification (#1202) depends on the not-yet-created case. Resolution now walks up to the nearest existing ancestor. Two adjacent aborts fixed: the from: read sat outside the per-link try, so a non-ENOENT errno killed the whole command; and an empty from: read the cwd directory, throwing EISDIR. Both now fail per-link. The issue was filed as a fourth ADR-0174 consolidation loss. It is not one — validatePath/requireSafePath never went away, only the SDK's name for the concept did. This is an instance of epic #3473's F2 family. The ADR-0174 loss count is three. Closes #3493 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -6,6 +6,7 @@ const { test, describe, 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, createTempGitProject, cleanup } = require('./helpers.cjs');
|
||||
const { gitOrThrow } = require('./helpers/git-fixture.cjs');
|
||||
const { runHook } = require('./helpers/process-seam.cjs');
|
||||
@@ -1844,6 +1845,222 @@ describe('verify key-links command', () => {
|
||||
`Expected "No must_haves.key_links" in error: ${output.error}`
|
||||
);
|
||||
});
|
||||
|
||||
// ── #3493: path confinement — from:/to: are untrusted plan frontmatter and
|
||||
// must never be readable outside the project directory. ────────────────────
|
||||
|
||||
test('from: traversal outside project is rejected — not read, per-link failure only (#3493)', () => {
|
||||
const outsideDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-3493-outside-'));
|
||||
try {
|
||||
const outsideFile = path.join(outsideDir, 'secret.txt');
|
||||
fs.writeFileSync(outsideFile, 'top-secret-oracle-bait\n');
|
||||
const traversalFrom = path.relative(tmpDir, outsideFile);
|
||||
|
||||
writePlanWithKeyLinks(tmpDir, [
|
||||
`- from: "${traversalFrom.split(path.sep).join('/')}"`,
|
||||
' to: "src/b.js"',
|
||||
]);
|
||||
fs.writeFileSync(path.join(tmpDir, 'src', 'b.js'), 'module.exports = {};\n');
|
||||
|
||||
const result = runGsdTools('verify key-links .planning/phases/01-test/01-01-PLAN.md', tmpDir);
|
||||
assert.ok(result.success, `Command failed: ${result.error}`);
|
||||
|
||||
const output = JSON.parse(result.output);
|
||||
assert.strictEqual(output.links[0].verified, false);
|
||||
assert.strictEqual(output.links[0].path_rejected, 'from');
|
||||
assert.strictEqual(output.all_verified, false);
|
||||
// Load-bearing: pins the confinement-specific detail text. This is NOT
|
||||
// the same as asserting the secret content is absent from the JSON —
|
||||
// that assertion is tautological here (no code path ever echoes file
|
||||
// *content* into `detail`/output, confined or not — a no-pattern link
|
||||
// only ever reports whether `to:` text appears in the source, never
|
||||
// the source's own bytes), so it would pass even without the
|
||||
// path-confinement fix. This assertion, by contrast, DOES fail
|
||||
// pre-fix: without confinement the outside file is actually read, the
|
||||
// no-pattern branch falls through to "Target not referenced in
|
||||
// source", and `path_rejected` is never set at all.
|
||||
assert.strictEqual(
|
||||
output.links[0].detail,
|
||||
'Source path rejected — resolves outside the project directory',
|
||||
);
|
||||
} finally {
|
||||
cleanup(outsideDir);
|
||||
}
|
||||
});
|
||||
|
||||
test('to: traversal is rejected — outside file content never read even when pattern would match it (#3493)', () => {
|
||||
const outsideDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-3493-outside-'));
|
||||
try {
|
||||
const outsideFile = path.join(outsideDir, 'secret.txt');
|
||||
fs.writeFileSync(outsideFile, 'oracleMarkerXYZ\n');
|
||||
const traversalTo = path.relative(tmpDir, outsideFile);
|
||||
|
||||
writePlanWithKeyLinks(tmpDir, [
|
||||
'- from: "src/a.js"',
|
||||
` to: "${traversalTo.split(path.sep).join('/')}"`,
|
||||
' pattern: "oracleMarkerXYZ"',
|
||||
]);
|
||||
// Pattern deliberately absent from the (valid) source so the check must
|
||||
// fall through to the target read — proving the oracle stays closed.
|
||||
fs.writeFileSync(path.join(tmpDir, 'src', 'a.js'), 'const x = 1;\n');
|
||||
|
||||
const result = runGsdTools('verify key-links .planning/phases/01-test/01-01-PLAN.md', tmpDir);
|
||||
assert.ok(result.success, `Command failed: ${result.error}`);
|
||||
|
||||
const output = JSON.parse(result.output);
|
||||
assert.strictEqual(
|
||||
output.links[0].verified,
|
||||
false,
|
||||
`Expected verified:false — a matching outside file must never flip this true: ${JSON.stringify(output.links[0])}`,
|
||||
);
|
||||
assert.strictEqual(output.links[0].path_rejected, 'to');
|
||||
} finally {
|
||||
cleanup(outsideDir);
|
||||
}
|
||||
});
|
||||
|
||||
test('absolute from: path is rejected (#3493)', () => {
|
||||
const absoluteDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-3493-absolute-'));
|
||||
try {
|
||||
const absoluteFrom = path.join(absoluteDir, 'gsd-3493-absolute-probe.txt');
|
||||
fs.writeFileSync(absoluteFrom, 'irrelevant\n');
|
||||
|
||||
writePlanWithKeyLinks(tmpDir, [
|
||||
`- from: "${absoluteFrom.split(path.sep).join('/')}"`,
|
||||
' to: "src/b.js"',
|
||||
]);
|
||||
fs.writeFileSync(path.join(tmpDir, 'src', 'b.js'), 'module.exports = {};\n');
|
||||
|
||||
const result = runGsdTools('verify key-links .planning/phases/01-test/01-01-PLAN.md', tmpDir);
|
||||
assert.ok(result.success, `Command failed: ${result.error}`);
|
||||
|
||||
const output = JSON.parse(result.output);
|
||||
assert.strictEqual(output.links[0].verified, false);
|
||||
assert.strictEqual(output.links[0].path_rejected, 'from');
|
||||
} finally {
|
||||
cleanup(absoluteDir);
|
||||
}
|
||||
});
|
||||
|
||||
test('a symlink inside the project pointing outside it is rejected as from: (#3493)', (t) => {
|
||||
const outsideDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-3493-outside-'));
|
||||
try {
|
||||
const outsideFile = path.join(outsideDir, 'secret.txt');
|
||||
fs.writeFileSync(outsideFile, 'top-secret-oracle-bait\n');
|
||||
const symlinkPath = path.join(tmpDir, 'src', 'linked.js');
|
||||
try {
|
||||
fs.symlinkSync(outsideFile, symlinkPath, 'file');
|
||||
} catch (error) {
|
||||
if (error && ['EPERM', 'EACCES', 'ENOTSUP'].includes(error.code)) {
|
||||
t.skip('symlink creation is not available on this platform');
|
||||
return;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
writePlanWithKeyLinks(tmpDir, [
|
||||
'- from: "src/linked.js"',
|
||||
' to: "src/b.js"',
|
||||
]);
|
||||
fs.writeFileSync(path.join(tmpDir, 'src', 'b.js'), 'module.exports = {};\n');
|
||||
|
||||
const result = runGsdTools('verify key-links .planning/phases/01-test/01-01-PLAN.md', tmpDir);
|
||||
assert.ok(result.success, `Command failed: ${result.error}`);
|
||||
|
||||
const output = JSON.parse(result.output);
|
||||
assert.strictEqual(output.links[0].verified, false);
|
||||
assert.strictEqual(output.links[0].path_rejected, 'from');
|
||||
} finally {
|
||||
cleanup(outsideDir);
|
||||
}
|
||||
});
|
||||
|
||||
test('normal in-project from:/to: still verifies with no path_rejected field (#3493 no-regression)', () => {
|
||||
writePlanWithKeyLinks(tmpDir, [
|
||||
'- from: "src/a.js"',
|
||||
' to: "src/b.js"',
|
||||
' pattern: "import.*b"',
|
||||
]);
|
||||
fs.writeFileSync(path.join(tmpDir, 'src', 'a.js'), "import { x } from './b';\n");
|
||||
fs.writeFileSync(path.join(tmpDir, 'src', 'b.js'), 'exports.x = 1;\n');
|
||||
|
||||
const result = runGsdTools('verify key-links .planning/phases/01-test/01-01-PLAN.md', tmpDir);
|
||||
assert.ok(result.success, `Command failed: ${result.error}`);
|
||||
|
||||
const output = JSON.parse(result.output);
|
||||
assert.strictEqual(output.all_verified, true);
|
||||
assert.strictEqual(output.links[0].verified, true);
|
||||
assert.strictEqual(output.links[0].path_rejected, undefined);
|
||||
});
|
||||
|
||||
test('a rejected first link does not abort the second, valid link (#3493 per-link failure)', () => {
|
||||
const outsideDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-3493-outside-'));
|
||||
const outsideFile = path.join(outsideDir, 'secret.txt');
|
||||
fs.writeFileSync(outsideFile, 'irrelevant\n');
|
||||
const traversalFrom = path.relative(tmpDir, outsideFile);
|
||||
|
||||
writePlanWithKeyLinks(tmpDir, [
|
||||
`- from: "${traversalFrom.split(path.sep).join('/')}"`,
|
||||
' to: "src/b.js"',
|
||||
'- from: "src/a.js"',
|
||||
' to: "src/b.js"',
|
||||
' pattern: "import.*b"',
|
||||
]);
|
||||
fs.writeFileSync(path.join(tmpDir, 'src', 'a.js'), "import { x } from './b';\n");
|
||||
fs.writeFileSync(path.join(tmpDir, 'src', 'b.js'), 'exports.x = 1;\n');
|
||||
|
||||
const result = runGsdTools('verify key-links .planning/phases/01-test/01-01-PLAN.md', tmpDir);
|
||||
assert.ok(result.success, `Command failed: ${result.error}`);
|
||||
|
||||
const output = JSON.parse(result.output);
|
||||
assert.strictEqual(output.links.length, 2, `Expected both links reported: ${JSON.stringify(output.links)}`);
|
||||
assert.strictEqual(output.links[0].path_rejected, 'from');
|
||||
assert.strictEqual(output.links[0].verified, false);
|
||||
assert.strictEqual(output.links[1].path_rejected, undefined);
|
||||
assert.strictEqual(output.links[1].verified, true, `Second link must still evaluate: ${JSON.stringify(output.links[1])}`);
|
||||
assert.strictEqual(output.all_verified, false);
|
||||
|
||||
cleanup(outsideDir);
|
||||
});
|
||||
|
||||
test('empty from: is a malformed link, not a path-confinement rejection (#3493)', () => {
|
||||
writePlanWithKeyLinks(tmpDir, [
|
||||
'- from: ""',
|
||||
' to: "src/b.js"',
|
||||
]);
|
||||
fs.writeFileSync(path.join(tmpDir, 'src', 'b.js'), 'module.exports = {};\n');
|
||||
|
||||
const result = runGsdTools('verify key-links .planning/phases/01-test/01-01-PLAN.md', tmpDir);
|
||||
assert.ok(result.success, `Command failed: ${result.error}`);
|
||||
|
||||
const output = JSON.parse(result.output);
|
||||
assert.strictEqual(output.links[0].verified, false);
|
||||
assert.strictEqual(output.links[0].path_rejected, undefined);
|
||||
assert.strictEqual(
|
||||
output.links[0].detail,
|
||||
'Source file not found (from: must be a relative file path; describe components/endpoints in via:)',
|
||||
);
|
||||
});
|
||||
|
||||
test('empty to: with a non-matching pattern is a malformed link, not a path-confinement rejection (#3493)', () => {
|
||||
writePlanWithKeyLinks(tmpDir, [
|
||||
'- from: "src/a.js"',
|
||||
' to: ""',
|
||||
' pattern: "oracleMarkerXYZ"',
|
||||
]);
|
||||
fs.writeFileSync(path.join(tmpDir, 'src', 'a.js'), 'const x = 1;\n');
|
||||
|
||||
const result = runGsdTools('verify key-links .planning/phases/01-test/01-01-PLAN.md', tmpDir);
|
||||
assert.ok(result.success, `Command failed: ${result.error}`);
|
||||
|
||||
const output = JSON.parse(result.output);
|
||||
assert.strictEqual(output.links[0].verified, false);
|
||||
assert.strictEqual(output.links[0].path_rejected, undefined);
|
||||
assert.strictEqual(
|
||||
output.links[0].detail,
|
||||
'Pattern "oracleMarkerXYZ" not found in source or target',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user