Files
msd-core/docs/adr/2363-capability-instruction-surface-trust.md
Tom Boucher 9e4f0e99ad 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>
2026-08-18 22:35:54 -04:00

26 KiB

ADR-2363: A capability's skill body is an instruction surface — trusted, unscanned, and disclosed

  • Status: Accepted — ratified 2026-08-09 (originally Proposed 2026-08-09); see "Ratification" below
  • Date: 2026-08-09
  • Issue: #2363 (epic); Phase 0 tracked by #3247, Phase 1 by #3248
  • Amends: ADR-1244 — D5's disclosure gains a fifth class, and it is the first that is not an executable surface. ADR-2782 added the fourth (reviewer lanes); this adds the first non-executable one, which is why it needs its own classification rather than a fifth entry in the same list.
  • Related, and deliberately not amended: ADR-1577 — the untrusted-input boundary and its injection scanner. D2 explains at length why that control does not transfer to this surface. The two share the word "trust" and solve opposite problems.

Context

#2322 / PR #2340 made an installed third-party capability's skills/<stem>/SKILL.md materialize into the user's runtime skills directory, where it becomes an agent-invocable instruction file. That fix was correct and shipped the protections it set out to ship — all of them path-level:

  • skill stems bound to their declaring capability via registry.capabilityClusters, closing a cross-capability hijack;
  • stem sanitization plus isPathConfined on both read and write;
  • first-party-wins on stem collision;
  • prunable on uninstall, so removing a capability removes its instructions.

None of those say anything about what the file contains. external-descriptor-trust.cts — the module the staging path already imports — exports exactly isPathConfined and assertDescriptorConfined. There is no content scanner in it, and its own header says so: "Do NOT conflate with ADR-1577's prompt-injection circuit-breaker — separate concern sharing the word 'trust'."

Nothing was bypassed. The control does not exist. The independent security review of PR #2340 surfaced this and explicitly declined to invent policy, which is what produced #2363.

So the effective posture today is that capability skill bodies are fully trusted prose, injected verbatim into the agent's instruction surface — and that posture is nowhere written down. Worse, the one place it is implied states it wrongly. docs/explanation/capability-trust-model.md says:

For non-executable surfaces (skills, agents, workflow files), the disclosure note explains what they do but consent is lighter — they do not execute code.

That sentence is accurate about OS-level code execution and misleading about effect. The agent is the interpreter. A skill body does not execute code; it instructs the thing that does. The consent path was chosen on the basis of a property ("does not execute code") that is true and not the relevant one.

Three properties compounded to make this worth a decision rather than a footnote. The capability author controls the content (no scanning); before #2322 hardened it, could influence the name the content landed under; and, before the same fix, an uninstall did not remove the instructions from the agent's context. The latter two are closed. The first is untouched, undocumented, and is the one that determines whether "install a capability" means "grant arbitrary instructions to my agent."

This ADR answers that. It does not report a defect: nothing here violates a stated contract, because there was no stated contract.

Decision

D1 — A capability skill body is a trusted, unscanned instruction surface

Installing a third-party capability that ships skills grants that capability instruction reach: its SKILL.md bodies are copied verbatim into the user's agent instruction context and are not inspected. Their reach is bounded only by what the agent will do when told.

This is the same posture GSD already takes toward capability code — ADR-1244 D5's "artifact parity is not trust parity," where the barrier is consent, integrity, and reversibility rather than a sandbox. D1 states that the same barrier, and only that barrier, applies to instructions. A capability is trusted code the user chose to install, in the sense an npm dependency is; the difference is that here the "code" is prose and the runtime is a language model.

This posture is now recorded rather than emergent. That is the substance of D1: not a change in behavior, but the end of an unstated one.

D2 — Content scanning is rejected

GSD will not scan capability skill bodies for prompt-injection or suspicious content, at stage time or at any other time. This is a decision, not a deferral: there is no "until we build a scanner."

Three arguments, in order of weight.

Kerckhoffs's principle — decisive. A scanner's rule set ships inside the package the adversary installs. A hostile capability author reads the patterns, tests against them locally, and writes prose that passes on the first attempt. The scanner's entire security value would rest on the adversary not knowing the rules — the precise condition Kerckhoffs forbids relying on. The controls GSD does use survive the same test: an adversary with full knowledge of consent, integrity pinning, and reversibility gains nothing from that knowledge.

The threat model does not transfer from ADR-1577. That scanner works because it looks for instructions that are anomalous inside data — a directive embedded in a fetched web page has no business being there, and its presence is itself the signal. In a capability SKILL.md, instructions are the payload's legitimate form. There is no anomaly to detect. A hostile author does not need an injection signature, an evasion phrasing, or a forged <instructions> tag; plainly-worded malicious guidance is indistinguishable from plainly-worded legitimate guidance, because that is what the file is for. That ADR's scanner (hooks/gsd-read-injection-scanner.js) describes itself in its own header as "a static pattern match — NOT a semantic guard, NOT PromptArmor", and ships a documented false-positive exclusion list. Promoting it to a security boundary here would misrepresent what it is.

Goodhart's law — the scanner would make users less safe. A "scanned ✓" line in a consent summary reads to a user as "reviewed and safe." The measure becomes the target: a green scan displaces the judgment the consent prompt exists to provoke. Honest disclosure that a capability ships agent instructions produces a better security decision than a passing scan that means almost nothing.

A fourth, supporting argument: a pattern corpus is a permanently-maintained artifact with a real innovation-budget cost, and it would be maintained against an adversary who can read it.

D3 — Skills are reclassified as an instruction surface, not a "non-executable" one

The binary in ADR-1244 D5 — executable surfaces get consent, everything else is "non-executable" and gets a lighter note — is replaced by three classes:

Class Members Consent treatment
Executable surface hooks, command modules, MCP servers, reviewer lanes Full disclosure, consent-bound, signature-bound
Instruction surface (new) skills, agents, and any other artifact whose body reaches the agent's instruction context Disclosed by name at install; carries instruction reach, not code execution
Inert artifact everything else the bundle carries Note only

The middle class is the decision. "Non-executable" was never wrong as a statement about code; it was wrong as a risk classification, because it grouped an agent-instruction file with an inert asset on the strength of a property neither of them has.

Instruction surfaces deliberately do not contribute to hasExecutable. An instruction surface is not an executable surface, and folding it in would silently change executableSetChanged semantics and the auto-update re-consent trigger — a behavior change to a CRITICAL-blast-radius symbol, in order to express a classification that a separate field expresses cleanly.

D4 — Instruction surfaces are disclosed but are not folded into the v1 disclosureSignature

This is the decision with a consequence outside its own file, so it is decided here rather than in the implementation.

First, what disclosureSignature is and is not, because getting this wrong changes the argument. It is not the activation binding. hasProjectConsent compares one field and only one — contentHash, the recomputed full-bundle hash, which capability-consent.cts labels "THE security binding". The record's disclosureSignature is annotated "kept for re-consent-on-executable-change UX", and integrity "kept for the human disclosure UX, NOT the security binding". A global-scope install needs no consent record at all (ADR-1244 D5 — global installs sit under the user's own home and are trusted as such).

So re-encoding the signature would not deactivate anything. What it would do is perturb the signature of every capability that ships skills, so executableSetChanged reports a change on each one's next upgrade and fires a re-consent prompt — for capabilities whose behavior did not change at all.

That is not a new concern to weigh; it is a rule this corpus has already recorded. ADR-2782 D4 rule 5:

A capability with no reviewer body must not perturb its disclosure signature (D5). An absent body that changed the signature would force spurious re-consent across every installed capability.

D4 is that same rule applied to a new surface class rather than a fresh principle. Hyrum's law is what makes it binding: the signature's value is durable state on users' disks — stored on every consent record for exactly the re-consent-on-change UX — so changing its encoding changes an observable that shipped state already depends on.

Therefore:

  1. Instruction surfaces are disclosed in the pre-install summary (D5).
  2. They are not added to the v1 signature encoding. No stored consent record is perturbed and no spurious re-consent fires.
  3. Should a future decision require instruction surfaces to be signature-bound, it arrives as a versioned signature (v2) with an explicit migration — never as an in-place re-encoding of v1.

Point 3 is the part that makes this a decision rather than a punt: the door stays open and the mechanism for opening it is named.

The residual gap, stated plainly — and it is smaller than it first looks. At project scope there is no gap at all: bundleContentHash walks every entry under the bundle with no exclusions and hashes every regular file (failing closed on symlinks and non-regular files), so a single changed byte in a skill body already deactivates a project-scoped capability until re-consent. At global scope there is no consent record in the first place — a global install is trusted because it sits under the user's own home (ADR-1244 D5) — so a skill-body change on upgrade is not consent-gated there, and would not have been even if instruction surfaces were signature-bound. The gap global scope has is the one it already had for code, and D4 neither widens nor narrows it. What D4 declines to add is a re-consent prompt on skill-body change during an interactive upgrade; the v2 path above is where that would land if it is ever wanted.

D5 — The mechanism (Phase 1, shipped in #3248 — not by this ADR itself)

discloseExecutableSurfaces gains an instructionSurfaces collector, enumerating each skill stem the manifest contributes, collected through the same safeCollect wrapper as the existing four classes so a hostile value degrades only that class and the function stays total for any manifest shape. The pre-install consent summary names those skills as an instruction surface. The signature behavior implements D4 exactly, pinned by a test that asserts what happens to a pre-existing consent record rather than leaving it incidental.

The design is deliberately additive — a new independent collector and a new field, with no change to the four existing collectors and none to hasExecutable — because get_impact rates discloseExecutableSurfaces CRITICAL at 196 affected symbols.

Implementation note (Phase 1 scope). The mechanism above discloses skills only. D3's class table names skills, agents as instruction surfaces, and that classification stands unchanged. agents[] are not disclosed by this mechanism, because third-party agents[] are never actually staged into the agent's instruction context: stageSkillsForRuntimeAsSkills (src/install-profiles.cts) unions third-party skills in via readInstalledCapabilitySkill, whereas stageAgentsForRuntimeWithConverter (same file) takes only a source directory and has no registry-aware third-party path. Disclosing agents here would have named a surface that does not exist. Whether third-party agents[] should be staged at all — and, if so, whether D3's agents half becomes reachable — is an open question for the maintainer, not a silent omission. Agents remain classified as an instruction surface either way; they are simply not yet a staged one.

Consequences

What improves. The boundary is written down on both sides: a capability author reading docs/how-to/develop-a-capability.md learns their skill body ships verbatim and unscanned, and a user reading capability-trust-model.md learns what installing a skill-bearing capability grants. A skill-only capability — which used to disclose nothing at all — now names its instruction surface at the consent moment.

What does not change. No behavior changes in this ADR's phase. No stored consent record is perturbed and no spurious re-consent fires, now or under Phase 1 (D4). Skills remain the intended, low-friction contribution path; this is disclosure, not discouragement.

What is deliberately not fixed.

  • Disclosure is not a safety property. Naming an instruction surface tells a user a capability ships agent instructions. It says nothing about whether they are benign — exactly as an integrity SHA says nothing about whether the pinned bundle is safe. GSD is honest about this for code and is now honest about it for instructions.
  • First-party skills are equally unscanned. Their assurance is provenance — they are the shipped package, and the GSD Core release process is their control — not content inspection. No content control exists on the first-party side either, and no reader should infer one.
  • Global-scope skill-body change on upgrade is not consent-gated. That is true of a global install's code too, and D4 does not change it either way. See D4.

Ratification bar. This ADR flips to Accepted when #3248 has merged, the consent summary renders instruction surfaces, and the D4 signature behavior is pinned by a passing test. All three were met on 2026-08-09 — see the Ratification section below.

Ratification (2026-08-09): Proposed → Accepted

Ratified against docs/adr/README.md → "Ratifying a stale Proposed". Tracked by #3256. All four bar conditions, with evidence:

1. The decided mechanism demonstrably exists in the tree.

Decision Where it lives
D1 / D2 — posture recorded, scanning rejected this file; docs/explanation/capability-trust-model.md
D3 — three-class model the class table in docs/explanation/capability-trust-model.md; the author-side statement in docs/how-to/develop-a-capability.md
D4 — signature untouched disclosureSignature in src/capability-trust.cts carries no instruction-surface term; pinned by the signature-invariance tests in tests/instruction-surface-disclosure.security.test.cjs
D5 — the mechanism instructionSurfaces on Disclosure, its safeCollect-wrapped collector, and summarizeInstructionSurfaces (called from both branches of summarizeDisclosure) in src/capability-trust.cts

Verified by gsd-test for the exact merged HEAD, plus CI's Linux and Windows lanes, the coverage gate, and the 80% mutation gate on changed files.

2. The owning issue is closed as completed. #2363 — stateReason: COMPLETED, as are both phase children #3247 and #3248.

3. No material part is unshipped. This ADR's own stated bar named three conditions — #3248 merged, the consent summary rendering instruction surfaces, and the D4 signature behavior pinned by a passing test. All three hold.

4. No later ADR supersedes it, and no approved issue plans its graduation as separate work.

The judgment call this ratification rests on, stated plainly

D3 classifies instruction surfaces as "skills, agents"; the shipped mechanism discloses skills only. That is not a half-built decision, and the distinction is what makes ratification honest rather than a rubber stamp:

Third-party agents[] are never staged into the agent's instruction context. stageSkillsForRuntimeAsSkills unions third-party skills in via readInstalledCapabilitySkill; stageAgentsForRuntimeWithConverter takes only a source directory and has no registry-aware third-party path. Disclosing agents would therefore have named a surface that does not exist — a false claim in a security prompt, which is worse than the omission it would have cured. D5 discloses everything that is actually staged, which is what the decision requires.

The README's first ratification trap is "shipped code is necessary, not sufficient — verify the decision, not just the code." Applied here: the decision is that a body reaching the agent's instruction context must be disclosed. Every such body is disclosed. Whether third-party agents[] should be staged, and thereby become instruction surfaces in fact rather than only in classification, is recorded in D5 as an open maintainer question — a separate product decision, not an unshipped part of this one.

Alternatives considered

Accept the posture with no mechanism (#2363 option 1 alone) — record D1 and D2, correct the docs, and stop. Rejected by the maintainer: it leaves a skill-only capability disclosing nothing at the consent moment, which is the one moment a user can act.

Scan on stage (#2363 option 2) — rejected in D2, on Kerckhoffs, threat-model non-transfer, and Goodhart.

Fold instruction surfaces into the v1 signature — rejected in D4, under ADR-2782 D4 rule 5 (a change that perturbs the signature without changing behavior forces spurious re-consent across every installed capability), with Hyrum's law as the reason that rule binds. Superseded by the versioned-v2 path rather than closed off.

Add skills to hasExecutable — rejected in D3: a silent semantics change to executableSetChanged and the auto-update trigger, on a CRITICAL-radius symbol, to say something a separate field says cleanly.

Full consent parity with hooks — require the same ceremony for a skill contribution as for a hook. Rejected as consent fatigue. capability-trust-model.md already rejects a per-run egress prompt on exactly these grounds: inflating every contribution to hook-level ceremony trains users to click through, degrading the prompt that matters.

Docs correction with no ADR — rejected: CONTRIBUTING.md requires an ADR for an architectural decision, and a docs edit with no recorded decision reproduces the unrecorded posture this ADR exists to end.

Amendment (2026-08-18): bundleContentHash is no longer exclusion-free

The residual-gap section above states that bundleContentHash "walks every entry under the bundle with no exclusions." As of #3631 that is no longer literally true, so the sentence is corrected here rather than edited in place.

The trigger was a usability defect, not a security one — running a Python-backed capability's own test suite wrote __pycache__ inside the bundle (an empty __pycache__ directory was enough, because the walk emits a typed DIR marker per directory), which changed the recomputed hash and silently deactivated the capability. The FIRST fix shipped for this (basename-excluded __pycache__/.pytest_cache/.DS_Store, plus any .pyc/.pyo file anywhere, with an excluded DIRECTORY skipped from recursion entirely) was itself found UNSAFE by two orthogonal reviews and was corrected before merge to next. That draft is not described further here; this section describes the shipped exclusion. .DS_Store was part of that first draft and was later removed from the exclusion set entirely (not merely narrowed) — see the amendment below.

bundleContentHash now excludes from the DIGEST ONLY:

  • A __pycache__ or .pytest_cache DIRECTORY's own marker (its bare existence no longer moves the hash) — but the directory is ALWAYS recursed into; every non-excluded child underneath is still hashed. (Skipping recursion was the unsafe draft's hole: an excluded directory became an unbounded, permanently-unhashed region a manifest hook script could point into — __pycache__/run.js — ship benign, get consent, then rewrite freely afterward.)
  • A .pyc/.pyo FILE, but ONLY when its immediate parent directory's basename is exactly __pycache__. A .pyc/.pyo anywhere else (bundle root, scripts/, a directory literally named cache.pyc, etc.) stays hashed, because a sourceless legacy .pyc there is genuinely importable/executable. (The unsafe draft matched the suffix anywhere in the tree.)

.DS_Store is deliberately NOT excluded (the first draft excluded it; that exclusion was removed entirely, not narrowed). An excluded filename is a permanently unhashed name that a declared hook script could still be pointed at — e.g. hooks/.DS_Store — and isSafeHookScriptPath (below) was hardened only for the __pycache__/.pytest_cache/.pyc/.pyo shapes, not for .DS_Store. It was also unrelated to #3631's reported symptom (Python bytecode caching from a test run), so it was not worth carrying as a permanently unhashed name. .DS_Store now stays bound like any other file.

isSafeHookScriptPath (src/capability-lifecycle.cts and its mirror gsd-core/bin/lib/capability-validator.cjs) rejects any DECLARED script path containing a __pycache__/.pytest_cache path segment, or whose basename ends .pyc/.pyo — a file named e.g. x.pyc can contain perfectly valid JavaScript and would be executed by node regardless of extension. This raises the bar for a manifest-declared hook script, but it is NOT a containment bound on the excluded region: it only ever inspects the declared script path string itself, not what that script requires/imports at runtime. A hashed, consent-covered hooks/run.js containing require('../__pycache__/mod.pyc') reaches the excluded region in one hop — Node loads an unregistered extension through its default .js handler — and the validator never sees that reference. Once loaded that way, the referenced .pyc is free to be rewritten post-consent with the digest unmoved. See the corrected bound below.

D4's argument is unaffected. The claim this ADR rests on is that a single changed byte in a skill body deactivates a project-scoped capability until re-consent. Skill bodies are .md files and are not in the exclusion set, so that still holds exactly as written.

ACCEPTED RESIDUAL RISK — stated plainly, not glossed over. An earlier version of this section claimed CPython "validates [a cached .pyc] against its sibling source" before trusting it. That claim is FALSE and was disproven by execution: CPython's default (timestamp-based) invalidation compares the cached .pyc header's stored mtime and size against the CURRENT source file's mtime and size — it does NOT check source content. Both mtime and size are ordinary file metadata an attacker who can already write to the bundle can forge. A forged __pycache__/mod.cpython-3XX.pyc whose header mtime/size were copied from an unmodified, still-hashed mod.py executes without moving this digest. Before this change, any write under __pycache__ (even an empty directory) was detected; after it, a __pycache__/*.pyc matching that narrow shape is not. This is accepted deliberately — it stops routine bytecode caching from silently deactivating capabilities, which is the usability defect this exclusion exists to fix — and it is bounded by: (1) the attacker must already have POST-CONSENT write access to the bundle (this is not a remote-exploit surface); (2) everything outside __pycache__/*.pyc — including sourceless legacy .pyc/.pyo files anywhere else in the bundle — remains hashed. NOT a bound: the excluded region IS reachable by indirection from any hashed, consent-covered script — a require/import of a __pycache__/*.pyc path is one hop, not only CPython's own bytecode loading path described above — so isSafeHookScriptPath raises the bar for a DECLARED hook script surface but does not contain the risk. KNOWN LIMITATION: .pytest_cache's CONTENTS still change the digest as ordinary hashed files — only its directory marker is suppressed, so this residual risk does not extend to .pytest_cache.

Two properties were preserved deliberately and are pinned by tests: the exclusion is applied AFTER the symlink/non-regular fail-closed rejection (so a symlink named x.pyc still throws rather than being silently skipped), and excluded entries still count toward the walk's size/count caps. node_modules, dist, and build were considered and deliberately NOT excluded: their contents are required/executed at runtime, so dropping them from the digest would stop consent binding executable content.