skill-vetting security invariants
The skill-vetting advisory tripwire is a SessionStart hook that watches
installed skills and injects one advisory line when one appears, changes,
disappears, or cannot be fully observed. Its threat model treats the author of
watched content (ADV-1) as controlling every byte, name, type and shape under
the watched trees; same-privilege local code (ADV-2) is out of scope by design.
These invariants shipped hardened in PR #83 (7cd2af6, merged 2026-07-26)
over a multi-round cross-family campaign; the design record
reviews/2026-07-25-skill-vetting-snapshot-threat-model.md defines goals
G1–G6 + G3-SHELL and invariants I1–I11 (I12–I17 belong to the unimplemented
round-8 design — see the last section). Each invariant below violates "breaks
users you cannot see": a silently-missed trojan edit, a discarded BLOCK, a hung
session start.
Boundary map
| Path |
Role |
hooks/skill_snapshot.py |
observation + persistence primitive: scan_root, snapshot_tree, canonical digest, baseline I/O, and the digest/record/status CLI. Decides no verdicts. |
hooks/skill-vetting-advisory.py |
thin SessionStart hook: resolves roots, compares snapshot vs baseline, composes/delivers the advisory, advances the baseline. No filesystem-walking of its own. |
skills/skill-vetting/SKILL.md |
the procedure an agent follows to vet a candidate; §3 binds a verdict to an exit-0 digest. |
The two files are imported in-process, never spawned — passing attacker
path-bytes across an argv/JSON boundary would add an encoding surface for no
isolation gain (both run at the same privilege). Do not "improve" this into a
subprocess split.
Invariants
INV-1 — a structural refusal must never set the shared budget (I8)
- Trigger: any change to the walk budget,
MAX_DEPTH/MAX_OPEN_DIRS, or a
thought like "unify the caps so exhaustion is handled in one place".
- Invariant:
MAX_ENTRIES/MAX_TOTAL_BYTES are SHARED across candidates and
set budget["stop"]; MAX_DEPTH/MAX_OPEN_DIRS are PER-CANDIDATE structural
refusals that do NOT DIRECTLY set budget["stop"] — they mark that candidate
anomalous (hooks/skill_snapshot.py:82-89; the budget dict at :326). Their
anomaly record still flows through _entry, which increments the shared entry
counter and CAN trip MAX_ENTRIES at the boundary like any other entry
(:209-214); what is forbidden is a structural cap poisoning the budget merely
because it fired.
- Done-check:
grep -n 'budget\["stop"\] = True' hooks/skill_snapshot.py —
every set (at :214/:256) traces to a resource cap
(MAX_ENTRIES/MAX_TOTAL_BYTES), never to depth/fanout.
- Incident: conflating them let one skill holding 31 empty nested directories
hand every LATER candidate the same constant, content-independent digest, so
is_changed was permanently False for them (round-6 "budget poisoner"; I8 in
the threat model).
- Don't simplify back to: "a breach stops that skill's scan" — a shared stop
poisons siblings.
- ✅ depth breach → that candidate is anomalous but NOT
partial (partial is
budget truncation, INV-3); it doesn't DIRECTLY stop the shared budget (its
anomaly entry is still counted normally). ❌ "one exhaustion flag is cleaner" —
that flag is the poisoner.
INV-2 — CLI and hook are different entry points that converge on shared encoding (G6/I1)
- Trigger: adding a branch for a new file type in
snapshot_tree or
scan_root, or "the CLI and hook can each format this case".
- Invariant: the CLI (
digest/record) enters through snapshot_tree
(hooks/skill_snapshot.py:289; callsites :912,:1055); the hook enters
through scan_root (:551, called from skill-vetting-advisory.py:303). They
are DIFFERENT entry points that AGREE because both route every terminal
(non-regular) case through the shared _anomaly_snap (:526) — snapshot_tree
at :328-383, scan_root at :617-634 (plus :649) — rather than
hand-rolling. A regular file is encoded via _entry (:209): the CLI's
top-level loose-file case at :365, nested files/anomalies in _walk_dir
(:390) at :413-515. The invariant is the shared terminal encoder, not a
single entry function.
- Done-check:
grep -n '_anomaly_snap(' hooks/skill_snapshot.py shows every
terminal case in BOTH snapshot_tree and scan_root going through it; neither
entry point hand-rolls a divergent terminal tuple.
- Incident: round-5 unified only the symlink branch; FIFO and unreadable-dir
still diverged, so a CLI-recorded BLOCK was judged "changed" and discarded next
session.
- Don't simplify back to: two hand-written encoders "kept in sync by tests",
or hand-rolling a terminal case in one entry point.
- ✅ new terminal type → one
_anomaly_snap(kind,…) case reached by BOTH entry
points. ❌ a bespoke tuple in snapshot_tree "just for FIFOs" that scan_root
doesn't match.
INV-3 — a partial snapshot is never stored as a real digest (I9)
- Trigger: touching baseline write, the
partial flag, or skip_baseline.
- Invariant: a budget-truncated snapshot is
partial (its digest describes
the SCAN STATE, not the tree); the hook sets skip_baseline = partial and old is None and skips only the baseline WRITE, never the advisory
(hooks/skill-vetting-advisory.py:489); a partial re-observation of a known
candidate keeps the prior record (entry = dict(old) at :491). The record
CLI likewise refuses to bind a verdict to a partial digest.
- Done-check:
grep -n 'skip_baseline\|partial' hooks/skill-vetting-advisory.py
— a partial and old is None candidate is advised but not baselined.
- Incident (a fix that regressed): an earlier guard did
continue here,
skipping the whole candidate — a single 4200-file skill (vs MAX_ENTRIES=4096)
then made the hook emit ZERO bytes every session, taking every candidate after
it into silence (documented inline at :480-488).
- Don't simplify back to: "skip the candidate if we couldn't fully read it."
- ✅ skip the baseline write only. ❌
continue — that is the silent-miss the
whole component exists to prevent.
INV-4 — path identity comes from proven arrival, dot-paths fail closed
- Trigger: editing
_resolve_dot_base, _strip_trailing, or any path
normalization; or reaching for os.path.normpath.
- Invariant:
_resolve_dot_base (hooks/skill_snapshot.py:847, predicate at
:894-903) resolves a .-form path ONLY on ARRIVAL EVIDENCE, not spelling:
$PWD is set, realpath($PWD) == realpath(the path), and $PWD is not itself a
symlink. So a bare . — OR a child/.. that resolves back to the current
non-symlink $PWD — passes; a .. that resolves AWAY from $PWD (the common
case: cwd IS the candidate, so .. is the parent≠$PWD), unset $PWD, and
$PWD≠the-path all return _REFUSE → the CLI prints "REFUSED" and exits 2
(:929-937 digest, :1008- record), distinct from a badname ANOMALY (exit 3,
:938-949). It is NOT ".. always refuses" — that is the very oversimplification
the incident below rejects. normpath is never CALLED (_strip_trailing at
:187 does only trailing-slash stripping; sole normalization entry, callsites
:324/577/919/996).
- Done-check:
grep -nE 'normpath\(' hooks/skill_snapshot.py returns nothing
(the word survives only in comments :189/190/192/323); AND drive the resolver
BOTH ways from the repo root, where subdir skills/ is directly under $PWD so
skills/.. resolves back to $PWD: python3 hooks/skill_snapshot.py digest skills/.. RESOLVES (exit 3 by content, arrival evidence — a .. that does NOT
refuse), while env -u PWD python3 hooks/skill_snapshot.py digest skills/..
REFUSES (exit 2, no $PWD). The regression is
test_every_dot_spelling_without_arrival_evidence_refuses
(hooks/test-skill_snapshot.py:1317).
- Incident:
normpath(b"") == b"." turned a fail-closed empty/unset path into
a clean digest of the CWD with exit 0 — the exact green light §3 binds
SAFE-TO-PROPOSE to; dot-path laundering recurred across ~5 spellings before
converging on "evidence of arrival, not spelling".
- Don't simplify back to: "just reject
.." — <link>/sub/../. and unset
$PWD still launder (the reviewers' own proposed narrow fix, refuted).
- ✅
$PWD-proven, non-symlink → resolve. ❌ text-level .. handling.
INV-5 — a failed first write is ADVISED, not silent; the baseline stays absent (N-CORRECTION)
- Trigger: editing baseline bootstrap / the
absent state.
- Invariant: first-run bootstrap announces its count BEFORE the write. If that
first write FAILS, the run still ADVISES (it is not silent) and does NOT write
the baseline — so the next session sees
absent again and re-advises rather
than silently bootstrapping whatever the content has become (G5
deliver-before-advance makes the un-written baseline safe). There is no
GUARANTEED baseline-state failure marker; _log may leave a best-effort warning
in advisory.log (hooks/skill-vetting-advisory.py:120-150, called at
:701-704), but that is a forensic trace, not the safety mechanism — safety
comes from advise-and-don't-advance. (Doc-vs-code note — RESOLVED by PR #118
(c7951bc, the #104 fix): the canonical docs USED TO overstate this —
threat-model I6 said a failed write "is logged" and both READMEs called advisory
logging "auditable" — while _log swallows all exceptions (advisory.py, the
bare except Exception around the log write). PR #118 added the best-effort
qualifier to I6 and both READMEs, so the docs now match the code; the CODE stays
authoritative. Threat-model I6 is still at :296 (content updated in place); the
README anchors moved (429/268 → ~`471/302`) — prefer the I6 goal / the READMEs'
logging note over a line number. See UNCERTAINTY.md.)
- Done-check: run the regression —
python3 hooks/test-skill-vetting-advisory.py HookE2E.test_failed_first_write_does_not_silently_bootstrap_a_change prints OK
(it lives at hooks/test-skill-vetting-advisory.py:132 and asserts the advisory
context is non-empty — "a failed first write must not be silent" — AND that the
baseline file was not written).
- Incident: a transient first-write failure used to leave no trace and emit
nothing, so the next session treated changed content as a fresh silent bootstrap
and never advised it (round-6; the inline test comment records this).
- Don't simplify back to: silently writing the baseline on a first run before
the advisory is delivered, or treating a failed write as a completed one.
INV-6 — audit visibility is monotone with severity, and fails closed (status)
- Trigger: editing
_cli_status, its partition, or its exit codes.
- Invariant:
status splits unvetted / adverse_verdicts_in_baseline /
vetted_safe; absent→exit 0 (truthful empty BASELINE state — nothing recorded
yet; NOT a claim that no skills are installed — status reads the baseline and
never lstats, :1170), corrupt/stale→exit 4 (audit could not be performed),
any adverse verdict→exit 3 (hooks/skill_snapshot.py:1152-1189).
- Done-check: two ways. (1) Isolated live fixture (nothing real touched):
CLAUDE_CONFIG_DIR=$(mktemp -d) python3 hooks/skill_snapshot.py status; echo $?
→ prints {"baseline": "absent"} (json.dumps spacing) and exit 0 on an empty
world. (2) Drive the absent/unusable AND adverse branches via the regressions —
python3 hooks/test-skill_snapshot.py CommandLine.test_status_exit_code_separates_absent_from_unusable CommandLine.test_status_surfaces_an_adverse_verdict_instead_of_hiding_it prints
OK (a corrupt/stale baseline exits 4; a recorded BLOCK shows in
adverse_verdicts_in_baseline and exits 3).
- Incident: the old partition was
status != "vetted" and never printed the
verdict, so recording BLOCK on a live trojan REMOVED it from the only list the
command printed — the more damning the verdict, the cleaner the report. The
field was renamed off _still_installed because status reads the baseline and
never lstats (:1170-1173).
- Don't simplify back to: "one list of the unvetted."
- ✅ severity raises visibility + a nonzero exit. ❌ a success exit when the audit
itself could not run.
INV-7 — a hostile candidate name never becomes shell syntax; digest/hook anomaly it, record refuses only SAFE (G3)
- Trigger: editing SKILL.md §3, or "add quoting so the candidate name is safe
on the command line".
- Invariant (current, procedural): a candidate name that fails the display
allowlist is an anomaly (exit 3) for
digest and for the hook; for record it
refuses only SAFE-TO-PROPOSE — so a BLOCK/SUSPECT verdict CAN still be recorded
against a hostile-named tree, which reports no anomaly. SKILL.md §3 directs: a
hostile name is itself strong evidence → record BLOCK in prose with the reason,
do NOT substitute it into any shell command (skills/skill-vetting/SKILL.md,
the $(...)/backtick discussion at :181-197; the verdict-fail-closed note at
:111).
- Done-check: the implemented half is executable —
python3 hooks/test-skill_snapshot.py CommandLine.test_record_refuses_safe_on_hostile_name
prints OK. That proves ONLY that record refuses SAFE-TO-PROPOSE for a hostile
DISPLAY NAME; it does NOT prove shell non-substitution (G3-SHELL, still untested).
What must hold: no
basename that FAILS the display allowlist enters shell source, quoted or
otherwise — it routes to a BLOCK-in-prose path; an allowlisted name relies on the
inert alphabet ([A-Za-z0-9._-]), NOT quoting (never the control). G3-SHELL
itself has NO test and remains open (below); in-tree filenames are a separate
open hole.
- Incident: round-6 "fixed" the RCE by double-quoting placeholders and the
commit message CLAIMED it fixed — false: quotes do not stop
$()/backticks, and
the verification used a candidate named with only ; (the one class quotes DO
block). Five independent round-7 lenses reproduced the bypass (b427bf8).
- Don't simplify back to: quoting/escaping — the failure mode is invisible, so
it is not a control. G3-SHELL remains NOT MET, and its subject is exactly this
candidate-name substitution: the shipped procedure STILL puts the (quoted)
candidate directory name into the
digest/record command templates
(skills/skill-vetting/SKILL.md:204-224; the threat model at :127-138).
The display-gate + BLOCK-in-prose + inert allowlist is a PROCEDURAL mitigation
that relies on the agent, NOT the structural fix (D1 self-minted addressing, no
name on the command line). In-tree filenames are an ADDITIONAL open surface
(round8-design), not the definition of G3-SHELL. See UNCERTAINTY.md.
- ✅ hostile name → BLOCK in prose, no shell interpolation. ❌
"$(...)" "is
quoted, so it's safe."
Known open items (do not claim these are met — see UNCERTAINTY.md)
At PR #83 merge these were documented NOT MET/OPEN in the threat model and
remain so in HEAD: G3-SHELL (the procedure substitutes the candidate
directory name into digest/record command templates; quoting is insufficient;
no test; D1 closes it — with in-tree filenames as an additional surface),
the §1 procedure boundary (reads then executes untrusted content before the
verdict — a separate open item, UNCERTAINTY #3), G3 prose-injection via an
allowlisted name (pinned open by
test_prose_injection_via_an_allowlisted_name_is_STILL_OPEN), I11 full
concurrency serialization (the lock is hand-rolled O_EXCL at :226;
fcntl.flock/design D2 not landed), and the I2 mid-scan swap window and
I10 partial-with-prior half. The round-8 design
reviews/2026-07-25-skill-vetting-round8-design.md (D1–D5, invariants I12–I17)
is an explicitly unimplemented design, not shipped code.
When NOT to use
Trusting/extending the mutation harness, evidence integrity, OR test/doc honesty
(vacuous assertions, duplicate-def shadowing, name-is-a-claim) →
mutation-matrix-evidence-discipline — but if your runtime-hook change ALSO
adds/edits a test, co-load both, not one alone. Generic
fix-a-defect-then-sweep-for-twins →
operational-rigor §5. Why a fold itself was defective →
skill-vetting-hardening-archaeology.
Re-verify (HEAD = 79ca49c, shipped 2026-07-26)
python3 .github/checks.py # repo consistency gate: all green
bash hooks/test-skill_snapshot.sh # primitive suite: OK (skipped=1)
bash hooks/test-skill-vetting-advisory.sh # hook suite: OK
grep -nE 'normpath\(' hooks/skill_snapshot.py # expect: no output = no call (INV-4)
checks.py validates published-skill frontmatter and sweeps ALL tracked text for
hidden-directive chars (it does not validate this staged library's frontmatter —
that is checked by hand). If any symbol path above has moved, re-derive it before
trusting the rule.
1---2name: skill-vetting-security-invariants3description: Load when editing hooks/skill_snapshot.py or hooks/skill-vetting-advisory.py, changing the digest/baseline/anomaly logic, editing skills/skill-vetting/SKILL.md §3, touching the display allowlist or the digest/record/status CLI, or about to cite G3/G3-SHELL/any I-numbered invariant as met. A runtime-hook change that also adds/edits a test or makes a test/evidence claim ALSO loads mutation-matrix-evidence-discipline. Behavior-only runtime work uses this skill rather than mutation-matrix (and vice-versa) — that split is only about the security↔mutation pair and does not suppress security-hardening-review-ops during a hardening campaign or a security-sensitive push/PR/merge.4---56# skill-vetting security invariants78The `skill-vetting` advisory tripwire is a SessionStart hook that watches9installed skills and injects one advisory line when one appears, changes,10disappears, or cannot be fully observed. Its threat model treats **the author of11watched content (ADV-1) as controlling every byte, name, type and shape** under12the watched trees; same-privilege local code (ADV-2) is out of scope by design.13These invariants shipped hardened in **PR #83 (`7cd2af6`, merged 2026-07-26)**14over a multi-round cross-family campaign; the design record15`reviews/2026-07-25-skill-vetting-snapshot-threat-model.md` defines **goals16G1–G6 + G3-SHELL and invariants I1–I11** (I12–I17 belong to the *unimplemented*17round-8 design — see the last section). Each invariant below violates "breaks18users you cannot see": a silently-missed trojan edit, a discarded BLOCK, a hung19session start.2021## Boundary map2223| Path | Role |24|---|---|25| `hooks/skill_snapshot.py` | observation + persistence primitive: `scan_root`, `snapshot_tree`, canonical digest, baseline I/O, and the `digest`/`record`/`status` CLI. Decides no verdicts. |26| `hooks/skill-vetting-advisory.py` | thin SessionStart hook: resolves roots, compares snapshot vs baseline, composes/delivers the advisory, advances the baseline. No filesystem-walking of its own. |27| `skills/skill-vetting/SKILL.md` | the procedure an agent follows to vet a candidate; §3 binds a verdict to an exit-0 digest. |2829The two files are imported in-process, never spawned — passing attacker30path-bytes across an argv/JSON boundary would add an encoding surface for no31isolation gain (both run at the same privilege). Do not "improve" this into a32subprocess split.3334## Invariants3536### INV-1 — a structural refusal must never set the shared budget (I8)37- **Trigger:** any change to the walk budget, `MAX_DEPTH`/`MAX_OPEN_DIRS`, or a38 thought like "unify the caps so exhaustion is handled in one place".39- **Invariant:** `MAX_ENTRIES`/`MAX_TOTAL_BYTES` are SHARED across candidates and40 set `budget["stop"]`; `MAX_DEPTH`/`MAX_OPEN_DIRS` are PER-CANDIDATE structural41 refusals that do NOT DIRECTLY set `budget["stop"]` — they mark that candidate42 anomalous (`hooks/skill_snapshot.py:82-89`; the `budget` dict at `:326`). Their43 anomaly record still flows through `_entry`, which increments the shared entry44 counter and CAN trip `MAX_ENTRIES` at the boundary like any other entry45 (`:209-214`); what is forbidden is a structural cap poisoning the budget merely46 because it fired.47- **Done-check:** `grep -n 'budget\["stop"\] = True' hooks/skill_snapshot.py` —48 every set (at `:214`/`:256`) traces to a resource cap49 (`MAX_ENTRIES`/`MAX_TOTAL_BYTES`), never to depth/fanout.50- **Incident:** conflating them let one skill holding 31 empty nested directories51 hand every LATER candidate the same constant, content-independent digest, so52 `is_changed` was permanently False for them (round-6 "budget poisoner"; I8 in53 the threat model).54- **Don't simplify back to:** "a breach stops that skill's scan" — a shared stop55 poisons siblings.56- ✅ depth breach → that candidate is anomalous but NOT `partial` (`partial` is57 budget truncation, INV-3); it doesn't DIRECTLY stop the shared budget (its58 anomaly entry is still counted normally). ❌ "one exhaustion flag is cleaner" —59 that flag is the poisoner.6061### INV-2 — CLI and hook are different entry points that converge on shared encoding (G6/I1)62- **Trigger:** adding a branch for a new file type in `snapshot_tree` or63 `scan_root`, or "the CLI and hook can each format this case".64- **Invariant:** the CLI (`digest`/`record`) enters through `snapshot_tree`65 (`hooks/skill_snapshot.py:289`; callsites `:912,:1055`); the hook enters66 through `scan_root` (`:551`, called from `skill-vetting-advisory.py:303`). They67 are DIFFERENT entry points that AGREE because both route every terminal68 (non-regular) case through the shared `_anomaly_snap` (`:526`) — `snapshot_tree`69 at `:328-383`, `scan_root` at `:617-634` (plus `:649`) — rather than70 hand-rolling. A regular file is encoded via `_entry` (`:209`): the CLI's71 top-level loose-file case at `:365`, nested files/anomalies in `_walk_dir`72 (`:390`) at `:413-515`. The invariant is the shared terminal encoder, not a73 single entry function.74- **Done-check:** `grep -n '_anomaly_snap(' hooks/skill_snapshot.py` shows every75 terminal case in BOTH `snapshot_tree` and `scan_root` going through it; neither76 entry point hand-rolls a divergent terminal tuple.77- **Incident:** round-5 unified only the symlink branch; FIFO and unreadable-dir78 still diverged, so a CLI-recorded BLOCK was judged "changed" and discarded next79 session.80- **Don't simplify back to:** two hand-written encoders "kept in sync by tests",81 or hand-rolling a terminal case in one entry point.82- ✅ new terminal type → one `_anomaly_snap(kind,…)` case reached by BOTH entry83 points. ❌ a bespoke tuple in `snapshot_tree` "just for FIFOs" that `scan_root`84 doesn't match.8586### INV-3 — a partial snapshot is never stored as a real digest (I9)87- **Trigger:** touching baseline write, the `partial` flag, or `skip_baseline`.88- **Invariant:** a budget-truncated snapshot is `partial` (its digest describes89 the SCAN STATE, not the tree); the hook sets `skip_baseline = partial and old90 is None` and skips only the baseline WRITE, never the advisory91 (`hooks/skill-vetting-advisory.py:489`); a `partial` re-observation of a known92 candidate keeps the prior record (`entry = dict(old)` at `:491`). The `record`93 CLI likewise refuses to bind a verdict to a `partial` digest.94- **Done-check:** `grep -n 'skip_baseline\|partial' hooks/skill-vetting-advisory.py`95 — a `partial and old is None` candidate is advised but not baselined.96- **Incident (a fix that regressed):** an earlier guard did `continue` here,97 skipping the whole candidate — a single 4200-file skill (vs `MAX_ENTRIES=4096`)98 then made the hook emit ZERO bytes every session, taking every candidate after99 it into silence (documented inline at `:480-488`).100- **Don't simplify back to:** "skip the candidate if we couldn't fully read it."101- ✅ skip the baseline write only. ❌ `continue` — that is the silent-miss the102 whole component exists to prevent.103104### INV-4 — path identity comes from proven arrival, dot-paths fail closed105- **Trigger:** editing `_resolve_dot_base`, `_strip_trailing`, or any path106 normalization; or reaching for `os.path.normpath`.107- **Invariant:** `_resolve_dot_base` (`hooks/skill_snapshot.py:847`, predicate at108 `:894-903`) resolves a `.`-form path ONLY on ARRIVAL EVIDENCE, not spelling:109 `$PWD` is set, `realpath($PWD) == realpath(the path)`, and `$PWD` is not itself a110 symlink. So a bare `.` — OR a `child/..` that resolves back to the current111 non-symlink `$PWD` — passes; a `..` that resolves AWAY from `$PWD` (the common112 case: cwd IS the candidate, so `..` is the parent≠`$PWD`), unset `$PWD`, and113 `$PWD`≠the-path all return `_REFUSE` → the CLI prints "REFUSED" and exits **2**114 (`:929-937` digest, `:1008-` record), distinct from a badname ANOMALY (exit 3,115 `:938-949`). It is NOT "`..` always refuses" — that is the very oversimplification116 the incident below rejects. `normpath` is never CALLED (`_strip_trailing` at117 `:187` does only trailing-slash stripping; sole normalization entry, callsites118 `:324/577/919/996`).119- **Done-check:** `grep -nE 'normpath\(' hooks/skill_snapshot.py` returns nothing120 (the word survives only in comments `:189/190/192/323`); AND drive the resolver121 BOTH ways from the repo root, where subdir `skills/` is directly under `$PWD` so122 `skills/..` resolves back to `$PWD`: `python3 hooks/skill_snapshot.py digest123 skills/..` RESOLVES (exit 3 by content, arrival evidence — a `..` that does NOT124 refuse), while `env -u PWD python3 hooks/skill_snapshot.py digest skills/..`125 REFUSES (exit 2, no `$PWD`). The regression is126 `test_every_dot_spelling_without_arrival_evidence_refuses`127 (`hooks/test-skill_snapshot.py:1317`).128- **Incident:** `normpath(b"") == b"."` turned a fail-closed empty/unset path into129 a clean digest of the CWD with exit 0 — the exact green light §3 binds130 SAFE-TO-PROPOSE to; dot-path laundering recurred across ~5 spellings before131 converging on "evidence of arrival, not spelling".132- **Don't simplify back to:** "just reject `..`" — `<link>/sub/../.` and unset133 `$PWD` still launder (the reviewers' own proposed narrow fix, refuted).134- ✅ `$PWD`-proven, non-symlink → resolve. ❌ text-level `..` handling.135136### INV-5 — a failed first write is ADVISED, not silent; the baseline stays absent (N-CORRECTION)137- **Trigger:** editing baseline bootstrap / the `absent` state.138- **Invariant:** first-run bootstrap announces its count BEFORE the write. If that139 first write FAILS, the run still ADVISES (it is not silent) and does NOT write140 the baseline — so the next session sees `absent` again and re-advises rather141 than silently bootstrapping whatever the content has become (G5142 deliver-before-advance makes the un-written baseline safe). There is no143 GUARANTEED baseline-state failure marker; `_log` may leave a best-effort warning144 in `advisory.log` (`hooks/skill-vetting-advisory.py:120-150`, called at145 `:701-704`), but that is a forensic trace, not the safety mechanism — safety146 comes from advise-and-don't-advance. (Doc-vs-code note — RESOLVED by PR #118147 (`c7951bc`, the #104 fix): the canonical docs USED TO overstate this —148 threat-model I6 said a failed write "is logged" and both READMEs called advisory149 logging "auditable" — while `_log` swallows all exceptions (`advisory.py`, the150 bare `except Exception` around the log write). PR #118 added the best-effort151 qualifier to I6 and both READMEs, so the docs now match the code; the CODE stays152 authoritative. Threat-model I6 is still at `:296` (content updated in place); the153 README anchors moved (`429/268` → ~`471/302`) — prefer the I6 goal / the READMEs'154 logging note over a line number. See UNCERTAINTY.md.)155- **Done-check:** run the regression — `python3 hooks/test-skill-vetting-advisory.py156 HookE2E.test_failed_first_write_does_not_silently_bootstrap_a_change` prints `OK`157 (it lives at `hooks/test-skill-vetting-advisory.py:132` and asserts the advisory158 context is non-empty — "a failed first write must not be silent" — AND that the159 baseline file was not written).160- **Incident:** a transient first-write failure used to leave no trace and emit161 nothing, so the next session treated changed content as a fresh silent bootstrap162 and never advised it (round-6; the inline test comment records this).163- **Don't simplify back to:** silently writing the baseline on a first run before164 the advisory is delivered, or treating a failed write as a completed one.165166### INV-6 — audit visibility is monotone with severity, and fails closed (`status`)167- **Trigger:** editing `_cli_status`, its partition, or its exit codes.168- **Invariant:** `status` splits `unvetted` / `adverse_verdicts_in_baseline` /169 `vetted_safe`; `absent`→exit 0 (truthful empty BASELINE state — nothing recorded170 yet; NOT a claim that no skills are installed — `status` reads the baseline and171 never lstats, `:1170`), `corrupt`/`stale`→exit 4 (audit could not be performed),172 any adverse verdict→exit 3 (`hooks/skill_snapshot.py:1152-1189`).173- **Done-check:** two ways. (1) Isolated live fixture (nothing real touched):174 `CLAUDE_CONFIG_DIR=$(mktemp -d) python3 hooks/skill_snapshot.py status; echo $?`175 → prints `{"baseline": "absent"}` (json.dumps spacing) and exit 0 on an empty176 world. (2) Drive the absent/unusable AND adverse branches via the regressions —177 `python3 hooks/test-skill_snapshot.py178 CommandLine.test_status_exit_code_separates_absent_from_unusable179 CommandLine.test_status_surfaces_an_adverse_verdict_instead_of_hiding_it` prints180 `OK` (a corrupt/stale baseline exits 4; a recorded BLOCK shows in181 `adverse_verdicts_in_baseline` and exits 3).182- **Incident:** the old partition was `status != "vetted"` and never printed the183 verdict, so recording BLOCK on a live trojan REMOVED it from the only list the184 command printed — the more damning the verdict, the cleaner the report. The185 field was renamed off `_still_installed` because `status` reads the baseline and186 never lstats (`:1170-1173`).187- **Don't simplify back to:** "one list of the unvetted."188- ✅ severity raises visibility + a nonzero exit. ❌ a success exit when the audit189 itself could not run.190191### INV-7 — a hostile candidate name never becomes shell syntax; `digest`/hook anomaly it, `record` refuses only SAFE (G3)192- **Trigger:** editing SKILL.md §3, or "add quoting so the candidate name is safe193 on the command line".194- **Invariant (current, procedural):** a candidate name that fails the display195 allowlist is an anomaly (exit 3) for `digest` and for the hook; for `record` it196 refuses only SAFE-TO-PROPOSE — so a BLOCK/SUSPECT verdict CAN still be recorded197 against a hostile-named tree, which reports no anomaly. SKILL.md §3 directs: a198 hostile name is itself strong evidence → record BLOCK in prose with the reason,199 do NOT substitute it into any shell command (`skills/skill-vetting/SKILL.md`,200 the `$(...)`/backtick discussion at `:181-197`; the verdict-fail-closed note at201 `:111`).202- **Done-check:** the implemented half is executable —203 `python3 hooks/test-skill_snapshot.py CommandLine.test_record_refuses_safe_on_hostile_name`204 prints `OK`. That proves ONLY that `record` refuses SAFE-TO-PROPOSE for a hostile205 DISPLAY NAME; it does NOT prove shell non-substitution (G3-SHELL, still untested).206 What must hold: no207 basename that FAILS the display allowlist enters shell source, quoted or208 otherwise — it routes to a BLOCK-in-prose path; an allowlisted name relies on the209 inert alphabet (`[A-Za-z0-9._-]`), NOT quoting (never the control). **G3-SHELL210 itself has NO test** and remains open (below); in-tree filenames are a separate211 open hole.212- **Incident:** round-6 "fixed" the RCE by double-quoting placeholders and the213 commit message CLAIMED it fixed — false: quotes do not stop `$()`/backticks, and214 the verification used a candidate named with only `;` (the one class quotes DO215 block). **Five** independent round-7 lenses reproduced the bypass (`b427bf8`).216- **Don't simplify back to:** quoting/escaping — the failure mode is invisible, so217 it is not a control. **G3-SHELL remains NOT MET, and its subject is exactly this218 candidate-name substitution:** the shipped procedure STILL puts the (quoted)219 candidate directory name into the `digest`/`record` command templates220 (`skills/skill-vetting/SKILL.md:204-224`; the threat model at `:127-138`).221 The display-gate + BLOCK-in-prose + inert allowlist is a PROCEDURAL mitigation222 that relies on the agent, NOT the structural fix (D1 self-minted addressing, no223 name on the command line). In-tree filenames are an ADDITIONAL open surface224 (round8-design), not the definition of G3-SHELL. See UNCERTAINTY.md.225- ✅ hostile name → BLOCK in prose, no shell interpolation. ❌ `"$(...)"` "is226 quoted, so it's safe."227228## Known open items (do not claim these are met — see UNCERTAINTY.md)229230At PR #83 merge these were documented `NOT MET`/OPEN in the threat model and231remain so in HEAD: **G3-SHELL** (the procedure substitutes the candidate232directory name into `digest`/`record` command templates; quoting is insufficient;233no test; D1 closes it — with in-tree filenames as an additional surface),234**the §1 procedure boundary** (reads then executes untrusted content before the235verdict — a separate open item, UNCERTAINTY #3), **G3 prose-injection** via an236allowlisted name (pinned open by237`test_prose_injection_via_an_allowlisted_name_is_STILL_OPEN`), **I11** full238concurrency serialization (the lock is hand-rolled `O_EXCL` at `:226`;239`fcntl.flock`/design D2 not landed), and the **I2** mid-scan swap window and240**I10** partial-with-prior half. The round-8 design241`reviews/2026-07-25-skill-vetting-round8-design.md` (D1–D5, invariants I12–I17)242is an explicitly **unimplemented** design, not shipped code.243244## When NOT to use245246Trusting/extending the mutation harness, evidence integrity, OR test/doc honesty247(vacuous assertions, duplicate-def shadowing, name-is-a-claim) →248`mutation-matrix-evidence-discipline` — but if your runtime-hook change ALSO249adds/edits a test, **co-load both**, not one alone. Generic250fix-a-defect-then-sweep-for-twins →251operational-rigor §5. Why a fold itself was defective →252`skill-vetting-hardening-archaeology`.253254## Re-verify (HEAD = 79ca49c, shipped 2026-07-26)255256```257python3 .github/checks.py # repo consistency gate: all green258bash hooks/test-skill_snapshot.sh # primitive suite: OK (skipped=1)259bash hooks/test-skill-vetting-advisory.sh # hook suite: OK260grep -nE 'normpath\(' hooks/skill_snapshot.py # expect: no output = no call (INV-4)261```262`checks.py` validates published-skill frontmatter and sweeps ALL tracked text for263hidden-directive chars (it does not validate this staged library's frontmatter —264that is checked by hand). If any symbol path above has moved, re-derive it before265trusting the rule.