fix(#3631): exclude only __pycache__-resident bytecode from the consent digest (#3650)

* 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>
This commit is contained in:
Tom Boucher
2026-08-18 22:35:54 -04:00
committed by GitHub
parent 02a36d3db9
commit 9e4f0e99ad
8 changed files with 697 additions and 17 deletions

View File

@@ -15,8 +15,9 @@
* project ledger — `'' === ''` is no binding) NOR to the `disclosureSignature` alone (which covers
* only executable surfaces, so a declarative-only cap has a constant signature and a repo-write
* attacker could swap `capability.json` for a malicious gate/contribution while consent still
* matched). `bundleContentHash` is recomputed by the loader at load over EVERY file in the bundle
* (manifest AND artifacts AND identity), so any tamper — declarative-only swap, hook-script edit,
* matched). `bundleContentHash` is recomputed by the loader at load over the bundle (manifest AND
* artifacts AND identity), excluding only derived Python bytecode/cache noise (#3631 — see
* `bundleContentHash`'s own doc comment), so any tamper — declarative-only swap, hook-script edit,
* empty-integrity local install — changes the hash and leaves the cap inactive. `integrity` and
* `disclosureSignature` remain on the record for the human disclosure + re-consent-on-executable-
* change UX (TRUST-2); they are NO LONGER the security binding.
@@ -265,10 +266,31 @@ function normalizeSepBytes(rel: Buffer): Buffer {
/**
* Recursively collect every REGULAR file AND every DIRECTORY under `absDir` as RAW-BYTE POSIX-relative
* paths (`rel`, relative to the bundle root), refusing to follow symlinks out of the bundle. Bounded:
* throws if the entry count or total byte size exceeds the caps (fail closed — a hostile/runaway tree
* never hashes unbounded content). A non-regular entry encountered IN the tree (FIFO/device) is a
* fail-closed throw — a bundle must be plain files and directories.
* paths (`rel`, relative to the bundle root), refusing to follow symlinks out of the bundle. Two
* NARROW, RECURSION-PRESERVING exclusions apply (#3631, amended after two orthogonal reviews found the
* original wholesale-skip UNSAFE — see the doc comment on `bundleContentHash` for the full rationale
* and the accepted residual risk):
*
* 1. A DIRECTORY whose basename is exactly `__pycache__` or `.pytest_cache`: its own TAG_DIR marker
* is SUPPRESSED (not pushed), but it is STILL RECURSED INTO — every child that is not itself
* excluded is still hashed. Suppressing only the marker is what keeps an empty/all-excluded such
* directory from moving the digest; recursing is what closes the original hole (H1): skipping
* recursion made an excluded directory an unbounded, permanently-unhashed region a manifest could
* point a hook `script` at (`__pycache__/run.js`) to install benign, then rewrite freely post-consent.
* 2. A FILE whose name ends `.pyc` or `.pyo` is excluded ONLY when its PARENT directory's basename is
* exactly `__pycache__` (checked byte-exact, never `.pytest_cache`) — a `.pyc`/`.pyo` anywhere else
* (bundle root, `scripts/`, a directory literally named `cache.pyc`, etc.) is hashed normally,
* because a sourceless legacy `.pyc` there IS importable and executable (H2).
*
* A regular FILE literally named `__pycache__` or `.pytest_cache` is NOT excluded (only a DIRECTORY of
* that basename gets marker-suppression) — it is hashed like any other file. A DIRECTORY literally
* named `x.pyc` is NOT excluded either — the suffix rule only ever applies to files. All exclusion
* checks run strictly AFTER the lstat-backed symlink/non-regular rejections below, so a symlink or
* device masquerading under an excluded name is still fail-closed rejected rather than silently
* skipped. Bounded: throws if the entry count or total byte size exceeds the caps (fail closed — a
* hostile/runaway tree never hashes unbounded content); an excluded entry still counts toward BOTH
* caps (the caps guard the walk itself, not the digest). A non-regular entry encountered IN the tree
* (FIFO/device) is a fail-closed throw — a bundle must be plain files and directories.
*
* #1459 finding 2 (MED/HIGH, ROUND 6): the enumeration ITSELF is bounded. Instead of
* `fs.readdirSync` (which loads + sorts a WHOLE directory before the count cap — so a malicious bundle
@@ -287,11 +309,76 @@ function normalizeSepBytes(rel: Buffer): Buffer {
* abs/rel paths are concatenated at the BYTE level, so an invalid-UTF-8 filename is never lossily
* decoded — two filenames that differ only in invalid bytes produce distinct rel byte strings.
*
* @param absDir the absolute directory to scan, as RAW BYTES (Buffer).
* @param relDir the relpath of `absDir` from the bundle root, as RAW BYTES (Buffer; empty at the root).
* @param count the CUMULATIVE entry counter shared across the whole recursive walk (fail-closed at the cap).
* @param absDir the absolute directory to scan, as RAW BYTES (Buffer).
* @param relDir the relpath of `absDir` from the bundle root, as RAW BYTES (Buffer; empty at the root).
* @param dirBasename the basename of `absDir` itself, as RAW BYTES (Buffer) — the PARENT basename every
* entry collected at this level shares. Threaded down so a `.pyc`/`.pyo` FILE can be
* excluded only when ITS parent is exactly `__pycache__` (empty at the bundle root).
* @param count the CUMULATIVE entry counter shared across the whole recursive walk (fail-closed at the cap).
*/
function collectBundleEntries(absDir: Buffer, relDir: Buffer, acc: BundleEntry[], total: { bytes: number }, count: { n: number }): void {
// #3631 (amended — see bundleContentHash's doc comment for the H1/H2 rationale and accepted residual
// risk): basename rules for what is excluded FROM THE DIGEST. They are NOT excluded from the walk's
// resource caps — see the count.n / total.bytes accounting below, which still sees every excluded
// entry.
//
// Exact byte comparison, case-sensitive, deliberately: CPython always writes a lowercase
// `__pycache__` directory, so byte-exact matching keeps the digest identical across case-insensitive
// filesystems (e.g. default macOS/Windows) instead of varying with how a name happens to be spelled
// on disk.
//
// DELIBERATELY NOT on this list: node_modules, dist, build, or similar. Those hold code that is
// actually required/executed at runtime, so excluding them from the digest would stop consent from
// binding executable content — turning a usability bug (noisy re-consent prompts) into a
// supply-chain hole (a swapped dependency that never re-triggers consent).
/** Directory basenames whose TAG_DIR marker is suppressed — but the directory is STILL RECURSED INTO. */
const PYCACHE_DIR_BASENAMES: readonly Buffer[] = [
Buffer.from('__pycache__'),
Buffer.from('.pytest_cache'),
];
/** FILE-name suffixes excluded ONLY when the file's parent directory basename is `__pycache__` (see below). */
const PYCACHE_FILE_SUFFIXES: readonly Buffer[] = [
Buffer.from('.pyc'),
Buffer.from('.pyo'),
];
/** The one parent directory basename that gates the `.pyc`/`.pyo` FILE-suffix exclusion — never `.pytest_cache`. */
const PYCACHE_PARENT_BASENAME = Buffer.from('__pycache__');
/**
* True when `name` (a raw-byte dirent basename) is a DIRECTORY whose TAG_DIR marker must be
* suppressed — `__pycache__` or `.pytest_cache`, exact byte match. The caller still recurses into it;
* this predicate answers ONLY "skip the marker", never "skip the subtree".
*/
function isPycacheDirBasename(name: Buffer): boolean {
for (const exact of PYCACHE_DIR_BASENAMES) {
if (Buffer.compare(name, exact) === 0) return true;
}
return false;
}
/** True when raw-byte basename `name` ends in `.pyc` or `.pyo` (byte-suffix match, never utf8-decoded). */
function hasPycacheFileSuffix(name: Buffer): boolean {
for (const suffix of PYCACHE_FILE_SUFFIXES) {
if (name.length >= suffix.length && Buffer.compare(name.subarray(name.length - suffix.length), suffix) === 0) {
return true;
}
}
return false;
}
/**
* True when a FILE with basename `name`, inside a directory whose OWN basename is `dirBasename`, must
* be excluded from the digest: a `.pyc`/`.pyo` suffix whose parent directory basename is exactly
* `__pycache__` (byte-exact — never `.pytest_cache`, so a `.pyc` sitting directly inside a
* `.pytest_cache` dir is still hashed). A `.pyc`/`.pyo` file anywhere else — bundle root, `scripts/`,
* a directory literally named `cache.pyc`, etc. — is NOT excluded (H2): a sourceless legacy `.pyc`
* there is importable and executable, so it must stay bound to consent.
*/
function isExcludedFileBasename(name: Buffer, dirBasename: Buffer): boolean {
return hasPycacheFileSuffix(name) && Buffer.compare(dirBasename, PYCACHE_PARENT_BASENAME) === 0;
}
function collectBundleEntries(absDir: Buffer, relDir: Buffer, dirBasename: Buffer, acc: BundleEntry[], total: { bytes: number }, count: { n: number }): void {
let dir: fs.Dir;
try {
// RAW-BYTE streaming open: dirent names are Buffers (encoding: 'buffer'), so an invalid-UTF-8
@@ -344,19 +431,34 @@ function collectBundleEntries(absDir: Buffer, relDir: Buffer, acc: BundleEntry[]
throw new Error(`bundleContentHash: refusing to hash a symlink in the bundle: "${abs.toString('utf8')}"`);
}
if (st.isDirectory()) {
// #3631 (amended, H1): exclusion is checked HERE — after the symlink fail-closed throw above —
// so a symlink named e.g. "__pycache__" or "x.pyc" is never silently skipped; only a REAL
// (lstat-confirmed) dir/file can be excluded. An excluded directory's own dirent was already
// counted toward count.n above (the cap guards the walk itself). Only the TAG_DIR MARKER is
// suppressed for `__pycache__`/`.pytest_cache` — the directory is ALWAYS recursed into so every
// non-excluded child underneath is still hashed (closing H1: no unbounded unhashed region).
if (isPycacheDirBasename(name)) {
collectBundleEntries(abs, rel, name, acc, total, count);
continue;
}
// Emit a typed DIR marker for THIS directory (so an empty dir is bound), then recurse into it.
acc.push({ abs, rel, kind: 'dir' });
collectBundleEntries(abs, rel, acc, total, count);
collectBundleEntries(abs, rel, name, acc, total, count);
continue;
}
if (!st.isFile()) {
throw new Error(`bundleContentHash: refusing to hash a non-regular file in the bundle: "${abs.toString('utf8')}"`);
}
acc.push({ abs, rel, kind: 'file' });
// #3631: an excluded FILE (a __pycache__/*.pyc or *.pyo) still counts its bytes toward the
// total-size cap below — exclusion answers "does this bind the digest?", not "is this safe to
// read unbounded?" — so it must never become a way to smuggle unbounded bytes past
// BUNDLE_MAX_TOTAL_BYTES.
total.bytes += st.size;
if (total.bytes > BUNDLE_MAX_TOTAL_BYTES) {
throw new Error(`bundleContentHash: bundle size exceeds ${BUNDLE_MAX_TOTAL_BYTES} bytes (refusing)`);
}
if (isExcludedFileBasename(name, dirBasename)) continue;
acc.push({ abs, rel, kind: 'file' });
}
}
@@ -383,8 +485,39 @@ const TAG_DIR = Buffer.from([0x02]);
/**
* The recomputed full-bundle content hash (#1459 CB-1/CB-2/TRUST2-5) — the SECURITY BINDING. A
* `sha512-<base64>` over a DETERMINISTIC, INJECTIVE, LOSSLESS serialization of EVERY regular file
* AND directory under `capDir` (recursively).
* `sha512-<base64>` over a DETERMINISTIC, INJECTIVE, LOSSLESS serialization of every regular file
* AND directory under `capDir` (recursively), with two NARROW exclusions (#3631, amended after two
* orthogonal reviews found the original wholesale directory-skip UNSAFE):
* - A `__pycache__` or `.pytest_cache` DIRECTORY's own TAG_DIR marker is suppressed, but it is
* ALWAYS recursed into — every non-excluded child underneath is still hashed.
* - A `.pyc`/`.pyo` FILE is excluded ONLY when its immediate parent directory's basename is exactly
* `__pycache__`; a `.pyc`/`.pyo` anywhere else (bundle root, `scripts/`, etc.) is hashed normally,
* since a sourceless legacy `.pyc` there is importable and executable.
* `node_modules`, `dist`, `build`, and similar are deliberately NOT excluded: their contents are
* executed/required at runtime, so dropping them from the digest would stop consent from binding
* executable content.
*
* ACCEPTED RESIDUAL RISK (documented, not a defect to re-litigate): CPython's default `.pyc`
* invalidation (timestamp-based) validates a cached bytecode file against its SOURCE's mtime and size
* only — NOT against the source's content — and both mtime and size are attacker-settable by whoever
* can already write to the bundle. So a forged `__pycache__/mod.cpython-3XX.pyc` whose header mtime/size
* were copied from an unmodified, still-hashed `mod.py` will execute without moving this digest. Before
* this exclusion existed that write was detected (any file under `__pycache__` moved the hash); after
* it, it is not, for files matching the narrow `__pycache__/*.pyc` shape above. This is accepted
* deliberately — the alternative (H1's original wholesale skip, or hashing every regenerated bytecode
* file) either reopens an unbounded unhashed region or makes consent fire on routine bytecode caching —
* and is bounded by: (1) the attacker must already have POST-CONSENT write access to the bundle; (2)
* everything outside `__pycache__/*.pyc` — including sourceless legacy `.pyc`/`.pyo` files anywhere
* else — remains hashed. NOT a bound, despite `isSafeHookScriptPath` (capability-lifecycle.cts /
* capability-validator.cjs) rejecting a manifest-declared hook `script` that names a
* `__pycache__`/`.pytest_cache` segment or ends `.pyc`/`.pyo`: the excluded region is still reachable
* by ONE HOP of indirection from any hashed, consent-covered script — a `require`/`import` of a
* `__pycache__/*.pyc` module path is not itself a declared script and the validator never sees it —
* so a hashed `hooks/run.js` can load a `__pycache__/mod.pyc` whose bytes are then free to change
* post-consent with the digest unmoved. The validator guard raises the bar for DECLARED surfaces; it
* does not contain the risk. KNOWN LIMITATION: `.pytest_cache`'s CONTENTS still change the digest as
* normal files — only its directory marker is suppressed, so this residual risk does NOT extend to
* `.pytest_cache`.
*
* Canonicalization (#1459 findings 1 + 4 — the prior `relpath + NUL + content + NUL` over utf8-decoded
* STRINGS was non-injective, lossy in CONTENT, AND lossy in the PATH component):
@@ -412,8 +545,12 @@ const TAG_DIR = Buffer.from([0x02]);
function bundleContentHash(capDir: string): string {
// Resolve to an absolute path, then carry it as RAW BYTES so the walk never lossily decodes a name.
const rootBytes = Buffer.from(path.resolve(capDir));
// The root's own basename is threaded as the initial `dirBasename` so the parent-basename check for
// a `.pyc`/`.pyo` FILE applies even to a file placed DIRECTLY at the bundle root (root literally
// named `__pycache__` is the only case this matters for, and it is a correct, if exotic, match).
const rootBasename = Buffer.from(path.basename(path.resolve(capDir)));
const entries: BundleEntry[] = [];
collectBundleEntries(rootBytes, Buffer.alloc(0), entries, { bytes: 0 }, { n: 0 });
collectBundleEntries(rootBytes, Buffer.alloc(0), rootBasename, entries, { bytes: 0 }, { n: 0 });
// Sort by the raw-byte (separator-normalized) relpath so the digest is identical on Windows and POSIX,
// and is independent of the on-disk creation/readdir order. Tie-break on kind so a (degenerate, never
// produced on a real fs) file-and-dir same-relpath pair still has a stable order.

View File

@@ -416,6 +416,28 @@ function confinedSharedFile(runtimeDir: string, relFile: unknown): string | null
// isSafeHookScriptPath; see confinedBundleScript for why). Only [A-Za-z0-9._/-], no leading
// `-` segment, no `..`, not absolute.
const SAFE_HOOK_SCRIPT_RE = /^[A-Za-z0-9._/-]+$/;
// #3631 (defense-in-depth, mirrors capability-validator.cjs — KEEP BOTH IN SYNC): a declared script
// path must not point into the space bundleContentHash (capability-consent.cts) excludes from the
// consent-binding digest. A file whose basename ends `.pyc`/`.pyo` can contain perfectly valid
// JavaScript and would be executed by `node` regardless of extension, and a `__pycache__`/
// `.pytest_cache` segment marks a directory whose digest marker is suppressed — so a MANIFEST-DECLARED
// executable surface must never be able to reach either, or the exclusion becomes reachable from a
// path an attacker fully controls at declare-time rather than only via post-consent tamper.
// Regex asymmetry is DELIBERATE, KEEP BOTH RULES IN SYNC WITH capability-validator.cjs (byte-identical
// text, verified by the isSafeHookScriptPath parity test in tests/capability-registry.test.cjs):
// (i) PYCACHE_SUFFIX_RE is case-INSENSITIVE (`/i`) on purpose — a validator should be STRICTER than
// the digest it defends, so it rejects `x.PYC` too even though bundleContentHash's own suffix
// match (hasPycacheFileSuffix, capability-consent.cts) is byte-exact and would still hash it.
// (ii) The __pycache__/.pytest_cache SEGMENT match is case-SENSITIVE to match the digest's own
// byte-exact, case-sensitive directory-basename comparison (CPython always writes a lowercase
// `__pycache__`) — a validator segment match looser than the digest here would reject paths the
// digest would still hash, which is over-strict in the wrong direction for a defense-in-depth
// check layered on top of an already-correct digest.
// (iii) The `[/\\]` backslash alternations in both regexes are defensive/UNREACHABLE in practice:
// SAFE_HOOK_SCRIPT_RE (above) already rejects any backslash character outright, so a script
// string containing `\` never reaches either PYCACHE_*_RE check.
const PYCACHE_SEGMENT_RE = /(?:^|[/\\])(__pycache__|\.pytest_cache)(?:[/\\]|$)/;
const PYCACHE_SUFFIX_RE = /\.(pyc|pyo)$/i;
function isSafeHookScriptPath(script: string): boolean {
if (typeof script !== 'string' || script.length === 0) return false;
if (!SAFE_HOOK_SCRIPT_RE.test(script)) return false;
@@ -425,6 +447,8 @@ function isSafeHookScriptPath(script: string): boolean {
for (const seg of segments) {
if (seg.startsWith('-')) return false;
}
if (PYCACHE_SEGMENT_RE.test(script)) return false;
if (PYCACHE_SUFFIX_RE.test(path.basename(script))) return false;
return true;
}