drskill-core 0.3.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,523 @@
1
+ """Tier 3 injection surface checks: static flagging, never verification.
2
+
3
+ Every finding quotes the lines it judged. Lexicons and thresholds are module
4
+ constants tuned against real corpora (scripts/corpus.py); the ack ledger is
5
+ the user's escape hatch."""
6
+
7
+ from __future__ import annotations
8
+
9
+ import re
10
+ import shlex
11
+ import unicodedata
12
+ from dataclasses import dataclass
13
+ from pathlib import Path
14
+
15
+ from drskill.checks import check, make_finding
16
+ from drskill.ledger import Config
17
+ from drskill.models import Contributor, Finding
18
+ from drskill.resolution import World, normalize_content
19
+
20
+ SCRIPT_EXTS = frozenset(
21
+ {".py", ".sh", ".bash", ".zsh", ".js", ".mjs", ".ts", ".rb", ".pl", ".ps1"}
22
+ )
23
+
24
+
25
+ @dataclass
26
+ class Source:
27
+ relpath: str # "SKILL.md" or the bundled file's relpath
28
+ kind: str # "skillmd" | "script" | "prose"
29
+ text: str
30
+ lines: list[str]
31
+ body_start: int = 1 # 1-based first body line; only meaningful for skillmd
32
+
33
+
34
+ Hit = tuple[Source, int, str] # source, 1-based line number, the line
35
+
36
+ # Keyed by content state, so a stale entry is impossible and the cache never
37
+ # returns a view for edited files. Bounded by the number of distinct skill
38
+ # states seen by one process; tests and scans stay small.
39
+ _VIEW_CACHE: dict[tuple, list[Source]] = {}
40
+
41
+
42
+ def _cache_key(c: Contributor) -> tuple:
43
+ return (
44
+ c.id,
45
+ c.content_hash,
46
+ tuple((f.relpath, f.content_hash) for f in c.bundled_files),
47
+ )
48
+
49
+
50
+ def _split_lines(text: str) -> list[str]:
51
+ """Split on newlines only. str.splitlines also consumes U+2028/U+2029 and
52
+ friends, which would hide those separators from the unicode check and
53
+ skew reported line numbers."""
54
+ return text.replace("\r\n", "\n").replace("\r", "\n").split("\n")
55
+
56
+
57
+ def _body_start(lines: list[str]) -> int:
58
+ if lines and lines[0].strip() == "---":
59
+ for i in range(1, len(lines)):
60
+ if lines[i].strip() == "---":
61
+ return i + 2
62
+ return 1
63
+
64
+
65
+ def scan_view(c: Contributor) -> list[Source]:
66
+ """All scannable text of a skill, each file read once per content state."""
67
+ key = _cache_key(c)
68
+ if key in _VIEW_CACHE:
69
+ return _VIEW_CACHE[key]
70
+ sources: list[Source] = []
71
+ skill_file = Path(c.id)
72
+ try:
73
+ text = skill_file.read_text(encoding="utf-8", errors="replace")
74
+ except OSError:
75
+ text = ""
76
+ if text:
77
+ lines = _split_lines(text)
78
+ sources.append(
79
+ Source(
80
+ relpath=skill_file.name,
81
+ kind="skillmd",
82
+ text=text,
83
+ lines=lines,
84
+ body_start=_body_start(lines),
85
+ )
86
+ )
87
+ base = skill_file.parent
88
+ for bf in c.bundled_files:
89
+ if not bf.is_text or bf.oversize:
90
+ continue
91
+ try:
92
+ ftext = (base / bf.relpath).read_text(encoding="utf-8", errors="replace")
93
+ except OSError:
94
+ continue
95
+ ext = Path(bf.relpath).suffix.lower()
96
+ kind = "script" if ext in SCRIPT_EXTS or ftext.startswith("#!") else "prose"
97
+ sources.append(
98
+ Source(relpath=bf.relpath, kind=kind, text=ftext, lines=_split_lines(ftext))
99
+ )
100
+ _VIEW_CACHE[key] = sources
101
+ return sources
102
+
103
+
104
+ def find_hits(sources: list[Source], patterns, kinds: set[str]) -> list[Hit]:
105
+ out: list[Hit] = []
106
+ for s in sources:
107
+ if s.kind not in kinds:
108
+ continue
109
+ for i, line in enumerate(s.lines, start=1):
110
+ if any(p.search(line) for p in patterns):
111
+ out.append((s, i, line))
112
+ return out
113
+
114
+
115
+ _SNIPPET_MAX = 100
116
+
117
+
118
+ def _printable(line: str) -> str:
119
+ """Render control, format, and invisible-separator characters visibly;
120
+ keep tabs."""
121
+ return "".join(
122
+ f"\\u{ord(ch):04x}"
123
+ if ch != "\t" and unicodedata.category(ch) in ("Cc", "Cf", "Zl", "Zp")
124
+ else ch
125
+ for ch in line
126
+ )
127
+
128
+
129
+ def evidence_message(
130
+ c: Contributor, summary: str, hits: list[Hit], note: str | None = None
131
+ ) -> str:
132
+ skill_dir = str(Path(c.id).parent)
133
+ lines = [f"'{_printable(c.name)}' {summary} ({skill_dir}):"]
134
+ for s, n, line in hits[:3]:
135
+ snippet = _printable(line.strip())
136
+ if len(snippet) > _SNIPPET_MAX:
137
+ snippet = snippet[: _SNIPPET_MAX - 1].rstrip() + "…"
138
+ lines.append(f' {s.relpath}:{n}: "{snippet}"')
139
+ if len(hits) > 3:
140
+ lines.append(f" (and {len(hits) - 3} more)")
141
+ if note:
142
+ lines.append(f" ({note})")
143
+ lines.append(
144
+ " (static flag: drskill shows the evidence; it cannot verify intent)"
145
+ )
146
+ return "\n".join(lines)
147
+
148
+
149
+ def fingerprint_texts(hits: list[Hit]) -> list[str]:
150
+ """Contents of the files containing hits: an ack survives edits to files
151
+ without hits and resurfaces when a hit file changes."""
152
+ seen: dict[str, str] = {}
153
+ for s, _n, _line in hits:
154
+ seen[s.relpath] = (
155
+ normalize_content(s.text) if s.kind == "skillmd" else s.text
156
+ )
157
+ return [seen[k] for k in sorted(seen)]
158
+
159
+
160
+ def removal_commands(c: Contributor) -> list[str]:
161
+ # A name starting with "-" would be parsed as a flag by the installer
162
+ # even shell-quoted, so those fall through to the path form.
163
+ if c.source.kind in ("skills-lock", "linked") and not c.name.startswith("-"):
164
+ return [f"npx skills remove {shlex.quote(c.name)}"]
165
+ skill_file = Path(c.id)
166
+ if skill_file.name == "SKILL.md":
167
+ return [f"rm -r {shlex.quote(str(skill_file.parent))}"]
168
+ return [f"rm {shlex.quote(str(skill_file))}"]
169
+
170
+
171
+ # ---- the checks ----
172
+
173
+ # Bidi controls and zero width space have no business in a skill file. The
174
+ # zero width joiner and non joiner are excluded outright: emoji sequences and
175
+ # several writing systems use them. A byte order mark is legitimate only as
176
+ # the first character of a file.
177
+ _SUSPECT_CHARS = frozenset(
178
+ "\u200b\ufeff\u2028\u2029"
179
+ "\u202a\u202b\u202c\u202d\u202e"
180
+ "\u2066\u2067\u2068\u2069"
181
+ )
182
+ _ALL_KINDS = {"skillmd", "script", "prose"}
183
+
184
+
185
+ @check("injection-unicode")
186
+ def injection_unicode(world: World, config: Config) -> list[Finding]:
187
+ out = []
188
+ for c in world.contributors.values():
189
+ hits: list[Hit] = []
190
+ names: set[str] = set()
191
+ for s in scan_view(c):
192
+ for i, line in enumerate(s.lines, start=1):
193
+ scanned = line[1:] if i == 1 and line.startswith("\ufeff") else line
194
+ found = [ch for ch in scanned if ch in _SUSPECT_CHARS]
195
+ if found:
196
+ hits.append((s, i, line))
197
+ names.update(
198
+ unicodedata.name(ch, f"U+{ord(ch):04X}") for ch in found
199
+ )
200
+ if hits:
201
+ out.append(
202
+ make_finding(
203
+ "injection-unicode", "error", [c],
204
+ evidence_message(
205
+ c,
206
+ "contains invisible or bidirectional control characters",
207
+ hits,
208
+ note="characters: " + ", ".join(sorted(names)),
209
+ ),
210
+ fix_commands=removal_commands(c),
211
+ extra_key=c.name,
212
+ fingerprint_texts=fingerprint_texts(hits),
213
+ )
214
+ )
215
+ return out
216
+
217
+
218
+ # URLs share the base64 alphabet plus "/" and easily reach 120 characters, so
219
+ # they are stripped before matching. The hex floor sits above sha256's 64 and
220
+ # sha512's 128 hex digits, the digest lengths lockfiles and docs quote.
221
+ _URL = re.compile(r"https?://\S+")
222
+ _B64_RUN = re.compile(r"[A-Za-z0-9+/=]{120,}")
223
+ _HEX_RUN = re.compile(r"\b[0-9a-fA-F]{129,}\b")
224
+
225
+
226
+ @check("injection-encoded-blob")
227
+ def injection_encoded_blob(world: World, config: Config) -> list[Finding]:
228
+ out = []
229
+ for c in world.contributors.values():
230
+ hits: list[Hit] = []
231
+ for s in scan_view(c):
232
+ for i, line in enumerate(s.lines, start=1):
233
+ stripped = _URL.sub("", line)
234
+ if _B64_RUN.search(stripped) or _HEX_RUN.search(stripped):
235
+ hits.append((s, i, line))
236
+ if hits:
237
+ out.append(
238
+ make_finding(
239
+ "injection-encoded-blob", "warning", [c],
240
+ evidence_message(
241
+ c, "contains long encoded blobs a reviewer cannot read", hits
242
+ ),
243
+ fix_commands=[
244
+ "Decode the blob yourself before trusting the skill, or remove it"
245
+ ],
246
+ extra_key=c.name,
247
+ fingerprint_texts=fingerprint_texts(hits),
248
+ )
249
+ )
250
+ return out
251
+
252
+
253
+ _PROSE_KINDS = {"skillmd", "prose"}
254
+ _OVERRIDE = [
255
+ re.compile(p, re.IGNORECASE)
256
+ for p in (
257
+ r"\bignore\b.{0,20}\b(previous|prior|earlier|above|all other)\b.{0,20}\b(instructions?|rules|guidance|guidelines|prompts?)\b",
258
+ r"\bdisregard\b.{0,30}\b(instructions?|rules|guidance|guidelines|system prompt)\b",
259
+ # "(?!\s+to\b)" keeps the advice sense out: "do not tell the user to
260
+ # run X" is UX guidance, "do not tell the user about X" is concealment
261
+ # (real-loadout triage 2026-07-20).
262
+ r"\bdo not (tell|inform|notify|warn|alert)\b.{0,15}\bthe user\b(?!\s+to\b)",
263
+ r"\bwithout (informing|telling|notifying|alerting)\b.{0,15}\bthe user\b",
264
+ r"\bhide (this|it) from\b",
265
+ r"\bdo not reveal\b",
266
+ r"\byou are no longer (bound|restricted|limited)\b",
267
+ r"\bforget (everything|all previous)\b",
268
+ r"\boverride (the )?(system|safety)\b",
269
+ )
270
+ ]
271
+
272
+
273
+ @check("injection-override")
274
+ def injection_override(world: World, config: Config) -> list[Finding]:
275
+ out = []
276
+ for c in world.contributors.values():
277
+ hits = find_hits(scan_view(c), _OVERRIDE, _PROSE_KINDS)
278
+ if hits:
279
+ out.append(
280
+ make_finding(
281
+ "injection-override", "warning", [c],
282
+ evidence_message(
283
+ c, "contains instruction-override phrasing", hits
284
+ ),
285
+ fix_commands=[
286
+ "Read the quoted lines; remove the skill if you did not expect them"
287
+ ],
288
+ extra_key=c.name,
289
+ fingerprint_texts=fingerprint_texts(hits),
290
+ )
291
+ )
292
+ return out
293
+
294
+
295
+ # Matched in two linear steps (tool first, pipe-to-shell tail after it): a
296
+ # single backtracking regex is quadratic on crafted lines full of "curl"
297
+ # tokens, and a hostile skill stalling the scanner is itself an attack.
298
+ _FETCH_TOOL = re.compile(r"\b(curl|wget)\b")
299
+ _SHELL_TAIL = re.compile(r"\|\s*(sudo\s+)?(ba|z|da)?sh\b")
300
+
301
+
302
+ def _pipe_to_shell(line: str) -> bool:
303
+ tool = _FETCH_TOOL.search(line)
304
+ return bool(tool and _SHELL_TAIL.search(line, tool.end()))
305
+ _URLISH = re.compile(r"https?://")
306
+ # Local URLs are dev-server chatter, not remote content (corpus tuning
307
+ # 2026-07-20: `npm run dev` beside http://localhost was the main noise).
308
+ _LOCAL_URL = re.compile(r"https?://(?:localhost|127\.0\.0\.1|0\.0\.0\.0|\[::1\])\S*")
309
+ # A bare "run" near a URL is not a fetch instruction. Require a fetch verb
310
+ # joined to an act verb, or the explicit "follow the instructions" phrasing.
311
+ _FETCH_DIRECTIVE = re.compile(
312
+ r"\b(download|fetch|retrieve|curl|wget|get)\b.{0,60}\b(and|then)\s+"
313
+ r"(run|execute|eval|follow|apply)\b"
314
+ r"|\bfollow (the|these|its) instructions\b",
315
+ re.IGNORECASE,
316
+ )
317
+
318
+
319
+ @check("injection-remote-fetch")
320
+ def injection_remote_fetch(world: World, config: Config) -> list[Finding]:
321
+ out = []
322
+ for c in world.contributors.values():
323
+ hits: list[Hit] = []
324
+ for s in scan_view(c):
325
+ if s.kind not in _PROSE_KINDS:
326
+ continue
327
+ for i, line in enumerate(s.lines, start=1):
328
+ cleaned = _LOCAL_URL.sub("", line)
329
+ if _pipe_to_shell(cleaned) or (
330
+ _URLISH.search(cleaned) and _FETCH_DIRECTIVE.search(cleaned)
331
+ ):
332
+ hits.append((s, i, line))
333
+ if hits:
334
+ out.append(
335
+ make_finding(
336
+ "injection-remote-fetch", "warning", [c],
337
+ evidence_message(
338
+ c,
339
+ "instructs the agent to fetch remote content and act on it",
340
+ hits,
341
+ ),
342
+ fix_commands=[
343
+ "Fetched content becomes instructions the skill author"
344
+ " controls after install; remove the step or pin the content"
345
+ ],
346
+ extra_key=c.name,
347
+ fingerprint_texts=fingerprint_texts(hits),
348
+ )
349
+ )
350
+ return out
351
+
352
+
353
+ _SCRIPT_KINDS = {"script"}
354
+ _EGRESS = [
355
+ re.compile(p)
356
+ for p in (
357
+ r"\bcurl\b",
358
+ r"\bwget\b",
359
+ r"\bnc\b",
360
+ r"\bInvoke-WebRequest\b",
361
+ r"\bInvoke-RestMethod\b",
362
+ r"\brequests\.",
363
+ # urllib.parse is string handling, not egress; Unix-domain sockets
364
+ # are local IPC (corpus tuning 2026-07-20).
365
+ r"\burllib\.request\b",
366
+ r"\bhttpx\b",
367
+ r"\baiohttp\b",
368
+ r"\bsocket\.create_connection\b",
369
+ r"\bAF_INET\b",
370
+ r"\bfetch\s*\(",
371
+ r"\baxios\b",
372
+ r"\bXMLHttpRequest\b",
373
+ r"\bNet::HTTP\b",
374
+ r"\bhttps?\.(request|get)\b",
375
+ )
376
+ ]
377
+
378
+
379
+ @check("injection-egress")
380
+ def injection_egress(world: World, config: Config) -> list[Finding]:
381
+ out = []
382
+ for c in world.contributors.values():
383
+ hits = find_hits(scan_view(c), _EGRESS, _SCRIPT_KINDS)
384
+ if hits:
385
+ out.append(
386
+ make_finding(
387
+ "injection-egress", "warning", [c],
388
+ evidence_message(
389
+ c, "ships scripts that talk to the network", hits
390
+ ),
391
+ fix_commands=[
392
+ "Check each call's destination; a skill script can send"
393
+ " your files or context anywhere"
394
+ ],
395
+ extra_key=c.name,
396
+ fingerprint_texts=fingerprint_texts(hits),
397
+ )
398
+ )
399
+ return out
400
+
401
+
402
+ _CRED_STORE = [
403
+ re.compile(p)
404
+ for p in (
405
+ r"\.ssh\b",
406
+ r"\bid_rsa\b",
407
+ r"\bid_ed25519\b",
408
+ # No bare .key pattern: JS/dict property access like `obj.key` fires
409
+ # it constantly (corpus tuning 2026-07-20).
410
+ r"\.pem\b",
411
+ r"\.aws\b",
412
+ r"\.config/gcloud",
413
+ r"\.netrc\b",
414
+ r"\.kube/config",
415
+ r"\.mozilla/firefox",
416
+ r"\.config/google-chrome",
417
+ r"Google/Chrome",
418
+ )
419
+ ]
420
+ _ENV_FILE = re.compile(r"(?<![\w.])\.env\b")
421
+
422
+
423
+ @check("injection-credential-read")
424
+ def injection_credential_read(world: World, config: Config) -> list[Finding]:
425
+ out = []
426
+ for c in world.contributors.values():
427
+ store_hits = find_hits(scan_view(c), _CRED_STORE, _SCRIPT_KINDS)
428
+ env_hits = [
429
+ h for h in find_hits(scan_view(c), [_ENV_FILE], _SCRIPT_KINDS)
430
+ if h not in store_hits
431
+ ]
432
+ hits = store_hits + env_hits
433
+ if not hits:
434
+ continue
435
+ # A project reading its own .env is common; credential stores are not.
436
+ severity = "error" if store_hits else "warning"
437
+ fixes = (
438
+ removal_commands(c)
439
+ if severity == "error"
440
+ else ["Check what the script does with the values it reads"]
441
+ )
442
+ out.append(
443
+ make_finding(
444
+ "injection-credential-read", severity, [c],
445
+ evidence_message(
446
+ c, "ships scripts that reference credential paths", hits
447
+ ),
448
+ fix_commands=fixes,
449
+ extra_key=c.name,
450
+ fingerprint_texts=fingerprint_texts(hits),
451
+ )
452
+ )
453
+ return out
454
+
455
+
456
+ # The SkillJect pattern: the skill text demands its own bundled file runs
457
+ # first or always. Both parts are required on one line. A plain pointer like
458
+ # "run scripts/convert.py to convert the file" does not fire.
459
+ _MANDATORY = [
460
+ re.compile(p, re.IGNORECASE)
461
+ for p in (
462
+ r"\b(you )?must\b.{0,30}\brun\b",
463
+ r"\bbefore (doing )?anything( else)?\b.{0,30}\brun\b",
464
+ r"\b(always|first),? (begin|start) by running\b",
465
+ r"\balways run\b",
466
+ r"\bfirst,? (run|execute)\b",
467
+ r"\brequired (first )?step\b.{0,30}\b(run|execute)\b",
468
+ )
469
+ ]
470
+
471
+
472
+ @check("injection-mandatory-script")
473
+ def injection_mandatory_script(world: World, config: Config) -> list[Finding]:
474
+ out = []
475
+ for c in world.contributors.values():
476
+ if not c.bundled_files:
477
+ continue
478
+ # Word-bounded match on the relpath or basename; names shorter than
479
+ # 3 characters are skipped because they match almost anything.
480
+ names = {bf.relpath for bf in c.bundled_files}
481
+ names |= {Path(bf.relpath).name for bf in c.bundled_files}
482
+ names = {n for n in names if len(n) >= 3}
483
+ if not names:
484
+ continue
485
+ path_re = re.compile(
486
+ r"(?<!\w)(?:"
487
+ + "|".join(re.escape(n) for n in sorted(names, key=len, reverse=True))
488
+ + r")(?!\w)"
489
+ )
490
+ hits: list[Hit] = []
491
+ for s in scan_view(c):
492
+ if s.kind != "skillmd":
493
+ continue
494
+ for i, line in enumerate(s.lines, start=1):
495
+ if i < s.body_start:
496
+ continue
497
+ # The path must follow the framing: "must first run scripts/x"
498
+ # fires, a filename before the noun phrase "first run" does
499
+ # not (corpus tuning 2026-07-20).
500
+ match = None
501
+ for p in _MANDATORY:
502
+ match = p.search(line)
503
+ if match:
504
+ break
505
+ if match and path_re.search(line, match.end()):
506
+ hits.append((s, i, line))
507
+ if hits:
508
+ out.append(
509
+ make_finding(
510
+ "injection-mandatory-script", "warning", [c],
511
+ evidence_message(
512
+ c,
513
+ "demands that its own bundled script runs first",
514
+ hits,
515
+ ),
516
+ fix_commands=[
517
+ "Read the script before letting any agent run it"
518
+ ],
519
+ extra_key=c.name,
520
+ fingerprint_texts=fingerprint_texts(hits),
521
+ )
522
+ )
523
+ return out
@@ -0,0 +1,129 @@
1
+ """skills-lock.json parsing (read-only, always) and drift attribution."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import hashlib
6
+ import json
7
+ import shlex
8
+ from pathlib import Path
9
+
10
+ from drskill.checks import check, make_finding
11
+ from drskill.ledger import Config
12
+ from drskill.models import Finding
13
+ from drskill.resolution import World
14
+
15
+ # Verified 2026-07-19 against `npx skills add vercel-labs/agent-skills` output and
16
+ # vercel-labs/skills @ src/local-lock.ts (LocalSkillLockEntry.computedHash /
17
+ # computeSkillFolderHash): the local project lockfile field is `computedHash`, a
18
+ # plain sha256 hex digest with no prefix. `hash`/`integrity`/`sha256` are kept as
19
+ # fallbacks for the (untested) global/user-scope lock schema, which the same source
20
+ # comment says uses a GitHub tree SHA instead.
21
+ _HASH_FIELDS = ("computedHash", "hash", "integrity", "sha256")
22
+
23
+
24
+ def load_lockfile(project_root: Path) -> dict[str, dict] | None:
25
+ p = project_root / "skills-lock.json"
26
+ if not p.is_file():
27
+ return None
28
+ try:
29
+ data = json.loads(p.read_text())
30
+ except (json.JSONDecodeError, OSError):
31
+ return None
32
+ if not isinstance(data, dict):
33
+ return None
34
+ skills = data.get("skills", data)
35
+ if not isinstance(skills, dict):
36
+ return None
37
+ return {k: v for k, v in skills.items() if isinstance(v, dict)}
38
+
39
+
40
+ def compute_tree_hash(skill_dir: Path) -> str:
41
+ # Mirrors vercel-labs/skills `computeSkillFolderHash` (src/local-lock.ts): sha256
42
+ # over relative-path bytes immediately followed by file-content bytes, sorted by
43
+ # relative path, with `.git` and `node_modules` subdirectories excluded. No
44
+ # separator byte between path and content — matched here for hash compatibility.
45
+ h = hashlib.sha256()
46
+ for f in sorted(
47
+ p
48
+ for p in skill_dir.rglob("*")
49
+ if p.is_file() and not {".git", "node_modules"} & set(p.relative_to(skill_dir).parts[:-1])
50
+ ):
51
+ h.update(f.relative_to(skill_dir).as_posix().encode())
52
+ h.update(f.read_bytes())
53
+ return h.hexdigest()
54
+
55
+
56
+ def _entry_hash(entry: dict) -> str | None:
57
+ for field in _HASH_FIELDS:
58
+ v = entry.get(field)
59
+ if isinstance(v, str):
60
+ return v.removeprefix("sha256:").removeprefix("sha256-")
61
+ return None
62
+
63
+
64
+ @check("lockfile-drift")
65
+ def lockfile_drift(world: World, config: Config) -> list[Finding]:
66
+ if not world.lockfile:
67
+ return []
68
+ by_name = {c.name: c for c in world.contributors.values()}
69
+ out = []
70
+ matches: list[str] = []
71
+ mismatches: list[tuple[str, object]] = []
72
+ for name, entry in sorted(world.lockfile.items()):
73
+ expected = _entry_hash(entry)
74
+ c = by_name.get(name)
75
+ if c is None:
76
+ out.append(
77
+ make_finding(
78
+ "lockfile-drift", "warning", [],
79
+ f"'{name}' is in skills-lock.json but not found on disk",
80
+ harnesses=sorted(world.harnesses),
81
+ extra_key=f"missing:{name}",
82
+ fix_commands=[
83
+ f"npx skills add {shlex.quote(str(entry.get('source', name)))}",
84
+ "npx skills sync",
85
+ ],
86
+ )
87
+ )
88
+ continue
89
+ if expected is None:
90
+ continue
91
+ skill_dir = Path(c.id).parent
92
+ if compute_tree_hash(skill_dir) == expected:
93
+ matches.append(name)
94
+ else:
95
+ mismatches.append((name, c))
96
+
97
+ # Self-calibration: if every hashed entry mismatches (and at least one
98
+ # matched, verifying the algorithm), attribute each mismatch by name. If
99
+ # NONE match, we can't tell drift from an algorithm mismatch against this
100
+ # lockfile's producer — collapse to a single "unverifiable" warning
101
+ # instead of crying wolf on every skill.
102
+ if mismatches and not matches:
103
+ out.append(
104
+ make_finding(
105
+ "lockfile-drift", "warning", [],
106
+ "skills-lock.json hashes use an algorithm drskill cannot "
107
+ "reproduce; hash-drift detection is disabled for this "
108
+ "lockfile (missing-skill detection still active)",
109
+ harnesses=sorted(world.harnesses),
110
+ extra_key="unverifiable",
111
+ fix_commands=[
112
+ "npx skills sync # reinstall to a known-good state if you suspect drift"
113
+ ],
114
+ )
115
+ )
116
+ else:
117
+ for name, c in mismatches:
118
+ out.append(
119
+ make_finding(
120
+ "lockfile-drift", "warning", [c],
121
+ f"'{name}' was modified outside `npx skills` — likely a "
122
+ "`gh skill update` or a hand edit; the lockfile no longer matches",
123
+ fix_commands=[
124
+ "npx skills sync # restore the locked version",
125
+ f"npx skills update {shlex.quote(name)} # or re-pin the new content",
126
+ ],
127
+ )
128
+ )
129
+ return out