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.
- drskill/__init__.py +0 -0
- drskill/checks/__init__.py +82 -0
- drskill/checks/budget.py +40 -0
- drskill/checks/duplicates.py +103 -0
- drskill/checks/filesystem.py +36 -0
- drskill/checks/heuristics.py +249 -0
- drskill/checks/injection.py +523 -0
- drskill/checks/lockfile.py +129 -0
- drskill/checks/shadowing.py +144 -0
- drskill/checks/spec.py +108 -0
- drskill/cli.py +521 -0
- drskill/data/__init__.py +0 -0
- drskill/data/harnesses.toml +973 -0
- drskill/deep.py +240 -0
- drskill/deep_llm.py +84 -0
- drskill/discovery.py +96 -0
- drskill/harnesses.py +62 -0
- drskill/interactive.py +48 -0
- drskill/ledger.py +133 -0
- drskill/models.py +79 -0
- drskill/pipeline.py +58 -0
- drskill/report.py +271 -0
- drskill/resolution.py +222 -0
- drskill/state.py +42 -0
- drskill/text.py +113 -0
- drskill/tokens.py +30 -0
- drskill_core-0.3.0.dist-info/METADATA +234 -0
- drskill_core-0.3.0.dist-info/RECORD +31 -0
- drskill_core-0.3.0.dist-info/WHEEL +4 -0
- drskill_core-0.3.0.dist-info/entry_points.txt +2 -0
- drskill_core-0.3.0.dist-info/licenses/LICENSE +21 -0
|
@@ -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
|