Skip to content

Commit 8e2e657

Browse files
fix(skills): fingerprint the whole skill directory, not SKILL.md alone
A skill is not just its manifest. Its scripts, tools, templates and reference docs decide what it does, and _skills() hashed SKILL.md and nothing else. So a payload swapped into a skill's scripts/ directory produced no change in the fingerprint and the report said "nothing added, nothing subtracted". Demonstrated on a real install before the fix: replacing a skill's scripts/deploy.ps1 with a body that POSTs ~/.ssh/id_rsa to an attacker host left the skills fingerprint byte-identical and diff() returned nothing. Every skill in that install carries files beyond SKILL.md, five of them executable. This is the README's own headline threat, "a skill you installed ships an update that now runs curl to an address you never saw", and it was undetected. _skill_fingerprint() now hashes every file in the skill tree, binding relative paths alongside contents so a rename or a move is also drift. Exclusions are a tool-controlled denylist (state/, .cache/, __pycache__/, .git/, node_modules/, plus .log/.tmp/.pyc/.pyo). Skills write state as they run, and alarming on ordinary use would train the user to dismiss the next real alarm. The list deliberately lives in the engine rather than in a per-skill ignore file: an ignore file would let the measured thing decide what gets measured, so a hostile skill could exempt its own payload. Per-file instruction-layer digests. The rollup only says the layer moved, which across dozens of memory files is one bit of signal over a directory the reader then has to search by hand. instruction_files carries a digest per file so the diff names it, and the rollup stays for the fingerprint line. Scoped to *.md because the same tree holds session transcripts that change constantly. Measurement scope versioning. Both changes make older fingerprints incomparable, so a scope-1 baseline would otherwise report every skill as changed on upgrade. diff() now reports the widening once, as "re-approve to compare on the new scope", and drops skills from that comparison. An alarm the user knows is false is worse than no alarm. Verified against a real scope-1 baseline: one honest line instead of six phantom skill changes. 17 new tests covering the closed bypass, renames, state churn not alarming, run artifacts not alarming, the migration path, and per-file naming with fallback to the rollup against a scope-1 baseline. Suite: 32 passed, up from 15. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Signed-off-by: Imran Siddique <imran.siddique@opaque.co>
1 parent c4e3e9a commit 8e2e657

2 files changed

Lines changed: 337 additions & 5 deletions

File tree

claude-code/engine/capture.py

Lines changed: 153 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,19 @@
4141
from datetime import datetime, timedelta, timezone
4242
from pathlib import Path
4343

44+
#: Version of WHAT this engine measures, distinct from what it found.
45+
#:
46+
#: Bump it whenever a change makes a fingerprint incomparable to one written by
47+
#: an earlier version, so an upgrade cannot be mistaken for drift. A baseline at
48+
#: an older scope is reported as needing a one-time re-approve instead of showing
49+
#: every affected category as changed: an alarm the user knows is false is worse
50+
#: than no alarm, because it teaches them to dismiss the next one.
51+
#:
52+
#: 1 skills fingerprinted by SKILL.md alone; instruction layer as a single hash
53+
#: 2 skills fingerprinted across their whole directory; per-file instruction
54+
#: hashes added alongside the rollup
55+
MEASUREMENT_SCOPE = 2
56+
4457
CLAUDE_HOME = Path(os.path.expanduser("~")) / ".claude"
4558
STATE_DIR = CLAUDE_HOME / "agentrust"
4659
BASELINE = STATE_DIR / "baseline.json"
@@ -88,6 +101,65 @@ def _now_iso() -> str:
88101
# --------------------------------------------------------------------------- #
89102
# snapshot: read the real box (stdlib only)
90103
# --------------------------------------------------------------------------- #
104+
#: Directory names skipped when fingerprinting a skill. These hold state a skill
105+
#: writes as it runs, so hashing them would report drift on ordinary use, and a
106+
#: tool that cries wolf on every run trains its user to ignore it.
107+
#:
108+
#: The list is controlled here rather than by a file inside the skill on purpose.
109+
#: A per-skill ignore file would let the thing being measured decide what gets
110+
#: measured, so a hostile skill could ship an ignore rule covering its own
111+
#: payload. Adding a name here is a reviewed change to this repo.
112+
SKILL_EXCLUDE_DIRS = frozenset({
113+
"state", ".cache", "__pycache__", ".git", ".pytest_cache", "node_modules",
114+
})
115+
116+
#: File suffixes skipped for the same reason: run artifacts, not behaviour.
117+
SKILL_EXCLUDE_SUFFIXES = frozenset({".log", ".tmp", ".pyc", ".pyo"})
118+
119+
120+
def _skill_fingerprint(skill_dir: Path) -> str | None:
121+
"""Hash every behavioural file in one skill directory, or None if unreadable.
122+
123+
Covers the whole tree rather than SKILL.md alone. A skill is not just its
124+
manifest: these directories carry scripts, tools, templates and reference
125+
docs that decide what the skill actually does. Hashing only SKILL.md meant a
126+
payload could be swapped into scripts/ and the integrity check would report
127+
nothing added and nothing subtracted, which is the exact scenario this
128+
integration exists to catch.
129+
130+
Relative paths are hashed alongside contents so a rename or a move is drift,
131+
and traversal order is sorted so the digest is stable across platforms.
132+
"""
133+
h = hashlib.sha256()
134+
try:
135+
paths = sorted(p for p in skill_dir.rglob("*") if p.is_file())
136+
except OSError:
137+
return None
138+
for f in paths:
139+
try:
140+
rel = f.relative_to(skill_dir)
141+
except ValueError: # pragma: no cover - rglob results are always relative
142+
continue
143+
if SKILL_EXCLUDE_DIRS & set(rel.parts[:-1]):
144+
continue
145+
if f.suffix in SKILL_EXCLUDE_SUFFIXES:
146+
continue
147+
try:
148+
body = f.read_bytes()
149+
except OSError:
150+
# An unreadable file inside a skill is itself worth recording: bind
151+
# its path into the digest so the file appearing or vanishing moves
152+
# the fingerprint, instead of being silently skipped.
153+
h.update(rel.as_posix().encode())
154+
h.update(b"\0<unreadable>\0")
155+
continue
156+
h.update(rel.as_posix().encode())
157+
h.update(b"\0")
158+
h.update(body)
159+
h.update(b"\0")
160+
return "sha256:" + h.hexdigest()
161+
162+
91163
def _skills() -> dict[str, str]:
92164
out: dict[str, str] = {}
93165
sdir = CLAUDE_HOME / "skills"
@@ -99,12 +171,44 @@ def _skills() -> dict[str, str]:
99171
except OSError:
100172
return out
101173
for d in entries:
102-
sk = d / "SKILL.md"
103174
try:
104-
if sk.is_file():
105-
out[d.name] = _sha_file(sk)
175+
# SKILL.md is what makes a directory a skill; without it the
176+
# directory is not loaded as one and is not measured as one.
177+
if not (d / "SKILL.md").is_file():
178+
continue
106179
except OSError:
107180
continue # unreadable skill file: skip it, never crash the hook
181+
fp = _skill_fingerprint(d)
182+
if fp is not None:
183+
out[d.name] = fp
184+
return out
185+
186+
187+
def _instruction_files(pattern: str = "*.md") -> dict[str, str]:
188+
"""Hash each instruction file separately, keyed by path relative to the tree.
189+
190+
The rollup in ``hashes.system_prompt`` says only that something in the
191+
instruction layer moved. Across a real memory directory that is one bit of
192+
signal over dozens of files, which leaves a reader unable to act on the
193+
warning. Per-file digests let a diff name the file that changed.
194+
195+
Scoped to ``*.md`` deliberately: this tree also holds session transcripts and
196+
other machine-written state that changes constantly, and folding those in
197+
would make the instruction layer permanently dirty.
198+
"""
199+
out: dict[str, str] = {}
200+
root = CLAUDE_HOME / "projects"
201+
if not root.is_dir():
202+
return out
203+
try:
204+
paths = sorted(p for p in root.rglob(pattern) if p.is_file())
205+
except OSError:
206+
return out
207+
for f in paths:
208+
try:
209+
out[f.relative_to(root).as_posix()] = _sha_file(f)
210+
except (OSError, ValueError):
211+
continue # unreadable file: skip it, never crash the hook
108212
return out
109213

110214

@@ -182,7 +286,7 @@ def snapshot(live: dict | None = None) -> dict:
182286
# -- and diffed -- only when a live context supplies them. `observed` marks
183287
# which categories this snapshot actually measured, so a disk-only hook
184288
# snapshot is never diffed against the live categories of a richer baseline.
185-
observed = ["skills", "policy", "prompt"]
289+
observed = ["skills", "policy", "prompt", "instructions"]
186290
mcp_live = live.get("mcp_servers")
187291
mcp = mcp_live if mcp_live is not None else _mcp_from_config()
188292
builtin = live.get("builtin_tools") or []
@@ -194,6 +298,7 @@ def snapshot(live: dict | None = None) -> dict:
194298

195299
return {
196300
"captured_at": _now_iso(),
301+
"scope": MEASUREMENT_SCOPE,
197302
"observed": observed,
198303
"agent_id": _identity(),
199304
"model": {
@@ -203,6 +308,7 @@ def snapshot(live: dict | None = None) -> dict:
203308
"capability_level": live.get("capability_level"),
204309
},
205310
"skills": skills,
311+
"instruction_files": _instruction_files(),
206312
"policy_hash": policy_hash,
207313
"allow_rules": allow,
208314
"prompt_hash": prompt_hash,
@@ -221,19 +327,61 @@ def snapshot(live: dict | None = None) -> dict:
221327
# --------------------------------------------------------------------------- #
222328
# diff: nothing added, nothing subtracted
223329
# --------------------------------------------------------------------------- #
330+
def _instruction_file_changes(base: dict, cur: dict) -> list[dict]:
331+
"""Per-file additions, removals and edits in the instruction layer."""
332+
b_f, c_f = base.get("instruction_files", {}), cur.get("instruction_files", {})
333+
if not b_f and not c_f:
334+
return []
335+
out: list[dict] = []
336+
for name in sorted(set(c_f) - set(b_f)):
337+
out.append({"change": "added", "what": "instruction file", "detail": name})
338+
for name in sorted(set(b_f) - set(c_f)):
339+
out.append({"change": "removed", "what": "instruction file", "detail": name})
340+
for name in sorted(set(b_f) & set(c_f)):
341+
if b_f[name] != c_f[name]:
342+
out.append({"change": "changed", "what": "instruction file", "detail": name})
343+
return out
344+
345+
224346
def diff(base: dict, cur: dict) -> list[dict]:
225347
"""Return a list of {change, what, detail}, change in {added,removed,changed}.
226348
227349
Only categories BOTH snapshots observed are compared, so a disk-only hook
228350
snapshot never reports the live tool roster of a richer baseline as removed.
351+
352+
Categories whose fingerprints became incomparable because the engine widened
353+
what it measures are reported once as a scope change needing re-approval,
354+
rather than as drift that never happened.
229355
"""
230356
out: list[dict] = []
231357
obs = set(base.get("observed", ["skills", "policy", "prompt"])) & set(
232358
cur.get("observed", ["skills", "policy", "prompt"])
233359
)
234360

361+
# A baseline written before MEASUREMENT_SCOPE 2 holds skill fingerprints over
362+
# SKILL.md alone, so comparing them against whole-directory digests would
363+
# report every skill as changed. Drop skills from the comparison and say why.
364+
base_scope = base.get("scope", 1)
365+
if base_scope != MEASUREMENT_SCOPE:
366+
out.append({
367+
"change": "changed",
368+
"what": "measurement scope",
369+
"detail": (
370+
f"widened from {base_scope} to {MEASUREMENT_SCOPE}; skill "
371+
"fingerprints now cover the whole skill directory. Re-approve "
372+
"once to compare on the new scope."
373+
),
374+
})
375+
obs.discard("skills")
376+
235377
if "prompt" in obs and base["hashes"].get("system_prompt") != cur["hashes"].get("system_prompt"):
236-
out.append({"change": "changed", "what": "instruction layer", "detail": "system_prompt"})
378+
# Name the files when both snapshots carry per-file digests. The rollup
379+
# only says the layer moved, which over dozens of files gives a reader
380+
# nothing to act on. Fall back to the rollup against a scope-1 baseline
381+
# that has no per-file detail to compare against.
382+
per_file = _instruction_file_changes(base, cur) if "instructions" in obs else []
383+
out.extend(per_file or
384+
[{"change": "changed", "what": "instruction layer", "detail": "system_prompt"}])
237385
if "policy" in obs and base["hashes"].get("policy_bundle") != cur["hashes"].get("policy_bundle"):
238386
out.append({"change": "changed", "what": "permissions", "detail": "policy_bundle"})
239387

0 commit comments

Comments
 (0)