@promptci/core 0.1.0 → 0.2.1

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.
Files changed (152) hide show
  1. package/dist/action-pinning.d.ts +37 -0
  2. package/dist/action-pinning.d.ts.map +1 -0
  3. package/dist/action-pinning.js +277 -0
  4. package/dist/action-pinning.js.map +1 -0
  5. package/dist/agent-practices.d.ts +1 -1
  6. package/dist/agent-practices.d.ts.map +1 -1
  7. package/dist/agent-practices.js +107 -43
  8. package/dist/agent-practices.js.map +1 -1
  9. package/dist/ai-config.d.ts +42 -3
  10. package/dist/ai-config.d.ts.map +1 -1
  11. package/dist/ai-config.js +210 -12
  12. package/dist/ai-config.js.map +1 -1
  13. package/dist/alias-files.d.ts +33 -0
  14. package/dist/alias-files.d.ts.map +1 -0
  15. package/dist/alias-files.js +60 -0
  16. package/dist/alias-files.js.map +1 -0
  17. package/dist/buried-critical.d.ts.map +1 -1
  18. package/dist/buried-critical.js +4 -9
  19. package/dist/buried-critical.js.map +1 -1
  20. package/dist/canonical-owner.d.ts.map +1 -1
  21. package/dist/canonical-owner.js +45 -25
  22. package/dist/canonical-owner.js.map +1 -1
  23. package/dist/ci-alignment.d.ts.map +1 -1
  24. package/dist/ci-alignment.js +3 -2
  25. package/dist/ci-alignment.js.map +1 -1
  26. package/dist/command-validity.d.ts +16 -0
  27. package/dist/command-validity.d.ts.map +1 -1
  28. package/dist/command-validity.js +337 -63
  29. package/dist/command-validity.js.map +1 -1
  30. package/dist/conflicts.d.ts.map +1 -1
  31. package/dist/conflicts.js +3 -2
  32. package/dist/conflicts.js.map +1 -1
  33. package/dist/context-bloat.d.ts +1 -1
  34. package/dist/context-bloat.d.ts.map +1 -1
  35. package/dist/context-bloat.js +18 -7
  36. package/dist/context-bloat.js.map +1 -1
  37. package/dist/context-optimizer.d.ts.map +1 -1
  38. package/dist/context-optimizer.js +5 -2
  39. package/dist/context-optimizer.js.map +1 -1
  40. package/dist/custom-rules.d.ts.map +1 -1
  41. package/dist/custom-rules.js +6 -1
  42. package/dist/custom-rules.js.map +1 -1
  43. package/dist/dead-references.d.ts +16 -2
  44. package/dist/dead-references.d.ts.map +1 -1
  45. package/dist/dead-references.js +635 -77
  46. package/dist/dead-references.js.map +1 -1
  47. package/dist/dependency-staleness.d.ts +35 -0
  48. package/dist/dependency-staleness.d.ts.map +1 -0
  49. package/dist/dependency-staleness.js +1001 -0
  50. package/dist/dependency-staleness.js.map +1 -0
  51. package/dist/detectors.d.ts.map +1 -1
  52. package/dist/detectors.js +31 -6
  53. package/dist/detectors.js.map +1 -1
  54. package/dist/duplicates.d.ts.map +1 -1
  55. package/dist/duplicates.js +5 -4
  56. package/dist/duplicates.js.map +1 -1
  57. package/dist/evidence.d.ts +11 -0
  58. package/dist/evidence.d.ts.map +1 -1
  59. package/dist/evidence.js +40 -0
  60. package/dist/evidence.js.map +1 -1
  61. package/dist/finding-id.d.ts +21 -0
  62. package/dist/finding-id.d.ts.map +1 -0
  63. package/dist/finding-id.js +31 -0
  64. package/dist/finding-id.js.map +1 -0
  65. package/dist/frameworks/dotnet.js +2 -2
  66. package/dist/frameworks/dotnet.js.map +1 -1
  67. package/dist/frameworks/rust.d.ts.map +1 -1
  68. package/dist/frameworks/rust.js +2 -1
  69. package/dist/frameworks/rust.js.map +1 -1
  70. package/dist/git-symlinks.d.ts +30 -0
  71. package/dist/git-symlinks.d.ts.map +1 -0
  72. package/dist/git-symlinks.js +161 -0
  73. package/dist/git-symlinks.js.map +1 -0
  74. package/dist/gitignore.d.ts +22 -0
  75. package/dist/gitignore.d.ts.map +1 -0
  76. package/dist/gitignore.js +157 -0
  77. package/dist/gitignore.js.map +1 -0
  78. package/dist/health-score.d.ts.map +1 -1
  79. package/dist/health-score.js +34 -5
  80. package/dist/health-score.js.map +1 -1
  81. package/dist/index.d.ts +9 -3
  82. package/dist/index.d.ts.map +1 -1
  83. package/dist/index.js +5 -2
  84. package/dist/index.js.map +1 -1
  85. package/dist/makefile.d.ts +37 -0
  86. package/dist/makefile.d.ts.map +1 -0
  87. package/dist/makefile.js +156 -0
  88. package/dist/makefile.js.map +1 -0
  89. package/dist/manifest-consistency.d.ts.map +1 -1
  90. package/dist/manifest-consistency.js +52 -8
  91. package/dist/manifest-consistency.js.map +1 -1
  92. package/dist/negative-instructions.d.ts.map +1 -1
  93. package/dist/negative-instructions.js +4 -9
  94. package/dist/negative-instructions.js.map +1 -1
  95. package/dist/path-containment.d.ts +29 -0
  96. package/dist/path-containment.d.ts.map +1 -1
  97. package/dist/path-containment.js +70 -0
  98. package/dist/path-containment.js.map +1 -1
  99. package/dist/prompt-cache.d.ts.map +1 -1
  100. package/dist/prompt-cache.js +8 -6
  101. package/dist/prompt-cache.js.map +1 -1
  102. package/dist/repo-context.d.ts +53 -0
  103. package/dist/repo-context.d.ts.map +1 -1
  104. package/dist/repo-context.js +134 -11
  105. package/dist/repo-context.js.map +1 -1
  106. package/dist/report.d.ts +7 -0
  107. package/dist/report.d.ts.map +1 -1
  108. package/dist/report.js +36 -7
  109. package/dist/report.js.map +1 -1
  110. package/dist/scan.d.ts.map +1 -1
  111. package/dist/scan.js +41 -2
  112. package/dist/scan.js.map +1 -1
  113. package/dist/scanner.d.ts +49 -1
  114. package/dist/scanner.d.ts.map +1 -1
  115. package/dist/scanner.js +323 -38
  116. package/dist/scanner.js.map +1 -1
  117. package/dist/security-pack.d.ts.map +1 -1
  118. package/dist/security-pack.js +6 -4
  119. package/dist/security-pack.js.map +1 -1
  120. package/dist/skill-supply-chain.d.ts +132 -0
  121. package/dist/skill-supply-chain.d.ts.map +1 -0
  122. package/dist/skill-supply-chain.js +2954 -0
  123. package/dist/skill-supply-chain.js.map +1 -0
  124. package/dist/skills-detector.d.ts +7 -3
  125. package/dist/skills-detector.d.ts.map +1 -1
  126. package/dist/skills-detector.js +98 -22
  127. package/dist/skills-detector.js.map +1 -1
  128. package/dist/stale-instructions.d.ts.map +1 -1
  129. package/dist/stale-instructions.js +2 -1
  130. package/dist/stale-instructions.js.map +1 -1
  131. package/dist/suppression.d.ts +2 -0
  132. package/dist/suppression.d.ts.map +1 -1
  133. package/dist/suppression.js +5 -2
  134. package/dist/suppression.js.map +1 -1
  135. package/dist/types.d.ts +35 -2
  136. package/dist/types.d.ts.map +1 -1
  137. package/dist/types.js +22 -0
  138. package/dist/types.js.map +1 -1
  139. package/dist/vague-guidance.d.ts.map +1 -1
  140. package/dist/vague-guidance.js +2 -1
  141. package/dist/vague-guidance.js.map +1 -1
  142. package/dist/windsurf-detector.d.ts.map +1 -1
  143. package/dist/windsurf-detector.js +15 -6
  144. package/dist/windsurf-detector.js.map +1 -1
  145. package/dist/within-section-dedup.d.ts.map +1 -1
  146. package/dist/within-section-dedup.js +4 -9
  147. package/dist/within-section-dedup.js.map +1 -1
  148. package/dist/workspace-manifests.d.ts +80 -0
  149. package/dist/workspace-manifests.d.ts.map +1 -0
  150. package/dist/workspace-manifests.js +333 -0
  151. package/dist/workspace-manifests.js.map +1 -0
  152. package/package.json +1 -1
@@ -0,0 +1,2954 @@
1
+ /**
2
+ * Skill supply-chain scan
3
+ *
4
+ * An Agent Skill is instructions (SKILL.md plus reference docs) and bundled
5
+ * scripts that an agent loads on demand and runs with the user's permissions.
6
+ * Installing a third-party skill is installing code. The structural skills
7
+ * detector (skills-detector.ts) checks that a skill *loads*; this detector
8
+ * reads the same skill — and every file bundled in its directory — as plain
9
+ * text and flags content characteristic of supply-chain abuse.
10
+ *
11
+ * Static only. Nothing here executes, imports, fetches or resolves anything:
12
+ * skill content is matched against fixed patterns, so the same files always
13
+ * produce the same findings (no network, no clock, no LLM).
14
+ *
15
+ * Rules (tag `skill-supply-chain` + the rule id below):
16
+ *
17
+ * remote-exec fetched content piped into a shell (`curl … |
18
+ * bash`, by path, via env/sudo/busybox/xargs — an
19
+ * `env A=…` assignment of any length — in a
20
+ * subshell, through any number of pipe stages),
21
+ * into `tee >(bash)`, into an interpreter that runs
22
+ * its own stdin (`node -e "eval(readFileSync(0))"`,
23
+ * `python -c 'exec(sys.stdin.read())'`, `ruby -e`,
24
+ * `perl -e`, `node /dev/stdin`), or into a `while
25
+ * read l; do eval "$l"; done` loop; process/here-
26
+ * string substitution, `iex (iwr …)`, or a
27
+ * downloaded file executed later in the same
28
+ * command without first being verified high
29
+ * encoded-exec decoded or transformed content executed (`base64
30
+ * -d | sh`, `xxd -r -p | sh`, `rev | sh`, a rot13
31
+ * `tr`, `base32 -d`, a run of `\x63\x75…` escapes,
32
+ * decode-to-file then run, `eval "$(… | base64
33
+ * -d)"`, `exec(b64decode(…))`, `powershell -enc`) high
34
+ * remote-eval eval of fetched code (also in SKILL.md code,
35
+ * including a fetch function imported under another
36
+ * name: `urlopen as u`, `import requests as r`), or
37
+ * a script that both evaluates dynamic code AND
38
+ * makes network calls high
39
+ * dynamic-eval eval/exec/new Function/iex, no network call warning
40
+ * credential-exfil credential-store read or environment dump AND
41
+ * network calls in the same file high
42
+ * exfil-instruction prose sending secrets to a destination high/warning
43
+ * instruction-override prompt-injection text high
44
+ * conceal-from-user prose hiding actions from the user high/warning
45
+ * permission-bypass flags/prose disabling permission prompts warning
46
+ * hidden-unicode tag characters, bidi controls (high); other
47
+ * invisible/format/control characters (warning)
48
+ * hidden-html-comment agent-directed text inside an HTML comment warning
49
+ * hidden-html-element text hidden by `display:none`/`hidden` warning
50
+ * encoded-blob a large base64 blob in instruction text warning
51
+ * unpinned-remote-dep a remote dependency with no pinned version warning
52
+ * missing-script the SKILL.md runs a script that exists nowhere
53
+ * warning, ai_config
54
+ * unscanned-files content this scan could not fully read, named:
55
+ * binaries/archives/executables, compiled-language
56
+ * sources, files over the cap or budget, oversized
57
+ * files (head and tail read), overlong continuation
58
+ * chains (warning); dependency directories and
59
+ * include-filtered files (info)
60
+ *
61
+ * Hostile input: every regex uses bounded quantifiers and runs over lines in
62
+ * overlapping 2000-character windows behind cheap prefilters, and every rule
63
+ * whose span can exceed the window overlap (pipes, command chains) is matched
64
+ * procedurally over the whole logical command, so results do not depend on
65
+ * window alignment. HTML comments and tags are found with indexOf walks. A
66
+ * rule keeps scanning past its evidence cap (a flood of low-severity matches
67
+ * cannot hide a later high-severity one). Documents are loaded, scanned and
68
+ * dropped one at a time under a per-scan byte, file and line budget, SKILL.md
69
+ * files and scripts/executables first; a priority file the budget cannot
70
+ * reach is reported at HIGH (fail closed). There is deliberately no
71
+ * wall-clock budget: output must not depend on the clock.
72
+ *
73
+ * Documentation vs directive (the false-positive controls). Outside fenced
74
+ * code — prose, inline code, table rows — a negation or discussion word only
75
+ * counts when it directly GOVERNS the match: an imperative negation with
76
+ * nothing or one executing verb between ("never run `curl … | bash`"), a
77
+ * discussion word right before it ("this skill detects `curl | bash`" — a
78
+ * `Label:` is not governance), or a predicate right after it ("… is
79
+ * dangerous"). Governance ends at but / and / then / instead / so and at
80
+ * clause punctuation; "never forget/hesitate/stop/skip/fail/neglect/mind" is
81
+ * not a negation. Even then, context NEVER drops a match, or lowers its
82
+ * severity, when the match names a concrete target — a URL, a host (dotted,
83
+ * IPv4/IPv6, host:port, defanged or quote-split), a host/path, a `$VARIABLE`
84
+ * or `$(…)`, or any address-like argument or config/list flag given to
85
+ * curl/wget/iwr, or a dot-less host (`wget intranet`) that is the fetcher's
86
+ * first non-flag argument while the same text pipes into a shell or runs the
87
+ * download (a bare word elsewhere is prose: "pipe curl into bash") — it only
88
+ * lowers confidence (0.4) and notes "looks like documentation". Target-less
89
+ * exec documentation ("never run `curl | bash`")
90
+ * is dropped; target-less override/concealment/bypass text is dropped only
91
+ * when the governed phrase is itself set off in a code span, quotes or a
92
+ * table cell (otherwise it is kept at low confidence), and quoted text after
93
+ * an adoption phrase ("your new rule is \"…\"") is never data. The trade-off: a
94
+ * security skill quoting a real installer URL is reported (high, low
95
+ * confidence); the escape hatch is the baseline or a config `exclude`. A
96
+ * documented example whose governing word the rules do not recognise ("Instead
97
+ * of `curl URL | bash`", a table of risky patterns, a "bad example" fence)
98
+ * stays at full confidence; the benign-documentation corpus in
99
+ * skill-supply-chain.test.ts pins both behaviours.
100
+ * Other controls: one named API key sent to an API is not exfiltration;
101
+ * dynamic require/import only counts next to network access; emoji ZWJ, a
102
+ * single variation selector after a non-ASCII character, flag tag sequences,
103
+ * RTL marks in RTL text, a leading BOM and `data:` URIs are ignored; `npx
104
+ * tool` without a version is not flagged; a table row whose first cell is just
105
+ * a flag, and whose other cells direct nothing, documents that flag.
106
+ *
107
+ * Findings list the file they were found in plus the owning SKILL.md in
108
+ * `filePaths`, with `locations` at the exact line. Inline `promptci-ignore`
109
+ * annotations inside a skill directory never suppress these findings
110
+ * (scan.ts). Evidence quotes the matched text with every invisible or control
111
+ * character shown as `<U+XXXX>` and tag-character payloads decoded. No finding
112
+ * is auto-fixable: repairing a skill needs a human-reviewed diff.
113
+ */
114
+ import * as fs from 'node:fs';
115
+ import * as path from 'node:path';
116
+ import { isFileWithinRoot, isSkillContainerDir, lineOf, resolveWithinRoot, shortHash, withScannerPaths, } from './ai-config.js';
117
+ import { MAX_FILE_SIZE, BINARY_CHECK_BYTES, isBinary } from './scanner.js';
118
+ import { realPathWithinRoot } from './path-containment.js';
119
+ import { scanFencedLines } from './markdown-fences.js';
120
+ import { extractFileRefs } from './skills-detector.js';
121
+ import { codepointLabel, visibleText } from './evidence.js';
122
+ /** First entry of every finding's `tags`. */
123
+ export const SKILL_SUPPLY_CHAIN_TAG = 'skill-supply-chain';
124
+ /** Total bytes of skill content read per scan; the rest is reported, not read. */
125
+ export const MAX_SCAN_BYTES = 20 * 1024 * 1024;
126
+ /** Total skill files read per scan. */
127
+ export const MAX_SCAN_FILES = 2000;
128
+ // ── Rule wording ──────────────────────────────────────────────────────────────
129
+ const RULES = {
130
+ 'remote-exec': {
131
+ title: 'Skill may download and execute remote code',
132
+ category: 'security',
133
+ summary: 'content fetched from the network appears to be executed — piped or substituted into an interpreter, or downloaded and run ' +
134
+ 'without verification. Whatever the server returns at run time runs with the user\'s permissions, and it can change after the skill was reviewed.',
135
+ recommendation: 'Consider vendoring the script into the skill directory (so it is reviewed with the skill), or download it to a file, ' +
136
+ 'verify a pinned checksum, and only then run it.',
137
+ },
138
+ 'encoded-exec': {
139
+ title: 'Skill may decode and execute an encoded payload',
140
+ category: 'security',
141
+ summary: 'an encoded string appears to be decoded and executed. Encoding hides what actually runs from anyone reviewing the skill.',
142
+ recommendation: 'Replace the encoded payload with the plain-text commands it runs, so the behavior can be reviewed.',
143
+ },
144
+ 'remote-eval': {
145
+ title: 'Skill may evaluate code fetched from the network',
146
+ category: 'security',
147
+ summary: 'code appears to be evaluated dynamically (eval/exec, `new Function`, a dynamic import, or deserialization) ' +
148
+ 'alongside network calls — the shape of a remote-code loader.',
149
+ recommendation: 'Review what the evaluated code is built from. If it comes from the network, replace the loader with code bundled in the skill.',
150
+ },
151
+ 'dynamic-eval': {
152
+ title: 'Skill script evaluates dynamically built code',
153
+ category: 'security',
154
+ summary: 'a bundled script uses eval/exec, `new Function`, or Invoke-Expression. What it runs cannot be read from the script itself.',
155
+ recommendation: 'Consider replacing dynamic evaluation with direct code so the script\'s behavior is reviewable.',
156
+ },
157
+ 'credential-exfil': {
158
+ title: 'Skill script reads credentials and makes network calls',
159
+ category: 'security',
160
+ summary: 'a bundled file reads a credential store (SSH keys, cloud credentials, wallets, the keychain) or dumps ' +
161
+ 'the whole environment, and the same file makes network calls — a possible credential-exfiltration path.',
162
+ recommendation: 'Confirm the script needs this access. A skill should read only the specific variable it needs and send it only to the ' +
163
+ 'service that requires it; remove bulk environment dumps and credential-file reads.',
164
+ },
165
+ 'exfil-instruction': {
166
+ title: 'Skill instructions may direct sending secrets off-machine',
167
+ category: 'security',
168
+ summary: 'the instructions appear to tell the agent to send, upload or post secrets, credentials or environment variables to a destination.',
169
+ recommendation: 'Remove the instruction. Skills should never direct an agent to transmit credentials anywhere; if an API call needs a key, ' +
170
+ 'reference the environment variable by name only.',
171
+ },
172
+ 'instruction-override': {
173
+ title: 'Skill contains prompt-injection style override text',
174
+ category: 'security',
175
+ summary: 'the text appears to try to override the agent\'s existing instructions (e.g. "ignore previous instructions") or embeds ' +
176
+ 'chat-template role tokens. A skill has no legitimate reason to do this.',
177
+ recommendation: 'Remove the override text. If the skill documents injection strings as examples, quote them so they read as data.',
178
+ },
179
+ 'conceal-from-user': {
180
+ title: 'Skill instructions may hide actions from the user',
181
+ category: 'security',
182
+ summary: 'the instructions appear to tell the agent to act silently or keep something from the user.',
183
+ recommendation: 'Remove the concealment. Every action a skill takes should be visible to the person running it.',
184
+ },
185
+ 'permission-bypass': {
186
+ title: 'Skill may disable agent permission or safety prompts',
187
+ category: 'security',
188
+ summary: 'the skill appears to turn off permission prompts, approvals or sandboxing (for example `--dangerously-skip-permissions`), ' +
189
+ 'so later actions would run without the user confirming them.',
190
+ recommendation: 'Remove the bypass and let the agent\'s normal permission prompts apply; grant narrowly scoped `allowed-tools` instead if needed.',
191
+ },
192
+ 'hidden-unicode': {
193
+ title: 'Skill contains invisible or bidirectional Unicode characters',
194
+ category: 'security',
195
+ summary: 'the file contains characters that do not render — Unicode tag characters and bidi controls can smuggle instructions ' +
196
+ 'or reorder text so what a reviewer sees differs from what the agent reads.',
197
+ recommendation: 'Remove the invisible characters (the evidence shows them as <U+XXXX>, and decodes tag-character payloads) and re-review the affected lines.',
198
+ },
199
+ 'hidden-html-comment': {
200
+ title: 'Skill hides agent-directed text in an HTML comment',
201
+ category: 'security',
202
+ summary: 'an HTML comment contains agent-directed or sensitive text. Rendered previews hide comments, but the agent reads them.',
203
+ recommendation: 'Move the text into visible prose (or delete it) so reviewers see everything the agent is told.',
204
+ },
205
+ 'hidden-html-element': {
206
+ title: 'Skill hides text from rendered view with HTML/CSS',
207
+ category: 'security',
208
+ summary: 'an HTML element hides its content from rendered previews (`display:none`, `visibility:hidden`, `hidden`), but the agent still reads it.',
209
+ recommendation: 'Remove the hiding markup so the content is visible to reviewers, or delete the hidden content.',
210
+ },
211
+ 'encoded-blob': {
212
+ title: 'Skill instructions contain a large encoded blob',
213
+ category: 'security',
214
+ summary: 'the instruction text contains a long base64-like string whose content cannot be reviewed by reading the skill.',
215
+ recommendation: 'Replace the blob with the plain content it encodes, or move binary assets into bundled files.',
216
+ },
217
+ 'unpinned-remote-dep': {
218
+ title: 'Skill pulls a remote dependency without a pinned version',
219
+ category: 'security',
220
+ summary: 'the skill installs or fetches remote code by a mutable reference (a git URL without a commit or tag, `@latest`, or a ' +
221
+ 'script on a branch), so what runs can change after the skill was reviewed.',
222
+ recommendation: 'Pin the dependency to an exact version, tag or commit SHA (and prefer a checksum for downloaded scripts).',
223
+ },
224
+ 'missing-script': {
225
+ title: 'Skill invokes a script that is not bundled with it',
226
+ category: 'ai_config',
227
+ summary: 'the skill tells the agent to run a script that exists neither in the skill directory nor at that path in the repo, so ' +
228
+ 'what actually runs cannot be reviewed with the skill.',
229
+ recommendation: 'Bundle the script in the skill directory, fix the path, or remove the instruction.',
230
+ },
231
+ 'unscanned-files': {
232
+ title: 'Some skill content was not fully scanned',
233
+ category: 'security',
234
+ summary: 'part of this skill could not be checked by the supply-chain scan — binaries, archives or compiled-language sources it cannot ' +
235
+ 'analyze, files beyond the per-skill cap or the per-scan budget, the middle of very large files, or directories it does not walk. ' +
236
+ 'Anything in those parts was not checked.',
237
+ recommendation: 'Review the listed content by hand. A skill that ships binaries or very large files deserves extra scrutiny before installing.',
238
+ },
239
+ };
240
+ // ── File classification ───────────────────────────────────────────────────────
241
+ const INSTRUCTION_EXT = new Set(['md', 'mdx', 'markdown', 'txt']);
242
+ const TEXT_EXT = new Set([
243
+ 'yml', 'yaml', 'json', 'jsonc', 'json5', 'toml', 'ini', 'cfg', 'conf', 'html', 'htm', 'xhtml', 'xml', 'svg',
244
+ ]);
245
+ const SCRIPT_EXT = {
246
+ sh: 'sh', bash: 'sh', zsh: 'sh', ksh: 'sh', fish: 'sh', command: 'sh',
247
+ js: 'js', mjs: 'js', cjs: 'js', ts: 'js', mts: 'js', cts: 'js', jsx: 'js', tsx: 'js',
248
+ py: 'py', pyw: 'py',
249
+ rb: 'rb',
250
+ ps1: 'ps', psm1: 'ps',
251
+ pl: 'other', php: 'other', lua: 'other', bat: 'other', cmd: 'other',
252
+ };
253
+ /**
254
+ * Compiled-language and infrastructure sources: read for command/exfil
255
+ * patterns only. Noted at info (the rules that matter still ran on them).
256
+ */
257
+ const SOURCE_EXT = new Set([
258
+ 'go', 'rs', 'java', 'c', 'cc', 'cpp', 'cxx', 'h', 'hpp', 'cs', 'swift', 'kt', 'kts', 'groovy', 'gradle', 'scala',
259
+ 'tf', 'hcl', 'sql', 'service', 'desktop',
260
+ ]);
261
+ /** Windows/macOS script hosts: read for command/exfil patterns, noted at warning (they run directly). */
262
+ const SCRIPT_HOST_EXT = new Set(['vbs', 'vba', 'vbe', 'hta', 'applescript', 'wsf', 'jse', 'reg']);
263
+ /** Binary executables, archives and shortcuts: never readable as text — reported. */
264
+ const BINARY_REPORT_EXT = new Set([
265
+ 'exe', 'dll', 'so', 'dylib', 'wasm', 'jar', 'war', 'class', 'zip', 'tar', 'gz', 'tgz', 'bz2', 'xz', '7z', 'rar',
266
+ 'msi', 'dmg', 'pkg', 'deb', 'rpm', 'appimage', 'apk', 'bin', 'o', 'a', 'lnk', 'scr', 'com', 'pif', 'scpt', 'node',
267
+ ]);
268
+ /** Office documents and databases: not readable here, rarely executable — noted at info. */
269
+ const OPAQUE_DOC_EXT = new Set(['docx', 'xlsx', 'pptx', 'doc', 'xls', 'ppt', 'odt', 'sqlite', 'sqlite3', 'db']);
270
+ /** Media, fonts, data and OS clutter: assets, not read and not reported. */
271
+ const ASSET_EXT = new Set([
272
+ 'png', 'jpg', 'jpeg', 'gif', 'webp', 'bmp', 'ico', 'tif', 'tiff', 'pdf', 'woff', 'woff2', 'ttf', 'otf', 'eot',
273
+ 'mp3', 'mp4', 'wav', 'ogg', 'flac', 'mov', 'avi', 'webm', 'm4a', 'csv', 'tsv', 'parquet', 'psd', 'sketch', 'fig', 'pyc',
274
+ ]);
275
+ const ASSET_NAMES = new Set(['.ds_store', 'thumbs.db', 'desktop.ini']);
276
+ /** Classify by name alone; 'probe' means "sniff the first bytes". */
277
+ function classifyByName(relPath) {
278
+ const base = path.posix.basename(relPath);
279
+ if (ASSET_NAMES.has(base.toLowerCase()))
280
+ return { skip: 'asset' };
281
+ if (base === 'package.json' || /^requirements[\w.-]{0,40}\.txt$/i.test(base))
282
+ return { kind: 'manifest', lang: 'other' };
283
+ if (/^(?:GNU)?makefile$/i.test(base) || /^(?:Dockerfile|Containerfile|Justfile)(?:\..{1,40})?$/i.test(base)) {
284
+ return { kind: 'text', lang: 'sh' };
285
+ }
286
+ // Templates (.env.example/.sample/.template/.dist) document variable names, not secrets.
287
+ if (/^\.env\.(?:example|sample|template|dist|defaults?)$/i.test(base))
288
+ return { skip: 'asset' };
289
+ if (/^\.env(?:\..{1,40})?$/i.test(base) || /\.env$/i.test(base)) {
290
+ return { report: 'environment/secrets file bundled with the skill — not read; review it by hand', severity: 'warning' };
291
+ }
292
+ const ext = /\.([a-z0-9]{1,12})$/i.exec(base)?.[1]?.toLowerCase();
293
+ if (ext === undefined || base.lastIndexOf('.') <= 0)
294
+ return 'probe';
295
+ if (INSTRUCTION_EXT.has(ext))
296
+ return { kind: 'instructions', lang: 'other' };
297
+ if (SCRIPT_EXT[ext])
298
+ return { kind: 'script', lang: SCRIPT_EXT[ext] };
299
+ if (ext === 'mk' || ext === 'dockerfile')
300
+ return { kind: 'text', lang: 'sh' };
301
+ if (TEXT_EXT.has(ext))
302
+ return { kind: 'text', lang: 'other' };
303
+ if (SOURCE_EXT.has(ext)) {
304
+ return { kind: 'text', lang: 'other', note: { reason: `.${ext} source — only command and exfiltration patterns were checked`, severity: 'info' } };
305
+ }
306
+ if (SCRIPT_HOST_EXT.has(ext)) {
307
+ return { kind: 'script', lang: 'other', note: { reason: `.${ext} script — only command and exfiltration patterns were checked`, severity: 'warning' } };
308
+ }
309
+ if (BINARY_REPORT_EXT.has(ext))
310
+ return { report: `binary executable or archive (.${ext}) — cannot be reviewed as text`, severity: 'warning' };
311
+ if (OPAQUE_DOC_EXT.has(ext))
312
+ return { report: `.${ext} document/database — not read`, severity: 'info' };
313
+ if (ASSET_EXT.has(ext))
314
+ return { skip: 'asset' };
315
+ return 'probe'; // unknown extension: text is scanned, binary is reported
316
+ }
317
+ /**
318
+ * Scanned first, so no amount of decoy content can push them past a budget:
319
+ * every SKILL.md, scripts, manifests, shell-like config, executables and
320
+ * extensionless/unknown files (which may be executables).
321
+ */
322
+ function isPriorityFile(relPath) {
323
+ if (path.posix.basename(relPath) === 'SKILL.md')
324
+ return true;
325
+ const cls = classifyByName(relPath);
326
+ if (cls === 'probe')
327
+ return true;
328
+ if ('skip' in cls)
329
+ return false;
330
+ if ('report' in cls)
331
+ return cls.severity !== 'info';
332
+ return cls.kind === 'script' || cls.kind === 'manifest' || (cls.kind === 'text' && cls.lang === 'sh');
333
+ }
334
+ function langFromShebang(firstLine) {
335
+ const m = /^#!\s{0,5}(\S{1,200})(?:\s{1,5}(\S{1,100}))?/.exec(firstLine);
336
+ if (!m)
337
+ return undefined;
338
+ const interp = path.posix.basename(m[1]) === 'env' ? (m[2] ?? '') : path.posix.basename(m[1]);
339
+ if (/^(?:node|deno|bun|tsx|ts-node)$/.test(interp))
340
+ return 'js';
341
+ if (/^python/.test(interp))
342
+ return 'py';
343
+ if (/^(?:ba|z|k|da|fi)?sh$/.test(interp))
344
+ return 'sh';
345
+ if (/^ruby$/.test(interp))
346
+ return 'rb';
347
+ if (/^pwsh$/.test(interp))
348
+ return 'ps';
349
+ return 'other';
350
+ }
351
+ function binaryKind(head) {
352
+ if (head.length >= 4 && head[0] === 0x7f && head[1] === 0x45 && head[2] === 0x4c && head[3] === 0x46)
353
+ return 'ELF executable';
354
+ if (head.length >= 2 && head[0] === 0x4d && head[1] === 0x5a)
355
+ return 'Windows executable';
356
+ if (head.length >= 4 && (head.readUInt32BE(0) === 0xfeedface || head.readUInt32BE(0) === 0xfeedfacf ||
357
+ head.readUInt32BE(0) === 0xcefaedfe || head.readUInt32BE(0) === 0xcffaedfe || head.readUInt32BE(0) === 0xcafebabe)) {
358
+ return 'Mach-O/Java binary';
359
+ }
360
+ if (head.length >= 4 && head[0] === 0x50 && head[1] === 0x4b && head[2] === 0x03 && head[3] === 0x04)
361
+ return 'ZIP archive';
362
+ return 'binary file';
363
+ }
364
+ function frontmatterEnd(lines) {
365
+ if ((lines[0] ?? '').replace(/^\u{FEFF}/u, '').trim() !== '---')
366
+ return 0;
367
+ for (let i = 1; i < lines.length && i < 500; i++) {
368
+ if (/^(?:---|\.\.\.)[ \t]*$/.test(lines[i]))
369
+ return i + 1;
370
+ }
371
+ return 0;
372
+ }
373
+ // ── Text helpers ──────────────────────────────────────────────────────────────
374
+ const visible = visibleText;
375
+ /** A display-safe path: invisibles shown, wrapped in a code span nothing can break out of. */
376
+ function displayPath(p) {
377
+ const safe = visible(p);
378
+ const longestRun = Math.max(0, ...(safe.match(/`+/g) ?? []).map((run) => run.length));
379
+ const fence = '`'.repeat(longestRun + 1);
380
+ return `${fence}${safe}${fence}`;
381
+ }
382
+ /**
383
+ * A display excerpt of `text` around the match at `index`. The window is cut
384
+ * from the RAW string first (so padding before a payload cannot push it out
385
+ * of view), then sanitized and whitespace-collapsed.
386
+ */
387
+ function excerpt(text, index = 0, length = 0) {
388
+ const from = Math.max(0, index - 40);
389
+ const to = Math.min(text.length, from + 220, index + Math.max(0, length) + 60);
390
+ const shown = visible(text.slice(from, to)).replace(/\s+/g, ' ').trim();
391
+ return `${from > 0 ? '…' : ''}${shown}${to < text.length ? '…' : ''}`;
392
+ }
393
+ /** Longest line segment a regex ever sees; long lines are scanned in overlapping windows. */
394
+ const MAX_SCAN_WINDOW = 2000;
395
+ /** Every windowed regex's longest possible match is well under this. */
396
+ const WINDOW_OVERLAP = 600;
397
+ function fakeMatch(text, index, end) {
398
+ return Object.assign([text.slice(index, end)], { index, input: text, groups: undefined });
399
+ }
400
+ /** First match of a NON-global `re` in `text`, windowed so every pattern stays linear. */
401
+ function findFirst(re, text, requires) {
402
+ if (text.length <= MAX_SCAN_WINDOW) {
403
+ if (requires && !requires(text))
404
+ return undefined;
405
+ const m = re.exec(text);
406
+ return m ? { index: m.index, match: m } : undefined;
407
+ }
408
+ for (let start = 0; start < text.length; start += MAX_SCAN_WINDOW - WINDOW_OVERLAP) {
409
+ const window = text.slice(start, start + MAX_SCAN_WINDOW);
410
+ const m = requires && !requires(window) ? null : re.exec(window);
411
+ if (m)
412
+ return { index: start + m.index, match: m };
413
+ if (start + MAX_SCAN_WINDOW >= text.length)
414
+ break;
415
+ }
416
+ return undefined;
417
+ }
418
+ /** Every match of a GLOBAL `re` in `text` (windowed, de-duplicated by position), up to `limit`. */
419
+ function findAll(re, text, limit = 50) {
420
+ const out = [];
421
+ if (text.length <= MAX_SCAN_WINDOW) {
422
+ for (const m of text.matchAll(re)) {
423
+ out.push({ index: m.index ?? 0, match: m });
424
+ if (out.length >= limit)
425
+ break;
426
+ }
427
+ return out;
428
+ }
429
+ const seen = new Set();
430
+ for (let start = 0; start < text.length; start += MAX_SCAN_WINDOW - WINDOW_OVERLAP) {
431
+ for (const m of text.slice(start, start + MAX_SCAN_WINDOW).matchAll(re)) {
432
+ const index = start + (m.index ?? 0);
433
+ if (seen.has(index))
434
+ continue;
435
+ seen.add(index);
436
+ out.push({ index, match: m });
437
+ if (out.length >= limit)
438
+ return out;
439
+ }
440
+ if (start + MAX_SCAN_WINDOW >= text.length)
441
+ break;
442
+ }
443
+ return out;
444
+ }
445
+ const MAX_JOINED_LINES = 20;
446
+ const MAX_JOINED_CHARS = 4000;
447
+ /** A download-fed `| while read` loop body is joined further: ordinary scripts have long loop bodies. */
448
+ const MAX_LOOP_JOINED_LINES = 200;
449
+ const MAX_LOOP_JOINED_CHARS = 20000;
450
+ /** A line that starts a piped loop whose body continues on the next lines: `… | while read l; do`. */
451
+ const LOOP_OPEN_RE = /\|\s{0,5}[({]?\s{0,3}(?:while|until)\b/;
452
+ const DO_WORD_RE = /\bdo\b/g;
453
+ const DONE_WORD_RE = /\bdone\b/g;
454
+ const LOOP_REV_RE = /\|\s{0,5}rev\s{0,5}\|/;
455
+ /**
456
+ * Only a loop fed by a download or a decode/transform is worth joining: an
457
+ * ordinary `cmd | while read x; do … done` with a long body must not look like
458
+ * an over-long continuation chain.
459
+ */
460
+ const isLoopProducerLine = (line) => FETCH_STAGE_RE.test(line) || ENCODED_PRODUCER_RE.test(line) || LOOP_REV_RE.test(line);
461
+ /**
462
+ * Join continued lines so a `curl … \` / `| bash` split, or a pipe at the end
463
+ * of one line and the shell on the next, is one command. A markdown table row
464
+ * (starting with `|`) never pipe-joins. Joins are capped so 16k continued lines
465
+ * cannot become one giant string; each cap hit is returned so it can be
466
+ * reported instead of silently splitting a command.
467
+ */
468
+ function logicalLines(doc) {
469
+ const out = [];
470
+ const capped = [];
471
+ let buf = '';
472
+ let start = 0;
473
+ let joined = 0;
474
+ let prev = -2;
475
+ // Inside an unfinished `… | while read l; do` body: lines are joined (with `;`) up to its `done`.
476
+ // `loopDepth` counts open `do`s so a nested loop's `done` does not close the outer one early.
477
+ let loop = false;
478
+ let loopDepth = 0;
479
+ let loopSawDo = false;
480
+ // Only non-blank lines produce logical lines; a blank line ends any continuation.
481
+ for (const i of doc.active) {
482
+ const l = doc.lines[i];
483
+ if (buf !== '' && i !== prev + 1) {
484
+ out.push({ text: buf, line: start });
485
+ buf = '';
486
+ loop = false;
487
+ }
488
+ prev = i;
489
+ if (buf === '') {
490
+ start = i + 1;
491
+ joined = 0;
492
+ }
493
+ const trimmed = l.trimEnd();
494
+ const backslash = trimmed.endsWith('\\') && !trimmed.endsWith('\\\\');
495
+ const operator = (trimmed.endsWith('|') || trimmed.endsWith('&&')) &&
496
+ !(doc.kind === 'instructions' && !doc.fenced[i] && l.trimStart().startsWith('|'));
497
+ let opensLoop = false;
498
+ if (!loop && trimmed.includes('|') && (doc.kind !== 'instructions' || doc.fenced[i] === 1) &&
499
+ LOOP_OPEN_RE.test(trimmed) && isLoopProducerLine(trimmed)) {
500
+ const dos = trimmed.match(DO_WORD_RE)?.length ?? 0;
501
+ const dones = trimmed.match(DONE_WORD_RE)?.length ?? 0;
502
+ // A loop that already closes on its own line (`… do …; done`) needs no joining.
503
+ if (dos === 0 || dos > dones) {
504
+ opensLoop = true;
505
+ loopDepth = dos - dones;
506
+ loopSawDo = dos > 0;
507
+ }
508
+ }
509
+ if (backslash || operator || loop || opensLoop) {
510
+ // Loop lines are joined trimmed, so indentation does not eat the join budget.
511
+ const piece = loop || opensLoop ? l.trim() : l;
512
+ const inLoop = loop || opensLoop;
513
+ if (joined < (inLoop ? MAX_LOOP_JOINED_LINES : MAX_JOINED_LINES) &&
514
+ buf.length + piece.length < (inLoop ? MAX_LOOP_JOINED_CHARS : MAX_JOINED_CHARS)) {
515
+ let closes = false;
516
+ if (loop) {
517
+ const dos = piece.match(DO_WORD_RE)?.length ?? 0;
518
+ const dones = piece.match(DONE_WORD_RE)?.length ?? 0;
519
+ if (dos > 0)
520
+ loopSawDo = true;
521
+ loopDepth += dos - dones;
522
+ closes = (loopSawDo && loopDepth <= 0) || (!loopSawDo && dones > 0);
523
+ }
524
+ // The opening line needs a `;` after it unless it already ends where a command list continues (`… do`).
525
+ const joiner = backslash || operator || (opensLoop && /(?:\bdo|[;{(])$/.test(piece)) ? ' ' : '; ';
526
+ const text = loop || opensLoop ? piece : trimmed;
527
+ buf += `${backslash ? text.slice(0, -1) : text}${closes ? '' : joiner}`;
528
+ joined++;
529
+ if (opensLoop)
530
+ loop = true;
531
+ if (closes) {
532
+ out.push({ text: buf, line: start });
533
+ buf = '';
534
+ loop = false;
535
+ }
536
+ continue;
537
+ }
538
+ capped.push(start);
539
+ loop = false;
540
+ }
541
+ out.push({ text: buf + l, line: start });
542
+ buf = '';
543
+ }
544
+ if (buf !== '')
545
+ out.push({ text: buf, line: start });
546
+ return { lines: out, capped };
547
+ }
548
+ // ── Documentation vs directive ────────────────────────────────────────────────
549
+ /**
550
+ * Imperative negations. Followed by mind/hesitate/forget/stop/skip/fail/neglect
551
+ * they are not negations of what follows ("never forget to run …").
552
+ */
553
+ const NEGATION_WORD_RE = /\b(?:never|don't|dont|do\s{1,5}not|avoid|must\s{1,5}not|mustn't|should\s{1,5}not|shouldn't|refuse\s{1,5}to)\b(?!\s{1,5}(?:mind|hesitate|forget|stop|skip|fail|neglect)\w{0,4}\b)/gi;
554
+ /**
555
+ * Governance ends at a clause boundary or a coordinating word. A colon ends it
556
+ * too: `Injection:` / `Detect:` / `Example of:` are labels, not governance.
557
+ */
558
+ const GOVERNANCE_BREAK_RE = /[,;:.!?]|\b(?:but|and|then|instead|so)\b/i;
559
+ /** Discussion words may sit up to this many words before the match. */
560
+ const MAX_GOVERNED_WORDS = 2;
561
+ /** The only words allowed between a negation and what it negates: "never RUN `…`". */
562
+ const EXECUTING_VERB_RE = /^(?:run|pipe|use|execute|invoke|call|send|pass|type|paste)$/i;
563
+ function words(gap) {
564
+ return gap.match(/[A-Za-z0-9_'-]+/g) ?? [];
565
+ }
566
+ /**
567
+ * Words that mark a match as being *discussed* — only these, and only when they
568
+ * govern the match. Generic words (bad, risk, flag, wrong, skip, avoid) are
569
+ * deliberately absent: one of them anywhere in a sentence must not silence a rule.
570
+ */
571
+ const DISCUSSION_WORD_RE = /\b(?:detect\w{0,10}|block(?:s|ed|ing)?|prevent\w{0,10}|reject\w{0,10}|forbid\w{0,10}|prohibit\w{0,10}|den(?:y|ies|ied)|malicious|dangerous|insecure|unsafe|vulnerab\w{0,10}|exploit\w{0,10}|injection|attack\w{0,10}|(?:not|un)\s{0,3}supported|anti-?pattern|example\s{1,5}of)\b/gi;
572
+ /** "`…` is dangerous" — a predicate right after the match. */
573
+ const DISCUSSION_AFTER_RE = /^[`'")\]\s]{0,6}(?:is|are|was|would\s{1,5}be|looks)\s{1,5}(?:dangerous|insecure|unsafe|malicious|risky|an?\s{1,5}(?:security\s{1,5})?(?:risk|red\s{1,5}flag|attack))\b/i;
574
+ /** The last `re` match inside `before` whose gap to the end passes `gapOk`. */
575
+ function governs(before, re, gapOk) {
576
+ let last;
577
+ re.lastIndex = 0;
578
+ for (const m of before.matchAll(re))
579
+ last = m;
580
+ if (!last)
581
+ return false;
582
+ return gapOk(before.slice((last.index ?? 0) + last[0].length));
583
+ }
584
+ /**
585
+ * An imperative negation that directly governs the match: nothing, or one
586
+ * executing verb, between them ("never `…`", "never run `…`"), same clause.
587
+ */
588
+ function governedNegation(text, index) {
589
+ return governs(text.slice(Math.max(0, index - 80), index), NEGATION_WORD_RE, (gap) => {
590
+ if (gap.length > 30 || GOVERNANCE_BREAK_RE.test(gap))
591
+ return false;
592
+ const w = words(gap);
593
+ return w.length === 0 || (w.length === 1 && EXECUTING_VERB_RE.test(w[0]));
594
+ });
595
+ }
596
+ /** A discussion word directly before the match (not a `Label:`), or a predicate right after it. */
597
+ function governedDiscussion(text, index, length) {
598
+ if (DISCUSSION_AFTER_RE.test(text.slice(index + length, index + length + 60)))
599
+ return true;
600
+ return governs(text.slice(Math.max(0, index - 80), index), DISCUSSION_WORD_RE, (gap) => gap.length <= 30 && !GOVERNANCE_BREAK_RE.test(gap) && words(gap).length <= MAX_GOVERNED_WORDS);
601
+ }
602
+ /**
603
+ * A concrete target: a URL, a host (dotted, IPv4, IPv6, host:port such as
604
+ * `localhost:8000`, or obfuscated — `evil[.]com`, `evil .com`, `"evil"".com"`,
605
+ * `'evil'.com`), a host/path, or a variable/command substitution.
606
+ */
607
+ const TARGET_RE = new RegExp([
608
+ String.raw `https?:\/\/`,
609
+ String.raw `\b(?:[a-z0-9-]{1,63}\.){1,5}[a-z]{2,24}(?::\d{1,5})?\/\S`,
610
+ String.raw `\b(?:[a-z0-9-]{1,63}\.){2,5}[a-z]{2,24}\b`,
611
+ String.raw `\b[a-z0-9-]{1,63}\.(?:com|net|org|io|dev|app|xyz|ru|cn|info|biz|co|me|tk|top|site|online|cloud|run|link|ly|gg|ai|so|to)\b`,
612
+ String.raw `\b\d{1,3}(?:\.\d{1,3}){3}\b`, // IPv4
613
+ String.raw `\[[0-9a-f:]{2,45}\]|\b(?:[0-9a-f]{1,4}:){2,7}[0-9a-f]{0,4}\b`, // IPv6
614
+ String.raw `\b[a-z][a-z0-9-]{0,62}(?:\.[a-z0-9-]{1,63}){0,5}:\d{2,5}\b`, // host:port
615
+ String.raw `\[\.\]|\(\.\)|\{\.\}|\[dot\]|\(dot\)`, // defanged hosts
616
+ String.raw `\w\s{1,3}\.(?:com|net|org|io|dev|app|xyz|ru|cn|info|biz|co|me|tk|top|site|sh)\b`, // "evil .com"
617
+ String.raw `\w["']{1,2}\.[a-z]{2,24}\b`, // 'evil'.com, "evil"".com"
618
+ String.raw `\$\{?[A-Za-z_][A-Za-z0-9_]{0,60}\}?`,
619
+ String.raw `\$\(`,
620
+ ].join('|'), 'i');
621
+ const FETCHER_RE = /\b(?:curl|wget|iwr|irm|invoke-webrequest|invoke-restmethod)\b/gi;
622
+ const FETCH_CONFIG_FLAG_RE = /^(?:-K|--config|-i|--input-file|-T|--upload-file|--url|--url-query)(?:=|$)|^-K\S/;
623
+ /**
624
+ * A fetcher given a non-flag argument that looks like an address
625
+ * (`curl intranet/x.sh`, `wget host:8080`, `curl $U`) or a config/list/url flag
626
+ * (`curl -K cfg`, `wget -i list`, `--url x`) has a target, whatever its
627
+ * spelling. Bare `curl | bash` does not — and neither does prose such as
628
+ * "pipe curl into bash", whose "arguments" are plain words.
629
+ */
630
+ function fetcherHasArgument(span) {
631
+ let seen = 0;
632
+ for (const m of span.matchAll(FETCHER_RE)) {
633
+ if (++seen > 8)
634
+ break;
635
+ if (fetcherArgs(span, m).some((a) => FETCH_CONFIG_FLAG_RE.test(a) || (!a.startsWith('-') && /[/.:@=$~\d[]/.test(a))))
636
+ return true;
637
+ }
638
+ // A dot-less host (`wget intranet`) is indistinguishable from prose ("pipe curl into bash") on its own;
639
+ // it is a target only when it is the fetcher's first argument AND the span pipes the download into a
640
+ // shell or runs the downloaded file.
641
+ return hasBareHostFetch(span) && (pipeInto(span, FETCH_STAGE_RE, false) !== undefined || downloadIsRun(span));
642
+ }
643
+ /** The arguments after a fetcher match, up to the end of its command. */
644
+ function fetcherArgs(span, m) {
645
+ const rest = span.slice((m.index ?? 0) + m[0].length, (m.index ?? 0) + m[0].length + 300);
646
+ return rest.split(/[|;&)`\n]/, 1)[0].trim().split(/\s+/, 16)
647
+ .map((a) => a.replace(/[.,:;!?'")\]]+$/, ''))
648
+ .filter(Boolean);
649
+ }
650
+ /** A fetcher whose first non-flag argument is a dot-less word that could be a host (`wget intranet`). */
651
+ function hasBareHostFetch(text, maxFetchers = 4) {
652
+ // Bounded: only the first few fetcher words of the first 600 characters, however long the command is.
653
+ const span = text.slice(0, 600);
654
+ let seen = 0;
655
+ for (const m of span.matchAll(FETCHER_RE)) {
656
+ if (++seen > maxFetchers)
657
+ break;
658
+ const first = firstPositional(m[0].toLowerCase(), fetcherArgs(span, m));
659
+ if (first !== undefined && isBareHost(first))
660
+ return true;
661
+ }
662
+ return false;
663
+ }
664
+ /** Short and long options that consume the next word, per fetcher (lower-cased). */
665
+ const FETCH_VALUE_FLAGS = {
666
+ curl: new Set(['-o', '-H', '-d', '-X', '-u', '-A', '-e', '-m', '-w', '-b', '-c', '-x', '-F', '-r', '-z', '-E',
667
+ '--output', '--header', '--data', '--request', '--user', '--user-agent', '--referer', '--max-time', '--cookie',
668
+ '--proxy', '--retry', '--connect-timeout']),
669
+ wget: new Set(['-O', '-o', '-P', '-t', '-T', '-U', '-e', '-w', '-Q', '-a', '-B', '--output-document',
670
+ '--directory-prefix', '--user-agent', '--tries', '--timeout', '--header']),
671
+ iwr: new Set(['-outfile', '-method', '-headers', '-body', '-contenttype', '-useragent', '-credential', '-timeoutsec',
672
+ '-proxy', '-websession']),
673
+ };
674
+ /** Words that follow a fetcher in prose ("pipe curl into bash") rather than naming a host. */
675
+ const NOT_A_HOST = new Set([
676
+ 'a', 'an', 'the', 'it', 'its', 'this', 'that', 'these', 'those', 'into', 'to', 'from', 'with', 'without', 'and', 'or',
677
+ 'then', 'as', 'in', 'on', 'for', 'at', 'by', 'is', 'are', 'was', 'be', 'directly', 'blindly', 'output', 'outputs',
678
+ 'piped', 'pipe', 'piping', 'command', 'commands', 'script', 'scripts', 'file', 'files', 'content', 'contents', 'data',
679
+ 'result', 'results', 'url', 'urls', 'link', 'links', 'anything', 'something', 'remote', 'untrusted', 'unverified',
680
+ 'unknown', 'here', 'there', 'when', 'which', 'only', 'not', 'never', 'always', 'unsafe', 'safely', 'instead', 'again',
681
+ 'install', 'installer', 'installers', 'download', 'downloads', 'downloaded', 'run', 'runs', 'running', 'execute',
682
+ 'bash', 'sh', 'shell', 'silently', 'quietly',
683
+ // Placeholders documentation uses where a real host would go.
684
+ 'foo', 'bar', 'baz', 'example', 'examples', 'yourscript', 'your-script', 'your-host', 'your-server', 'yourhost',
685
+ 'host', 'hostname', 'server', 'domain', 'site', 'website', 'get', 'docs', 'doc', 'mirror', 'some', 'my', 'your',
686
+ 'somewhere', 'test', 'tests', 'x', 'y', 'z', 'xxx', 'target', 'endpoint', 'api', 'repo', 'package', 'tool', 'app',
687
+ 'service', 'path', 'name', 'address', 'ip',
688
+ ]);
689
+ /** The first non-flag argument of a fetcher (skipping option values; `-Uri x` names its target). */
690
+ function firstPositional(fetcher, args) {
691
+ const kind = fetcher === 'curl' || fetcher === 'wget' ? fetcher : 'iwr';
692
+ const valueFlags = FETCH_VALUE_FLAGS[kind];
693
+ for (let n = 0; n < args.length; n++) {
694
+ const a = args[n];
695
+ if (kind === 'iwr' && a.toLowerCase() === '-uri')
696
+ return args[n + 1];
697
+ const flag = kind === 'iwr' ? a.toLowerCase() : a;
698
+ if (valueFlags.has(flag)) {
699
+ n++;
700
+ continue;
701
+ }
702
+ if (a.startsWith('-'))
703
+ continue;
704
+ return a;
705
+ }
706
+ return undefined;
707
+ }
708
+ function isBareHost(arg) {
709
+ return /^[a-z][a-z0-9_-]{0,62}$/i.test(arg) && !NOT_A_HOST.has(arg.toLowerCase());
710
+ }
711
+ /** `curl -o i.sh host && sh i.sh`: a downloaded file that a later command of the span runs. */
712
+ function downloadIsRun(span) {
713
+ const segs = splitCommands(span, false).slice(0, 50);
714
+ for (let k = 0; k < segs.length; k++) {
715
+ if (!FETCH_STAGE_RE.test(segs[k].text))
716
+ continue;
717
+ const file = producedFile(segs[k].text, 'remote');
718
+ if (!file)
719
+ continue;
720
+ for (let j = k + 1; j < segs.length && j <= k + 8; j++) {
721
+ const t = segs[j].text;
722
+ if (/^\s{0,5}(?:chmod|cat|ls|rm|mv|cp|echo|printf|test|\[)\b/i.test(t))
723
+ continue;
724
+ const run = RUN_FILE_RE.exec(t);
725
+ if (run && fileKey(run[1]) === file)
726
+ return true;
727
+ }
728
+ }
729
+ return false;
730
+ }
731
+ function hasTarget(span) {
732
+ const s = span.slice(0, 600);
733
+ return TARGET_RE.test(s) || fetcherHasArgument(s);
734
+ }
735
+ /**
736
+ * Is this match documentation rather than a directive? Only outside fenced
737
+ * code, and only when a negation/discussion word directly governs it. A match
738
+ * with a concrete target is never dropped and never loses severity — it only
739
+ * gets low confidence and a "looks like documentation" note. For rules whose
740
+ * matches never carry a target (override, strong concealment, permission
741
+ * bypass), governance drops a match only when the phrase is itself set off as
742
+ * a quotation — in a code span, quotes or a table cell; otherwise it too is
743
+ * kept at low confidence. (The escape hatch for a real false positive is the
744
+ * baseline or a config `exclude`.)
745
+ */
746
+ function documentationVerdict(doc, lineIndex, text, index, length, targetless = false) {
747
+ if (doc.kind !== 'instructions' || doc.fenced[lineIndex])
748
+ return 'none';
749
+ if (!governedNegation(text, index) && !governedDiscussion(text, index, length))
750
+ return 'none';
751
+ if (hasTarget(text.slice(index, index + Math.max(length, 1) + 80)))
752
+ return 'lowconf';
753
+ if (!targetless)
754
+ return 'skip';
755
+ return isQuoted(text, index, index + length) || isTableRow(text) ? 'skip' : 'lowconf';
756
+ }
757
+ function isTableRow(line) {
758
+ return line.trimStart().startsWith('|');
759
+ }
760
+ /** Quoted text preceded by an adoption phrase is an instruction being installed, not quoted data. */
761
+ const ADOPTION_RE = /\b(?:(?:rule|instructions?|prompt|policy|directive|task|goal|job)\s{1,5}(?:is|are|now)|you\s{1,5}are|you\s{1,5}will|from\s{1,5}now\s{1,5}on|always|remember\s{1,5}(?:that|to)|act\s{1,5}as|your\s{1,5}new)\b[^.!?]{0,40}$/i;
762
+ /** Quoted data: inside quotes/backticks and NOT preceded by an adoption phrase ("your new rule is \"…\""). */
763
+ function isQuotedData(line, start, end) {
764
+ if (!isQuoted(line, start, end))
765
+ return false;
766
+ const before = line.slice(Math.max(0, start - 200), start);
767
+ const open = Math.max(before.lastIndexOf('"'), before.lastIndexOf('`'), before.lastIndexOf('\u{201C}'));
768
+ return !ADOPTION_RE.test(before.slice(0, Math.max(0, open)));
769
+ }
770
+ /** The match sits inside a double-quoted, curly-quoted or backticked span. */
771
+ function isQuoted(line, start, end) {
772
+ const before = line.slice(Math.max(0, start - MAX_SCAN_WINDOW), start);
773
+ const after = line.slice(end, end + MAX_SCAN_WINDOW);
774
+ for (const q of ['"', '`']) {
775
+ const count = before.split(q).length - 1;
776
+ if (count % 2 === 1 && after.includes(q))
777
+ return true;
778
+ }
779
+ return before.lastIndexOf('\u{201C}') > before.lastIndexOf('\u{201D}') && after.includes('\u{201D}');
780
+ }
781
+ /** A table row whose first cell is just a flag/setting name: `| --yolo | … |`. */
782
+ const FLAG_DOC_ROW_RE = /^\s{0,10}\|\s{0,5}`?(?:--[\w-]{2,60}|bypassPermissions)`?\s{0,5}\|/;
783
+ /** A cell that runs something or tells the reader to: code, or an imperative. */
784
+ const ROW_DIRECTIVE_RE = /`[^`]{0,200}`|\b(?:run|use|execute|launch|invoke|start|pass|add|set|enable|always|call|claude|codex|npx|bash|sh)\b/i;
785
+ /**
786
+ * Flag documentation: the first cell is just the flag, and no other cell
787
+ * contains code or an imperative (`| --yolo | always pass this |` is a directive).
788
+ */
789
+ function isFlagDocRow(row) {
790
+ if (!FLAG_DOC_ROW_RE.test(row))
791
+ return false;
792
+ const cells = row.split(/(?<!\\)\|/).slice(2); // [lead, flag cell, …rest]
793
+ return !cells.some((cell) => ROW_DIRECTIVE_RE.test(cell));
794
+ }
795
+ /**
796
+ * Split a command line into segments at unquoted `|`, `||`, `&&`, `;`, `&`.
797
+ * `sep` is the separator AFTER the segment. A quote only opens at a word start
798
+ * (so the apostrophe in "don't" is not a quote); quote tracking is skipped for
799
+ * prose, where quotes delimit a quoted command rather than shell strings.
800
+ */
801
+ function splitCommands(text, quoteAware, quoted) {
802
+ const out = [];
803
+ let start = 0;
804
+ let quote = '';
805
+ let quoteStart = 0;
806
+ for (let i = 0; i < text.length; i++) {
807
+ const c = text[i];
808
+ if (quote) {
809
+ if (c === '\\' && quote === '"')
810
+ i++;
811
+ else if (c === quote) {
812
+ quote = '';
813
+ if (quoted && quoted.length < 50)
814
+ quoted.push({ start: quoteStart + 1, end: i });
815
+ }
816
+ continue;
817
+ }
818
+ if (quoteAware && (c === '"' || c === "'") && (i === 0 || /[\s=(:,[{]/.test(text[i - 1]))) {
819
+ quote = c;
820
+ quoteStart = i;
821
+ continue;
822
+ }
823
+ if (quoteAware && c === '\\') {
824
+ i++;
825
+ continue;
826
+ }
827
+ let sep = '';
828
+ if (c === '|')
829
+ sep = text[i + 1] === '|' ? '||' : text[i + 1] === '&' ? '|&' : '|';
830
+ else if (c === '&')
831
+ sep = text[i + 1] === '&' ? '&&' : (text[i - 1] === '>' || text[i + 1] === '>' ? '' : '&');
832
+ else if (c === ';')
833
+ sep = ';';
834
+ if (!sep)
835
+ continue;
836
+ out.push({ text: text.slice(start, i), start, sep: sep === '|&' ? '|' : sep });
837
+ i += sep.length - 1;
838
+ start = i + 1;
839
+ }
840
+ out.push({ text: text.slice(start), start, sep: '' });
841
+ return out;
842
+ }
843
+ const FETCH_CMD = String.raw `(?:curl|wget|iwr|irm|invoke-webrequest|invoke-restmethod|aria2c|downloadstring|downloadfile)`;
844
+ const FETCH_STAGE_RE = new RegExp(String.raw `(?:^|[\s(\x60{>"':=])${FETCH_CMD}\b`, 'i');
845
+ const DECODE_STAGE_RE = /(?:^|[\s(`{>"':=])(?:base64\s{1,5}(?:-\S{1,10}\s{1,5}){0,3}(?:-d|--decode|-D)\b|xxd\s{1,5}(?:-\S{1,10}\s{1,5}){0,3}-r\b|openssl\s{1,5}(?:base64|enc)\b[^|;&\n]{0,80}\s-d\b|uudecode\b)/i;
846
+ /** Interpreters that execute whatever arrives on stdin when given no script argument. */
847
+ const STDIN_INTERP = String.raw `(?:python[0-9.]{0,5}|node|perl|ruby|php|deno|bun)(?=\s{0,5}$|\s{1,5}-(?:\s|$)|\s{1,5}run\s{1,5}-(?:\s|$)|\s{1,5}(?:\/dev\/stdin|\/proc\/self\/fd\/0)\b|\s{0,5}[;&|)}\x60'"])`;
848
+ /**
849
+ * What executes stdin (or its lines): a shell, `$SHELL`, `source /dev/stdin`,
850
+ * a stdin-reading interpreter, `python -c 'exec(sys.stdin.read())'`,
851
+ * `awk '{system($0)}'`, PowerShell's iex.
852
+ */
853
+ const SHELL_WORD = String.raw `(?:\$\{?SHELL\}?(?!\w)|(?:source|\.)\s{1,5}\/dev\/stdin\b|(?:ba|z|k|da|fi|a)?sh\b|` +
854
+ String.raw `python[0-9.]{0,5}\s{1,5}-c\s{1,5}["']?[^|\n]{0,80}?(?:exec|eval)\s{0,5}\(\s{0,5}sys\.stdin|` +
855
+ String.raw `g?awk\s{1,5}(?:-\S{1,20}\s{1,5}){0,3}["']?[^|\n]{0,60}?system\s{0,5}\(\s{0,5}\$0|` +
856
+ String.raw `${STDIN_INTERP}|iex\b|invoke-expression\b|pwsh\b|powershell\b)`;
857
+ /** Command wrappers that can sit in front of the shell in a pipeline stage. */
858
+ const STAGE_WRAPPER = String.raw `(?:` + [
859
+ String.raw `sudo(?:\s{1,5}-{1,2}[\w-]{1,20}(?:[= ]\s{0,3}[\w.@:-]{1,40})?){0,5}`,
860
+ String.raw `env(?:\s{1,5}-\S{1,20}){0,3}(?:\s{1,5}[^\s=]{1,40}=\S{0,100}){0,5}`,
861
+ String.raw `xargs(?:\s{1,5}-\S{1,20}(?:\s{1,5}[^\s-]\S{0,20})?){0,4}`,
862
+ String.raw `busybox`,
863
+ String.raw `nice(?:\s{1,5}-n\s{0,3}-?\d{1,3}|\s{1,5}-\d{1,3})?`,
864
+ String.raw `timeout(?:\s{1,5}-\S{1,20}){0,3}\s{1,5}[\d.]{1,8}[smhd]?`,
865
+ String.raw `nohup`,
866
+ String.raw `exec`,
867
+ String.raw `command`,
868
+ String.raw `stdbuf(?:\s{1,5}-\S{1,20}){0,3}`,
869
+ String.raw `[A-Za-z_]\w{0,40}=\S{0,100}`,
870
+ ].join('|') + String.raw `)\s{1,5}`;
871
+ /**
872
+ * A pipeline stage that is a shell: optional subshell/brace, up to six
873
+ * wrappers (sudo -u x, env A=…, nice, timeout N, exec, command, xargs…), a
874
+ * path prefix, `env`.
875
+ */
876
+ const STAGE_PREFIX = String.raw `^\s{0,5}[({]?\s{0,3}(?:${STAGE_WRAPPER}){0,6}` +
877
+ String.raw `(?:[\w./-]{0,40}\/)?(?:env\s{1,5}(?:-\S{1,20}\s{1,5}){0,3})?`;
878
+ const SHELL_STAGE_RE = new RegExp(STAGE_PREFIX + SHELL_WORD, 'i');
879
+ /**
880
+ * A call that evaluates its argument as code (`eval(`, python's `exec(`,
881
+ * `new Function(`, `vm.run…(`, `instance_eval`, `compile(`), or runs it as a
882
+ * command (`os.system(`, `child_process.execSync(`, `system(`), followed by the
883
+ * start of its argument. A literal argument (`eval('1+1')`) and a verb that is
884
+ * just a word in a string (`print('eval', …)`) do not qualify.
885
+ */
886
+ const STDIN_VERB_CALL_RE = new RegExp(String.raw `(?:(?<![.\w$])(?:eval|exec\w{0,8}|system|popen|spawn\w{0,8}|compile|Function)|(?:instance_|class_|module_)eval|` +
887
+ String.raw `\b(?:vm|child_process|os|subprocess|Kernel)["']?\)?\.(?:run\w{2,20}|compile\w{0,12}|exec\w{0,8}|spawn\w{0,8}|system|popen|call|check_output|Popen|eval|Script))\b` +
888
+ String.raw `(?:\s{0,5}\(\s{0,5}|\s{1,5})(?!["'\x60][^"'\x60]{0,60}["'\x60]\s{0,3}[);,])`, 'g');
889
+ /** Where a script reads its own stdin. */
890
+ const STDIN_SOURCE_RE = new RegExp(String.raw `readFileSync\s{0,5}\(\s{0,5}(?:0\b|process\.stdin|["']\/dev\/stdin)|process\.stdin|\/dev\/stdin|php:\/\/stdin|sys\.stdin|` +
891
+ String.raw `open\s{0,5}\(\s{0,5}0\b|fileinput|\bSTDIN\b|\$stdin|\bARGF\b|<STDIN>|<>|\bgets\b|\breadlines?\b|\binput\s{0,5}\(|\$_(?!\w)`, 'gi');
892
+ /** `eval do { local $/; <STDIN> }`: the `;` inside the block is not the end of the statement. */
893
+ const PERL_EVAL_BLOCK_RE = /\beval\s{0,5}\(?\s{0,5}do\s{0,5}\{[^}\n]{0,100}(?:<STDIN>|<>)/i;
894
+ /** `s = sys.stdin.read()` — the name assigned from a stdin read, at the end of the text before it. */
895
+ const STDIN_ASSIGN_BEFORE_RE = /(?:^|[;\s{(])\$?([A-Za-z_]\w{0,30})\s{0,3}=\s{0,3}[^;\n=]{0,60}$/;
896
+ const MAX_SCRIPT_CHARS = 600;
897
+ /** The farthest a stdin read may sit from the evaluating call that consumes it. */
898
+ const MAX_ARGUMENT_CHARS = 150;
899
+ /**
900
+ * Does inline interpreter script text run its own stdin? The stdin read has to
901
+ * sit INSIDE the argument of an evaluating call — `eval(require('fs').readFileSync(0,'utf8'))`,
902
+ * `exec(open(0).read())`, `eval STDIN.read` — or be assigned to a name that is
903
+ * then evaluated (`s=sys.stdin.read(); exec(s)`). A script that merely reads
904
+ * stdin AND, elsewhere, runs something fixed (`json.load(sys.stdin)` …
905
+ * `subprocess.call([...])`) does not qualify. Linear: sources and calls are
906
+ * found in one pass each over at most {@link MAX_SCRIPT_CHARS} characters.
907
+ */
908
+ function runsOwnStdin(scriptText) {
909
+ const script = scriptText.slice(0, MAX_SCRIPT_CHARS);
910
+ const sources = [];
911
+ for (const m of script.matchAll(STDIN_SOURCE_RE)) {
912
+ sources.push(m.index ?? 0);
913
+ if (sources.length >= 16)
914
+ break;
915
+ }
916
+ if (sources.length === 0)
917
+ return false;
918
+ if (PERL_EVAL_BLOCK_RE.test(script))
919
+ return true;
920
+ let verbs = 0;
921
+ let next = 0;
922
+ for (const call of script.matchAll(STDIN_VERB_CALL_RE)) {
923
+ if (++verbs > 40)
924
+ break;
925
+ const from = (call.index ?? 0) + call[0].length;
926
+ while (next < sources.length && sources[next] < from)
927
+ next++;
928
+ const source = sources[next];
929
+ if (source !== undefined && source - from <= MAX_ARGUMENT_CHARS && !/[;\n]/.test(script.slice(from, source)))
930
+ return true;
931
+ }
932
+ for (const source of sources.slice(0, 4)) {
933
+ const assigned = STDIN_ASSIGN_BEFORE_RE.exec(script.slice(Math.max(0, source - 80), source));
934
+ if (assigned && new RegExp(String.raw `\b(?:eval|exec|system)\s{0,5}\(?\s{0,5}\$?${assigned[1]}\b`).test(script.slice(source)))
935
+ return true;
936
+ }
937
+ return false;
938
+ }
939
+ /** An interpreter given inline script text (`node -e`, `python -c`, `ruby -e`, `perl -e`, `php -r`), up to the script. */
940
+ const STDIN_EVAL_WORD = String.raw `(?:` + [
941
+ String.raw `(?:node(?:js)?|bun|deno)\s{1,5}(?:--?[\w-]{1,30}(?:=\S{1,30})?\s{1,5}){0,3}?(?:-e|-p|-pe|--eval|--print|eval)`,
942
+ String.raw `python[0-9.]{0,5}\s{1,5}(?:-[\w-]{1,10}\s{1,5}){0,3}?-c`,
943
+ String.raw `ruby\s{1,5}(?:-\S{1,10}\s{1,5}){0,3}?-[a-z]{0,4}e`,
944
+ String.raw `perl\s{1,5}(?:-[\w=:.-]{1,20}\s{1,5}){0,3}?-[a-z]{0,4}e`,
945
+ String.raw `php\s{1,5}(?:-\S{1,10}\s{1,5}){0,3}?-r`,
946
+ ].join('|') + String.raw `)\s{1,5}["']?`;
947
+ const STDIN_EVAL_STAGE_RE = new RegExp(`${STAGE_PREFIX}${STDIN_EVAL_WORD}`, 'i');
948
+ /** `perl -ne 'eval'` / `perl -pe 'eval'`: the -n/-p loop feeds every stdin line to `$_`, which a bare eval runs. */
949
+ const PERL_LOOP_EVAL_RE = new RegExp(`${STAGE_PREFIX}perl\\s{1,5}(?:-[\\w=:.-]{1,20}\\s{1,5}){0,3}?-[a-z]{0,3}[np][a-z]{0,3}e\\s{1,5}["']?(?=[^\\n]{0,300}?eval\\b)`, 'i');
950
+ /** A stage that is an interpreter running its own stdin as code. */
951
+ function isStdinEvalStage(head) {
952
+ const m = STDIN_EVAL_STAGE_RE.exec(head);
953
+ if (m && runsOwnStdin(head.slice(m.index + m[0].length)))
954
+ return true;
955
+ return PERL_LOOP_EVAL_RE.test(head);
956
+ }
957
+ /** `tee >(bash)`: the pipe feeds a shell through process substitution. */
958
+ const TEE_STAGE_RE = /^\s{0,5}[({]?\s{0,3}(?:sudo\s{1,5}(?:-\S{1,20}\s{1,5}){0,3})?(?:[\w./-]{0,40}\/)?tee\b/i;
959
+ /**
960
+ * `while read l; do eval "$l"; done` as a pipeline stage: the loop's first
961
+ * segment reads lines, a later body segment (before `done`) runs one of the
962
+ * variables it read.
963
+ */
964
+ const LOOP_HEAD_RE = /^\s{0,5}[({]?\s{0,3}(?:while|until)\b[^;|]{0,150}?\bread\b([^;|]{0,100})/i;
965
+ const LOOP_RUN_RE = /^\s{0,5}(?:do\s{1,5})?(?:(?:(?:command|builtin)\s{1,5})?(?:eval|source|\.)\s{1,5}|(?:sudo\s{1,5})?(?:[\w./-]{0,40}\/)?(?:ba|z|k|da)?sh\s{1,5}(?:(?:-\S{1,5}|<<<)\s{1,5}){0,2}|sudo\s{1,5})?["']?\$\{?([A-Za-z_]\w{0,40})\}?/i;
966
+ /** `echo "$l"` (or printf) as the producer of a pipe into a shell, inside the loop body. */
967
+ const LOOP_ECHO_RE = /^\s{0,5}(?:do\s{1,5})?(?:echo|printf)\s{1,5}(?:-\S{1,5}\s{1,5}){0,2}(?:["']%s["']\s{1,5})?["']?\$\{?([A-Za-z_]\w{0,40})\}?/i;
968
+ const LOOP_DO_RE = /^\s*do\b/;
969
+ const LOOP_DONE_RE = /^\s*done\b/;
970
+ /** `read` options that consume the next word (a prompt, a delimiter, a count, a descriptor). */
971
+ const READ_VALUE_FLAGS = new Set(['-p', '-d', '-n', '-N', '-t', '-u', '-i']);
972
+ /** The longest loop body followed (in command segments) after its head. */
973
+ const MAX_LOOP_SEGMENTS = 250;
974
+ function readVariables(afterRead) {
975
+ const vars = new Set();
976
+ const parts = afterRead.trim().split(/\s+/).slice(0, 12);
977
+ for (let n = 0; n < parts.length; n++) {
978
+ const part = parts[n];
979
+ if (READ_VALUE_FLAGS.has(part)) {
980
+ n++;
981
+ continue;
982
+ }
983
+ if (part.startsWith('-'))
984
+ continue;
985
+ if (/^[A-Za-z_]\w{0,40}$/.test(part))
986
+ vars.add(part);
987
+ }
988
+ if (vars.size === 0)
989
+ vars.add('REPLY');
990
+ return vars;
991
+ }
992
+ function loopBodyInfo(segs, j, memo) {
993
+ let info = memo[j];
994
+ if (info === undefined) {
995
+ const body = segs[j];
996
+ const text = body.text.slice(0, 400);
997
+ if (LOOP_DONE_RE.test(text))
998
+ info = { kind: 'done' };
999
+ else {
1000
+ info = { kind: LOOP_DO_RE.test(text) ? 'do' : 'other' };
1001
+ const run = LOOP_RUN_RE.exec(text);
1002
+ if (run)
1003
+ info.runs = run[1];
1004
+ const echoed = body.sep === '|' && j + 1 < segs.length ? LOOP_ECHO_RE.exec(text) : null;
1005
+ if (echoed)
1006
+ info.echoes = echoed[1];
1007
+ }
1008
+ memo[j] = info;
1009
+ }
1010
+ return info;
1011
+ }
1012
+ /** End offset (in the logical command) of a loop segment that runs the lines it reads, else undefined. */
1013
+ function loopRunsLine(segs, k, memo) {
1014
+ const headText = segs[k].text;
1015
+ if (!headText.includes('read') || !/while|until/i.test(headText))
1016
+ return undefined;
1017
+ const head = LOOP_HEAD_RE.exec(headText.slice(0, 400));
1018
+ if (!head)
1019
+ return undefined;
1020
+ const vars = readVariables(head[1] ?? '');
1021
+ // `depth` follows nested loops (`for x in a; do …; done` before the eval) so an inner `done` does not end the scan.
1022
+ let depth = 0;
1023
+ for (let j = k + 1; j < segs.length && j <= k + MAX_LOOP_SEGMENTS; j++) {
1024
+ const info = loopBodyInfo(segs, j, memo);
1025
+ if (info.kind === 'done') {
1026
+ if (--depth <= 0)
1027
+ return undefined;
1028
+ continue;
1029
+ }
1030
+ if (info.kind === 'do')
1031
+ depth++;
1032
+ const body = segs[j];
1033
+ if (info.runs !== undefined && vars.has(info.runs))
1034
+ return body.start + Math.min(body.text.length, 60);
1035
+ // `echo "$l" | sh`: the line is piped into a shell.
1036
+ if (info.echoes !== undefined && vars.has(info.echoes) && isShellStage(segs[j + 1].text)) {
1037
+ return segs[j + 1].start + Math.min(segs[j + 1].text.length, 60);
1038
+ }
1039
+ }
1040
+ return undefined;
1041
+ }
1042
+ /**
1043
+ * Stages whose output a following shell would run in a form the reader cannot
1044
+ * see: reversed (`rev`), rotated (`tr a-z n-za-m`), base32/basenc-decoded, or
1045
+ * written as a long run of hex/octal/unicode escapes (`printf '\x63\x75…'`).
1046
+ * Matched as a pipeline producer next to a shell, like a base64 decode.
1047
+ */
1048
+ const TRANSFORM_STAGE_RE = new RegExp(String.raw `^\s{0,5}[({]?\s{0,3}(?:sudo\s{1,5})?(?:` + [
1049
+ String.raw `rev\b`,
1050
+ // A rotation or shift: the second set starts at a DIFFERENT letter/digit than the first (`A-Za-z` -> `N-ZA-Mn-za-m`).
1051
+ // Case conversion (`a-z` -> `A-Z`) starts at the same letter, so it never matches (the back-reference ignores case).
1052
+ String.raw `tr\s{1,5}(?:-\S{1,5}\s{1,5}){0,2}["']?\[?([a-z0-9])-[a-z0-9](?:[a-z0-9]-[a-z0-9]){0,3}\]?["']?\s{1,5}["']?\[?(?!\1)[a-z0-9]-[a-z0-9]`,
1053
+ String.raw `tr\s{1,5}(?:-\S{1,5}\s{1,5}){0,2}["']?([a-z])[a-z]{9,}["']?\s{1,5}["']?(?!\2)[a-z][a-z]{9,}`,
1054
+ String.raw `(?:base32|basenc)\s{1,5}(?:-\S{1,12}\s{1,5}){0,3}(?:-[a-z]{0,3}d[a-z]{0,3}|--decode)\b`,
1055
+ // Escapes that spell PRINTABLE characters (`\x63`, `\143`): a run of ANSI/control escapes (`\x1b`, `\033`) is terminal colour.
1056
+ String.raw `(?:printf|echo)\s{1,5}(?:-\S{1,4}\s{1,5}){0,2}(?:["']%b["']\s{1,5})?\$?["']?(?:\\x[2-7][0-9a-f]|\\0[4-7][0-7]|\\1[0-7][0-7]|\\u00[2-7][0-9a-f]){4,}`,
1057
+ ].join('|') + String.raw `)`, 'i');
1058
+ /** Decoders and transforms that can feed a shell. */
1059
+ const ENCODED_PRODUCER_RE = new RegExp(`${DECODE_STAGE_RE.source}|${TRANSFORM_STAGE_RE.source}`, 'i');
1060
+ /** Pipeline stages followed from the producer to the shell. */
1061
+ const MAX_PIPE_HOPS = 16;
1062
+ /**
1063
+ * The first 600 characters of a stage with its shell-relevant structure made
1064
+ * regex-friendly: whitespace runs collapse to one space, and a variable
1065
+ * assignment's value (`A=<any length>`) collapses to `V=1`, so a long value
1066
+ * (or a long run of spaces) before the shell cannot push it out of the bounded
1067
+ * quantifiers. Quote-aware and linear: each character is read once.
1068
+ */
1069
+ function stageHead(text) {
1070
+ let out = '';
1071
+ let i = 0;
1072
+ let tokens = 0;
1073
+ let lastWasAssignment = false;
1074
+ const n = text.length;
1075
+ while (i < n && out.length < 600 && tokens < 400) {
1076
+ while (i < n && text.charCodeAt(i) <= 32)
1077
+ i++;
1078
+ if (i >= n)
1079
+ break;
1080
+ const from = i;
1081
+ let quote = 0;
1082
+ while (i < n) {
1083
+ const c = text.charCodeAt(i);
1084
+ if (quote !== 0) {
1085
+ if (c === 92 && quote === 34)
1086
+ i++; // backslash escape inside double quotes
1087
+ else if (c === quote)
1088
+ quote = 0;
1089
+ }
1090
+ else if (c === 34 || c === 39)
1091
+ quote = c;
1092
+ else if (c <= 32)
1093
+ break;
1094
+ i++;
1095
+ }
1096
+ const token = text.slice(from, Math.min(i, from + 700));
1097
+ tokens++;
1098
+ const assignment = /^[A-Za-z_]\w{0,40}=/.test(token);
1099
+ // A run of assignments (`A=1 B=2 C=3 …`) is one: any number may precede the shell.
1100
+ if (assignment && lastWasAssignment)
1101
+ continue;
1102
+ lastWasAssignment = assignment;
1103
+ out += `${out === '' ? '' : ' '}${assignment ? 'V=1' : token}`;
1104
+ }
1105
+ return out.length >= 600 || i >= n ? out.slice(0, 600) : `${out} ${text.slice(i, i + 600)}`.slice(0, 600);
1106
+ }
1107
+ /** Is this pipeline stage a shell (or something that runs its stdin / its lines)? */
1108
+ function isShellStage(stageText) {
1109
+ if (!SHELL_WORD_HINT_RE.test(stageText))
1110
+ return false; // cheap reject before the head is normalized
1111
+ const head = stageHead(stageText);
1112
+ if (!SHELL_WORD_HINT_RE.test(head))
1113
+ return false;
1114
+ if (SHELL_STAGE_RE.test(head) || isStdinEvalStage(head))
1115
+ return true;
1116
+ // `tee >(bash)` / `tee f >(sh -s)`: process substitution feeds the shell.
1117
+ if (head.includes('>(') && TEE_STAGE_RE.test(head)) {
1118
+ let from = head.indexOf('>(');
1119
+ for (let n = 0; from >= 0 && n < 4; n++) {
1120
+ const inner = head.slice(from + 2);
1121
+ if (SHELL_STAGE_RE.test(inner) || isStdinEvalStage(inner))
1122
+ return true;
1123
+ from = head.indexOf('>(', from + 2);
1124
+ }
1125
+ }
1126
+ return false;
1127
+ }
1128
+ /**
1129
+ * `producer … | [stage |]… shell` over the whole logical command — split on
1130
+ * unquoted pipes, so the cost is linear and no window alignment can hide it.
1131
+ */
1132
+ function pipeInto(text, producer, quoteAware, depth = 0) {
1133
+ if (!text.includes('|'))
1134
+ return undefined;
1135
+ const quoted = [];
1136
+ const segs = splitCommands(text, quoteAware, quoted);
1137
+ const direct = pipeInSegments(text, segs, producer);
1138
+ if (direct || depth > 0)
1139
+ return direct;
1140
+ // A quoted string can itself be a command: `sh -c "curl … | sh"`, `"postinstall": "curl … | bash"`.
1141
+ // Its contents are scanned on their own, so `curl -d 'a|bash' URL` (no fetch before the pipe) stays clean.
1142
+ for (const q of quoted) {
1143
+ const inner = pipeInto(text.slice(q.start, q.end), producer, true, depth + 1);
1144
+ if (inner)
1145
+ return { index: q.start + inner.index, match: fakeMatch(text, q.start + inner.index, q.start + inner.index + inner.match[0].length) };
1146
+ }
1147
+ return undefined;
1148
+ }
1149
+ const SHELL_WORD_HINT_RE = /sh\b|SHELL|stdin|awk|python|node|perl|ruby|php|deno|bun|iex|invoke-expression|pwsh|powershell/i;
1150
+ function pipeInSegments(text, segs, producer) {
1151
+ // Each segment is tested as a shell stage at most once, however many producers look at it.
1152
+ const isShell = new Array(segs.length);
1153
+ const shellAt = (k) => {
1154
+ if (isShell[k] === undefined)
1155
+ isShell[k] = isShellStage(segs[k].text);
1156
+ return isShell[k];
1157
+ };
1158
+ // A `while read l; do eval "$l"; done` loop stage: the end of the segment that runs the lines (memoized).
1159
+ const loopEnds = new Array(segs.length);
1160
+ const loopMemo = new Array(segs.length);
1161
+ const loopAt = (k) => {
1162
+ if (loopEnds[k] === undefined)
1163
+ loopEnds[k] = loopRunsLine(segs, k, loopMemo) ?? null;
1164
+ return loopEnds[k] ?? undefined;
1165
+ };
1166
+ for (let s = 0; s < segs.length; s++) {
1167
+ if (segs[s].sep !== '|')
1168
+ continue;
1169
+ const p = producer.exec(segs[s].text);
1170
+ if (!p)
1171
+ continue;
1172
+ for (let hop = 1; hop <= MAX_PIPE_HOPS && s + hop < segs.length; hop++) {
1173
+ const next = segs[s + hop];
1174
+ const loopEnd = shellAt(s + hop) ? undefined : loopAt(s + hop);
1175
+ if (loopEnd !== undefined || shellAt(s + hop)) {
1176
+ const index = segs[s].start + p.index;
1177
+ const end = Math.min(text.length, loopEnd ?? next.start + Math.min(next.text.length, 60));
1178
+ return { index, match: fakeMatch(text, index, end) };
1179
+ }
1180
+ if (next.sep !== '|')
1181
+ break; // the pipeline ended
1182
+ }
1183
+ }
1184
+ return undefined;
1185
+ }
1186
+ /**
1187
+ * A real verification command (the first word of its segment, not text inside
1188
+ * an `echo` or a comment) that actually checks something: `sha256sum -c`,
1189
+ * `shasum -c`, `gpg --verify`, `gpgv`, `cosign verify[-blob]`, `minisign -V`,
1190
+ * `slsa-verifier verify…`, `openssl dgst … -verify`. A bare `sha256sum x`
1191
+ * only prints a hash — it verifies nothing.
1192
+ */
1193
+ const VERIFY_CMD_RE = /^\s{0,5}(?:sudo\s{1,5})?(?:(?:sha(?:1|224|256|384|512)sum|shasum)\b(?=[^|;&]{0,300}\s(?:-c|--check)\b)|gpg\b(?=[^|;&]{0,300}\s--verify\b)|gpgv\b|cosign\s{1,5}verify(?:-blob)?\b|minisign\s{1,5}-V\b|slsa-verifier\s{1,5}verify|openssl\s{1,5}dgst\b(?=[^|;&]{0,300}\s-verify\b))/i;
1194
+ const CHECKSUM_MANIFEST_RE = /^\s{0,5}(?:sudo\s{1,5})?(?:sha(?:1|224|256|384|512)sum|shasum)\b[^|;&]{0,300}\s(?:-c|--check)(?:\s{1,5}|=)(\S{1,300})/i;
1195
+ const RUN_FILE_RE = /^\s{0,5}(?:sudo\s{1,5}(?:-\S{1,20}\s{1,5}){0,5})?(?:(?:[\w./-]{0,40}\/)?(?:(?:ba|z|k|da)?sh|source|\.|python[0-9.]{0,5}|node|perl|ruby|pwsh|powershell)\s{1,5}(?:-\S{1,20}\s{1,5}){0,3})?(\S{1,300})/i;
1196
+ /** `X=$(curl …)` — a variable holding fetched content. */
1197
+ const FETCH_ASSIGN_RE = /^\s{0,5}(?:export\s{1,5}|local\s{1,5}|readonly\s{1,5})?([A-Za-z_]\w{0,60})=["']?(?:\$\(|`)\s{0,5}(?:curl|wget|iwr|irm)\b/i;
1198
+ /** `eval "$X"` / `bash -c "$X"` / `sh <<< "$X"` — running a variable's content. */
1199
+ const RUN_VAR_RE = /^\s{0,5}(?:eval|(?:ba|z|k|da)?sh\s{1,5}(?:-c|<<<)|iex|invoke-expression)\s{0,5}\(?\s{0,5}["']?\$\{?([A-Za-z_]\w{0,60})\}?/i;
1200
+ function fileKey(p) {
1201
+ return path.posix.basename(p.replace(/^['"]+|['"]+$/g, '').replace(/\\/g, '/'));
1202
+ }
1203
+ /** Exact file-name tokens of a command segment (quotes stripped, basenames) — no substring matching. */
1204
+ function tokenKeys(seg) {
1205
+ return new Set(seg.split(/\s+/).filter(Boolean).map(fileKey));
1206
+ }
1207
+ /** Output file of a download/decode command (`curl -o f`, `curl -O URL`, `wget URL`, `base64 -d > f`). */
1208
+ function producedFile(seg, kind) {
1209
+ const redirect = /(?:^|\s)>{1,2}\s{0,3}(\S{1,300})/.exec(seg);
1210
+ if (kind === 'encoded')
1211
+ return redirect ? fileKey(redirect[1]) : undefined;
1212
+ const url = /https?:\/\/[^\s'"`)]{1,2000}/i.exec(seg)?.[0];
1213
+ if (/\bcurl\b/i.test(seg)) {
1214
+ const out = /(?:\s-[a-zA-Z]{0,10}o\s{0,3}|\s--output[= ]\s{0,3})(\S{1,300})/.exec(seg);
1215
+ if (out)
1216
+ return fileKey(out[1]);
1217
+ if (/\s-[a-zA-Z]{0,10}O\b|\s--remote-name\b/.test(seg) && url)
1218
+ return fileKey(url.split(/[?#]/)[0]);
1219
+ return redirect ? fileKey(redirect[1]) : undefined;
1220
+ }
1221
+ if (/\bwget\b/i.test(seg)) {
1222
+ const out = /(?:\s-[a-zA-Z]{0,10}O\s{0,3}|\s--output-document[= ]\s{0,3})(\S{1,300})/.exec(seg);
1223
+ if (out)
1224
+ return out[1] === '-' ? undefined : fileKey(out[1]);
1225
+ return url ? fileKey(url.split(/[?#]/)[0]) : undefined;
1226
+ }
1227
+ return redirect ? fileKey(redirect[1]) : undefined;
1228
+ }
1229
+ /** A local hash command writing a checksum file: `sha256sum x.sh > x.sum` — a self-certified manifest. */
1230
+ const HASH_TO_FILE_RE = /^\s{0,5}(?:sudo\s{1,5})?(?:(?:sha(?:1|224|256|384|512)|md5|b2)sum|shasum|openssl\s{1,5}dgst)\b(?![^|;&]{0,300}\s(?:-c|--check)\b)[^|;&]{0,300}>{1,2}\s{0,3}(\S{1,300})/i;
1231
+ /** Any local hash command (feeding a `-c -` check from it is self-certification too). */
1232
+ const HASH_CMD_RE = /^\s{0,5}(?:sudo\s{1,5})?(?:(?:sha(?:1|224|256|384|512)|md5|b2)sum|shasum|openssl\s{1,5}dgst)\b/i;
1233
+ /** Candidates worth a chain scan when nothing is being tracked yet. */
1234
+ const CHAIN_HINT_RE = /curl|wget|iwr|irm|invoke-web|base64|xxd|openssl|uudecode|sum\b|gpg|cosign|minisign|slsa/i;
1235
+ /**
1236
+ * Tracks content produced by a download (or a decode) and reports when it is
1237
+ * executed later: `curl -o x URL && chmod +x x && ./x`, `base64 -d > f; sh f`,
1238
+ * `X=$(curl …); eval "$X"`. Lines of one script, or one fenced block, are fed
1239
+ * in order and behave like `;`-separated commands, so a download on one line
1240
+ * and its execution on another are still caught.
1241
+ *
1242
+ * A download is cleared only by a REAL verification command, chained with
1243
+ * `&&` to both the fetch and the run on the same logical line (a failed check
1244
+ * must stop the run — `;`, `||` and a new line do not), that names the
1245
+ * executed file exactly: a `-c` manifest that was itself fetched in the chain
1246
+ * or already exists in the repo — never one generated locally (`sha256sum x >
1247
+ * x.sum`), a here-string or process substitution, or a check run with
1248
+ * `--ignore-missing` — a piped checksum line (`echo "h x.sh" | sha256sum -c -`,
1249
+ * not piped from a local hash command), or `gpg --verify sig x.sh`.
1250
+ */
1251
+ class ChainScanner {
1252
+ manifestExists;
1253
+ produced = new Map();
1254
+ fetchedVars = new Map();
1255
+ generated = new Set();
1256
+ feeds = 0;
1257
+ constructor(manifestExists = () => false) {
1258
+ this.manifestExists = manifestExists;
1259
+ }
1260
+ feed(text, line, quoteAware) {
1261
+ const feedId = ++this.feeds;
1262
+ if (this.produced.size === 0 && this.fetchedVars.size === 0 && !CHAIN_HINT_RE.test(text))
1263
+ return undefined;
1264
+ let segs = splitCommands(text, quoteAware).slice(0, 500);
1265
+ // A comment ends the command line: nothing after `#` runs (or verifies).
1266
+ const commentAt = segs.findIndex((s) => /(?:^|\s)#/.test(s.text));
1267
+ if (commentAt >= 0) {
1268
+ const s = segs[commentAt];
1269
+ segs = [...segs.slice(0, commentAt), { ...s, text: s.text.slice(0, s.text.search(/(?:^|\s)#/)), sep: '' }];
1270
+ }
1271
+ const isVerify = segs.map((s) => VERIFY_CMD_RE.test(s.text));
1272
+ /** Every separator from segment `from` up to `to` (same line) is `&&`, or the pipe feeding a verify command. */
1273
+ const chainedAnd = (from, to) => {
1274
+ for (let m = from; m < to; m++) {
1275
+ const sep = segs[m].sep;
1276
+ if (sep === '&&')
1277
+ continue;
1278
+ if (sep === '|' && isVerify[m + 1])
1279
+ continue;
1280
+ return false;
1281
+ }
1282
+ return true;
1283
+ };
1284
+ const verifiedAt = new Map();
1285
+ for (let k = 0; k < segs.length; k++) {
1286
+ const seg = segs[k];
1287
+ const t = seg.text;
1288
+ const at = seg.start + Math.max(0, t.search(/\S/));
1289
+ const assign = FETCH_ASSIGN_RE.exec(t);
1290
+ if (assign) {
1291
+ this.fetchedVars.set(assign[1], { line, feed: feedId, index: at });
1292
+ continue;
1293
+ }
1294
+ const isFetch = FETCH_STAGE_RE.test(t) && (/https?:\/\//i.test(t) || hasBareHostFetch(t, 1));
1295
+ const isDecode = DECODE_STAGE_RE.test(t);
1296
+ if (isFetch || isDecode) {
1297
+ const kind = isFetch ? 'remote' : 'encoded';
1298
+ const file = producedFile(t, kind);
1299
+ if (file)
1300
+ this.produced.set(file, { kind, line, feed: feedId, seg: k, index: at });
1301
+ continue;
1302
+ }
1303
+ const generated = HASH_TO_FILE_RE.exec(t);
1304
+ if (generated) {
1305
+ this.generated.add(fileKey(generated[1]));
1306
+ continue;
1307
+ }
1308
+ if (isVerify[k]) {
1309
+ for (const file of this.verifiedFiles(t, k > 0 && segs[k - 1].sep === '|' ? segs[k - 1].text : undefined)) {
1310
+ verifiedAt.set(file, [...(verifiedAt.get(file) ?? []), k]);
1311
+ }
1312
+ continue;
1313
+ }
1314
+ const runVar = RUN_VAR_RE.exec(t);
1315
+ if (runVar && this.fetchedVars.has(runVar[1])) {
1316
+ const src = this.fetchedVars.get(runVar[1]);
1317
+ return this.hit('remote-exec', text, src.feed === feedId ? src.index : at, seg.start + t.length, src.line);
1318
+ }
1319
+ // `chmod +x f` and `cat f` are not executions.
1320
+ if (/^\s{0,5}(?:chmod|cat|ls|rm|mv|cp|echo|printf|test|\[)\b/i.test(t))
1321
+ continue;
1322
+ const run = RUN_FILE_RE.exec(t);
1323
+ if (!run)
1324
+ continue;
1325
+ const file = fileKey(run[1]);
1326
+ const source = this.produced.get(file);
1327
+ if (!source)
1328
+ continue;
1329
+ const verified = source.kind === 'remote' && source.feed === feedId &&
1330
+ (verifiedAt.get(file) ?? []).some((v) => v > source.seg && chainedAnd(source.seg, k));
1331
+ if (verified)
1332
+ continue;
1333
+ return this.hit(source.kind === 'remote' ? 'remote-exec' : 'encoded-exec', text, source.feed === feedId ? source.index : at, seg.start + t.length, source.line);
1334
+ }
1335
+ return undefined;
1336
+ }
1337
+ /** Files a verification segment really checks. */
1338
+ verifiedFiles(t, pipedFrom) {
1339
+ const produced = [...this.produced.keys()];
1340
+ // Self-certification: nothing checked against a locally generated or inline manifest counts.
1341
+ if (/--ignore-missing\b|<<<|<\(/.test(t))
1342
+ return [];
1343
+ const manifest = CHECKSUM_MANIFEST_RE.exec(t)?.[1];
1344
+ if (manifest !== undefined) {
1345
+ if (manifest === '-' || manifest === '/dev/stdin') {
1346
+ if (pipedFrom === undefined || HASH_CMD_RE.test(pipedFrom) || /<\(|\$\(/.test(pipedFrom))
1347
+ return [];
1348
+ const names = tokenKeys(pipedFrom);
1349
+ return produced.filter((f) => names.has(f));
1350
+ }
1351
+ const key = fileKey(manifest);
1352
+ if (manifest.startsWith('<') || this.generated.has(key))
1353
+ return [];
1354
+ const fetched = this.produced.get(key)?.kind === 'remote';
1355
+ if (!fetched && !this.manifestExists(manifest.replace(/^['"]+|['"]+$/g, '')))
1356
+ return [];
1357
+ return produced; // a real manifest covers what it lists
1358
+ }
1359
+ const names = tokenKeys(t);
1360
+ return produced.filter((f) => names.has(f));
1361
+ }
1362
+ hit(rule, text, index, end, fromLine) {
1363
+ const i = Math.min(index, text.length);
1364
+ return { rule, found: { index: i, match: fakeMatch(text, i, Math.max(i, Math.min(text.length, end))) }, fromLine };
1365
+ }
1366
+ }
1367
+ const REMOTE_EXEC_PATTERNS = [
1368
+ // bash <(curl …) / bash < <(curl …) / source <(wget …)
1369
+ {
1370
+ requires: (w) => w.includes('<('),
1371
+ pattern: /(?:^|[\s;&|(`])(?:(?:[\w./-]{0,40}\/)?(?:ba|z|k)?sh|source|\.)\s{1,5}(?:<\s{0,3})?<\(\s{0,5}(?:curl|wget)\b/i,
1372
+ },
1373
+ // curl … > >(bash) (output redirected into a process substitution that runs a shell)
1374
+ {
1375
+ requires: (w) => w.includes('>(') && /curl|wget/i.test(w),
1376
+ pattern: /(?:^|[\s;&|(`])(?:curl|wget)\b[^\n;&|]{0,300}>\s{0,3}>\(\s{0,5}(?:sudo\s{1,5})?(?:[\w./-]{0,40}\/)?(?:ba|z|k|da)?sh\b/i,
1377
+ },
1378
+ // sh -c "$(curl …)" / eval "$(wget …)" / bash <<< "$(curl …)"
1379
+ {
1380
+ requires: (w) => w.includes('$(') || w.includes('`'),
1381
+ pattern: /\b(?:(?:ba|z|k)?sh\s{1,5}(?:-c|<<<)|eval|python[0-9.]{0,5}\s{1,5}-c|node\s{1,5}-e|perl\s{1,5}-e|ruby\s{1,5}-e)\s{0,5}["']?(?:\$\(|`)\s{0,5}(?:curl|wget)\b/i,
1382
+ },
1383
+ // iex (iwr …) / iex (curl …) / Invoke-Expression ((New-Object Net.WebClient).DownloadString(…))
1384
+ {
1385
+ requires: (w) => /iex|invoke-expression/i.test(w) && /iwr|irm|curl|wget|invoke-web|invoke-rest|downloadstring|webclient/i.test(w),
1386
+ pattern: /\b(?:iex|invoke-expression)\b[^\n]{0,120}\b(?:iwr|irm|curl|wget|invoke-webrequest|invoke-restmethod|downloadstring|net\.webclient)\b/i,
1387
+ },
1388
+ ];
1389
+ const ENCODED_EXEC_PATTERNS = [
1390
+ // eval "$(echo … | base64 -d)" / sh -c "$(… | base64 -d)"
1391
+ {
1392
+ requires: (w) => w.includes('$(') && /base64|xxd/i.test(w),
1393
+ pattern: /\b(?:eval|(?:ba|z|k)?sh\s{1,5}-c)\s{0,5}["']?\$\([^)\n]{0,300}(?:base64\s{1,5}(?:-\S{1,10}\s{1,5}){0,3}(?:-d|--decode|-D)|xxd\s{1,5}(?:-\S{1,10}\s{1,5}){0,3}-r)\b/i,
1394
+ },
1395
+ // eval(atob(…)) / exec(base64.b64decode(…)) / exec(__import__('base64').b64decode(…))
1396
+ {
1397
+ requires: (w) => /atob|Buffer\.from|b64decode|decodebytes|codecs\.decode|FromBase64String|a85decode|b32decode/i.test(w),
1398
+ pattern: /\b(?:eval|exec|Function|Invoke-Expression|iex)\s{0,5}\(?[^\n]{0,80}?(?:atob|Buffer\.from|b64decode|b32decode|a85decode|decodebytes|codecs\.decode|FromBase64String)\b/i,
1399
+ },
1400
+ {
1401
+ requires: (w) => /powershell|pwsh/i.test(w) && /\s-(?:e|ec|enc|encodedcommand)\s{1,5}[A-Za-z0-9+/]{20}/i.test(w),
1402
+ pattern: /\b(?:powershell|pwsh)(?:\.exe)?\b[^\n]{0,300}\s-(?:e|ec|enc|encodedcommand)\s{1,5}[A-Za-z0-9+/]{20,}/i,
1403
+ },
1404
+ ];
1405
+ /** Direct eval-of-fetch on one line: remote-eval without needing file context. */
1406
+ const DIRECT_REMOTE_EVAL_RE = /\b(?:eval|exec|Function)\s{0,5}\(\s{0,5}(?:await\s{1,5})?\(?\s{0,5}(?:await\s{1,5})?(?:fetch|requests\.get|urlopen|urllib\.request\.urlopen|http\.get)\s{0,5}\(/i;
1407
+ /**
1408
+ * Python HTTP clients imported or assigned under another name:
1409
+ * `from urllib.request import urlopen as u`, `from urllib import request as rq`,
1410
+ * `import requests as r`, `fetch = urllib.request.urlopen`. `exec(u(url).read())`
1411
+ * is then the same remote-code loader as `exec(urlopen(url).read())`. The names
1412
+ * a code block (or a script) binds are collected once, in a single pass with one
1413
+ * regex, and an `exec(NAME(` / `eval(NAME(` call is checked against that set —
1414
+ * no cap, so a flood of decoy imports cannot push the real one out.
1415
+ */
1416
+ const PY_HTTP_MODULE = String.raw `(?:urllib\.request|urllib2|requests|httpx)`;
1417
+ const PY_ALIAS_RE = new RegExp([
1418
+ String.raw `\bfrom\s{1,5}${PY_HTTP_MODULE}\s{1,5}import\s{1,5}\(?(?<list>[^\n;#]{1,300})`,
1419
+ String.raw `\bfrom\s{1,5}urllib\s{1,5}import\s{1,5}\(?(?<ulist>[^\n;#]{1,300})`,
1420
+ String.raw `\bimport\s{1,5}${PY_HTTP_MODULE}\s{1,5}as\s{1,5}(?<mod>[A-Za-z_]\w{0,39})\b`,
1421
+ String.raw `(?:^|[\s;(])(?<fn>[A-Za-z_]\w{0,39})\s{0,3}=\s{0,3}(?:urllib\.request\.urlopen|urllib2\.urlopen|urlopen|requests\.(?:get|post)|httpx\.(?:get|post))\s{0,3}(?=$|[\s;#)])`,
1422
+ String.raw `(?:^|[\s;(])(?<modassign>[A-Za-z_]\w{0,39})\s{0,3}=\s{0,3}${PY_HTTP_MODULE}\s{0,3}(?=$|[\s;#)])`,
1423
+ ].join('|'), 'g');
1424
+ /** `exec(NAME(` / `eval(compile(NAME(`: the callee of the evaluated call. */
1425
+ const PY_EVAL_CALLEE_RE = /\b(?:eval|exec)\s{0,5}\(\s{0,5}(?:compile\s{0,5}\(\s{0,5})?([A-Za-z_][\w.]{0,60})\s{0,5}\(/g;
1426
+ const PY_FETCH_FUNCTIONS = ['urlopen', 'get', 'post', 'request'];
1427
+ const PY_FETCH_FUNCTION_SET = new Set(PY_FETCH_FUNCTIONS);
1428
+ const PY_HINT_RE = /urlopen|urllib|requests|httpx/;
1429
+ const fetchAliasCache = new WeakMap();
1430
+ function collectFetchAliases(line, names) {
1431
+ const matches = [];
1432
+ if (line.length <= MAX_SCAN_WINDOW) {
1433
+ PY_ALIAS_RE.lastIndex = 0;
1434
+ for (let m = PY_ALIAS_RE.exec(line); m !== null && matches.length < 20; m = PY_ALIAS_RE.exec(line))
1435
+ matches.push(m);
1436
+ }
1437
+ else {
1438
+ for (const found of findAll(PY_ALIAS_RE, line, 20))
1439
+ matches.push(found.match);
1440
+ }
1441
+ for (const match of matches) {
1442
+ const g = match.groups ?? {};
1443
+ if (g.list !== undefined) {
1444
+ for (const item of g.list.split(',').slice(0, 12)) {
1445
+ const [name, alias] = item.trim().replace(/[()]/g, '').split(/\s{1,5}as\s{1,5}/i);
1446
+ if (name !== undefined && PY_FETCH_FUNCTION_SET.has(name))
1447
+ names.add((alias ?? name).trim());
1448
+ }
1449
+ }
1450
+ else if (g.ulist !== undefined) {
1451
+ // `from urllib import request as rq`: `rq` is the module.
1452
+ for (const item of g.ulist.split(',').slice(0, 12)) {
1453
+ const [name, alias] = item.trim().replace(/[()]/g, '').split(/\s{1,5}as\s{1,5}/i);
1454
+ if (name === 'request')
1455
+ for (const fn of PY_FETCH_FUNCTIONS)
1456
+ names.add(`${(alias ?? name).trim()}.${fn}`);
1457
+ }
1458
+ }
1459
+ else if (g.mod !== undefined || g.modassign !== undefined) {
1460
+ for (const fn of PY_FETCH_FUNCTIONS)
1461
+ names.add(`${(g.mod ?? g.modassign)}.${fn}`);
1462
+ }
1463
+ else if (g.fn !== undefined) {
1464
+ names.add(g.fn);
1465
+ }
1466
+ }
1467
+ }
1468
+ /** The names bound to a fetch function in each code block of the document (a script is one block). */
1469
+ function fetchAliasIndex(doc) {
1470
+ const cached = fetchAliasCache.get(doc);
1471
+ if (cached !== undefined)
1472
+ return cached;
1473
+ let index = null;
1474
+ if (PY_HINT_RE.test(doc.content)) {
1475
+ const blockOf = chainBlocks(doc);
1476
+ const byBlock = new Map();
1477
+ // Repeated identical short lines (a decoy flood) are examined once.
1478
+ const seen = new Set();
1479
+ for (const i of doc.active) {
1480
+ const line = doc.lines[i];
1481
+ if (!line.includes('import') && !line.includes('='))
1482
+ continue;
1483
+ if (!PY_HINT_RE.test(line))
1484
+ continue;
1485
+ if (line.length < 300) {
1486
+ if (seen.has(`${blockOf(i)}|${line}`))
1487
+ continue;
1488
+ if (seen.size < 5000)
1489
+ seen.add(`${blockOf(i)}|${line}`);
1490
+ }
1491
+ const block = blockOf(i);
1492
+ let names = byBlock.get(block);
1493
+ if (!names)
1494
+ byBlock.set(block, (names = new Set()));
1495
+ collectFetchAliases(line, names);
1496
+ }
1497
+ index = { blockOf, byBlock };
1498
+ }
1499
+ fetchAliasCache.set(doc, index);
1500
+ return index;
1501
+ }
1502
+ /** One-line eval-of-fetch, including a fetch function bound under another name in the same code block. */
1503
+ function directRemoteEval(doc, text, lineIndex) {
1504
+ const direct = findFirst(DIRECT_REMOTE_EVAL_RE, text);
1505
+ if (direct)
1506
+ return direct;
1507
+ const aliases = fetchAliasIndex(doc);
1508
+ const names = aliases?.byBlock.get(aliases.blockOf(lineIndex));
1509
+ if (!names || names.size === 0 || !text.includes('('))
1510
+ return undefined;
1511
+ for (const found of findAll(PY_EVAL_CALLEE_RE, text, 20)) {
1512
+ if (names.has(found.match[1]))
1513
+ return found;
1514
+ }
1515
+ return undefined;
1516
+ }
1517
+ /** Strong dynamic evaluation, per language. */
1518
+ const EVAL_PATTERNS = {
1519
+ js: [
1520
+ /(?<![.\w$])eval\s{0,5}\(/,
1521
+ /\bnew\s{1,5}Function\s{0,5}\(/,
1522
+ /\bvm\.(?:runInNewContext|runInThisContext|runInContext|compileFunction)\s{0,5}\(|\bnew\s{1,5}vm\.Script\s{0,5}\(/,
1523
+ ],
1524
+ py: [/(?<![.\w])(?:exec|eval)\s{0,5}\(/],
1525
+ sh: [/(?:^|[\s;&|(])eval\s{1,5}\S/],
1526
+ ps: [/\b(?:Invoke-Expression|iex)\b/i],
1527
+ rb: [/(?<![.\w])eval\s{0,5}[( ]/],
1528
+ };
1529
+ /** Dynamic loading that is only suspicious next to network access. */
1530
+ const DYNAMIC_LOAD_PATTERNS = {
1531
+ js: [
1532
+ /(?<![.\w$])require\s{0,5}\(\s{0,5}(?!['"][^'"\n]{0,300}['"]\s{0,5}\))[^)\s]/,
1533
+ /(?<![.\w$])import\s{0,5}\(\s{0,5}(?!['"][^'"\n]{0,300}['"]\s{0,5}\))[^)\s]/,
1534
+ ],
1535
+ py: [
1536
+ /(?<![.\w])__import__\s{0,5}\(\s{0,5}(?!['"])/,
1537
+ /\bimportlib\.import_module\s{0,5}\(\s{0,5}(?!['"])/,
1538
+ /\b(?:pickle|marshal)\.loads?\s{0,5}\(/,
1539
+ ],
1540
+ };
1541
+ const NETWORK_RE = new RegExp([
1542
+ String.raw `(?:^|[\s;&|(\x60$])(?:curl|wget|nc|ncat|netcat|socat|scp|sftp|telnet)\s`,
1543
+ String.raw `\/dev\/(?:tcp|udp)\/`,
1544
+ String.raw `(?<![.\w$])fetch\s{0,5}\(`,
1545
+ String.raw `\baxios\b`,
1546
+ String.raw `\bhttps?\.(?:get|request)\s{0,5}\(`,
1547
+ String.raw `\brequire\s{0,5}\(\s{0,5}['"](?:node:)?(?:https?|net|dgram)['"]`,
1548
+ String.raw `\bfrom\s{1,5}['"](?:node:)?(?:https?|net|dgram)['"]`,
1549
+ String.raw `\bnew\s{1,5}WebSocket\b`,
1550
+ String.raw `\bXMLHttpRequest\b`,
1551
+ String.raw `\b(?:node-fetch|undici)\b`,
1552
+ String.raw `\brequests\.(?:get|post|put|patch|request|Session)\b`,
1553
+ String.raw `\burllib\.request\b`,
1554
+ String.raw `\burlopen\s{0,5}\(`,
1555
+ String.raw `\bhttp\.client\b`,
1556
+ String.raw `\bhttpx\.`,
1557
+ String.raw `\baiohttp\b`,
1558
+ String.raw `\bsocket\.(?:socket|create_connection)\b`,
1559
+ String.raw `\bsmtplib\b`,
1560
+ String.raw `\b(?:Invoke-WebRequest|Invoke-RestMethod|iwr|irm)\b`,
1561
+ String.raw `\bNet\.WebClient\b`,
1562
+ String.raw `\bSystem\.Net\.Http\b`,
1563
+ String.raw `\bNet::HTTP\b`,
1564
+ String.raw `\bopen-uri\b`,
1565
+ String.raw `\bURI\.open\b`,
1566
+ String.raw `\bdns\.(?:resolve|lookup)\s{0,5}\(`,
1567
+ ].join('|'), 'i');
1568
+ /** Bulk environment dumps — not a single named variable. */
1569
+ const BULK_ENV_RE = new RegExp([
1570
+ String.raw `JSON\.stringify\(\s{0,5}process\.env\s{0,5}\)`,
1571
+ String.raw `Object\.(?:entries|keys|values)\(\s{0,5}process\.env\s{0,5}\)`,
1572
+ String.raw `json\.dumps\(\s{0,5}(?:dict\(\s{0,5})?os\.environ`,
1573
+ String.raw `\bstr\(\s{0,5}os\.environ\s{0,5}\)`,
1574
+ String.raw `\bos\.environ\.items\(\s{0,5}\)`,
1575
+ String.raw `(?:^|[\s;&(])(?:printenv|env)\s{0,5}[|>]`,
1576
+ String.raw `\$\(\s{0,5}(?:printenv|env)\s{0,5}\)`,
1577
+ String.raw `\x60\s{0,5}(?:printenv|env)\s{0,5}\x60`,
1578
+ String.raw `\bprintenv\s{0,5}$`,
1579
+ String.raw `\/proc\/(?:self|\d{1,10}|\$\$)\/environ`,
1580
+ String.raw `\b(?:Get-ChildItem|gci|dir|ls)\s{1,5}env:`,
1581
+ String.raw `\[Environment\]::GetEnvironmentVariables\(\s{0,5}\)`,
1582
+ String.raw `\bENV\.to_h\b`,
1583
+ ].join('|'), 'i');
1584
+ /**
1585
+ * Credential stores whose only reason to be read next to a network call is to
1586
+ * send them somewhere: SSH private keys, AWS/GPG stores, netrc/git-credentials,
1587
+ * keychains, wallets, browser logins. Config a legitimate client reads for its
1588
+ * own service (kubeconfig, docker/gh/gcloud config, .npmrc) is deliberately
1589
+ * not listed — a k8s or registry skill reading it is normal.
1590
+ */
1591
+ const CREDENTIAL_SOURCE_RE = new RegExp([
1592
+ String.raw `(?:^|[\s'"\/\\~(])\.ssh(?=[\s'")]|\/?['"]|\/?$)`, // the whole ~/.ssh directory
1593
+ String.raw `\.ssh\/(?:id_\w{1,40}|[\w.-]{1,80}\.(?:pem|key))\b`,
1594
+ String.raw `\bid_(?:rsa|ed25519|ecdsa|dsa)\b`,
1595
+ String.raw `(?:^|[\s'"\/\\~(])\.(?:aws|gnupg)(?=[\/\\'"\s)]|$)`,
1596
+ String.raw `(?:^|[\s'"\/\\~(])\.(?:netrc|git-credentials)\b`,
1597
+ String.raw `\bsecurity\s{1,5}(?:find-generic-password|find-internet-password|dump-keychain)\b`,
1598
+ String.raw `\bsecret-tool\s{1,5}lookup\b`,
1599
+ String.raw `\bwallet\.dat\b`,
1600
+ String.raw `\.config\/solana\/id\.json`,
1601
+ String.raw `\bkeystore\/UTC--`,
1602
+ String.raw `\.ethereum\/keystore`,
1603
+ String.raw `\bLogin Data\b`,
1604
+ String.raw `\bseed[_\s-]?phrase\b`,
1605
+ ].join('|'), 'i');
1606
+ // Prose: prompt-injection overrides.
1607
+ const OVERRIDE_PATTERNS = [
1608
+ // Not "…previous instructions files in legacy/": a following path/noun makes it a file reference.
1609
+ /\b(?:ignore|disregard|forget)\s{1,5}(?:all\s{1,5}|any\s{1,5}|every\s{1,5})?(?:of\s{1,5})?(?:the\s{1,5}|your\s{1,5}|my\s{1,5}|these\s{1,5}|those\s{1,5})?(?:previous|prior|above|earlier|preceding|system|developer|safety)\s{1,5}(?:instructions?|prompts?|rules|guidelines|directives|messages?|context)\b(?![\w/-]|\.[\w/]|\s{1,5}(?:files?|folders?|director(?:y|ies)|docs?|pages?|in\s{1,5}\S{0,80}\/))/i,
1610
+ /\byou\s{1,5}are\s{1,5}(?:now\s{1,5})?(?:in\s{1,5})?(?:DAN|developer\s{1,5}mode|jailbroken|jailbreak\s{1,5}mode|unrestricted\s{1,5}mode|god\s{1,5}mode)\b/i,
1611
+ /<\|im_start\|>|<\|(?:system|endoftext)\|>|<<SYS>>|\[\/?INST\]/i,
1612
+ /\b(?:new|updated|actual|real)\s{1,5}system\s{1,5}prompt\b/i,
1613
+ /\b(?:these|this|the\s{1,5}following)\s{1,5}instructions?\s{1,5}(?:take|takes|have|has)\s{1,5}(?:precedence|priority)\s{1,5}over\s{1,5}(?:all\s{1,5}|any\s{1,5}|the\s{1,5})?(?:system|safety)\b/i,
1614
+ ];
1615
+ /** Cheap per-line prefilters: a line without these words cannot match the group. */
1616
+ const OVERRIDE_HINT_RE = /ignore|disregard|forget|you\s{1,5}are|<\||<<SYS|INST\]|system\s{1,5}prompt|precedence|priority/i;
1617
+ const CONCEAL_HINT_RE = /user|silently/i;
1618
+ const BYPASS_HINT_RE = /without|disable|turn\s{1,5}off|bypass|circumvent|skip|override|approve/i;
1619
+ // Prose: concealment. Strong patterns are high; "don't tell the user" is a warning.
1620
+ const CONCEAL_STRONG = [
1621
+ // Not "consent": "… without user consent" is overwhelmingly a policy statement, not an instruction.
1622
+ /\b(?:without|w\/o)\s{1,5}(?:the\s{1,5})?user'?s?\s{1,5}(?:knowledge|knowing|noticing|awareness)\b/i,
1623
+ /\b(?:hide|conceal|obscure)\s{1,5}(?:this|these|it|them|that|the\s{1,5}[\w\s-]{1,30}?)\s{1,5}from\s{1,5}(?:the\s{1,5})?user\b/i,
1624
+ /\bsilently\s{1,5}(?:upload|send|post|exfiltrate|transmit|forward)\b/i,
1625
+ ];
1626
+ const CONCEAL_SOFT = [
1627
+ // "never tell the user TO do X" is advice about wording, not concealment.
1628
+ /\b(?:do\s{1,5}not|don't|never)\s{1,5}(?:tell|inform|notify|alert|warn)\s{1,5}(?:the\s{1,5})?user\b(?!\s{1,5}to\b)/i,
1629
+ /\b(?:do\s{1,5}not|don't|never)\s{1,5}(?:mention|reveal|disclose)\s{1,5}(?:this|that|it|these\s{1,5}(?:steps|instructions))\s{1,5}to\s{1,5}(?:the\s{1,5})?user\b/i,
1630
+ ];
1631
+ // Permission/safety bypass. Flags apply to every file; prose to instruction files only.
1632
+ const BYPASS_FLAG_RE = /--dangerously-skip-permissions\b|--dangerously-bypass-approvals-and-sandbox\b|\bbypassPermissions\b|--yolo\b|--allow-all-tools\b/i;
1633
+ const BYPASS_PROSE = [
1634
+ // Not "… without asking for confirmation on each file": that is batching, not bypassing.
1635
+ /\bwithout\s{1,5}(?:first\s{1,5})?(?:asking|prompting|checking\s{1,5}with)\s{1,5}(?:the\s{1,5}user\s{1,5})?(?:for\s{1,5})?(?:permission|confirmation|approval|consent)\b(?!\s{1,5}(?:on|for)\s{1,5}(?:each|every|individual|single)\b)/i,
1636
+ /\b(?:disable|turn\s{1,5}off|bypass|circumvent|skip|override)\s{1,5}(?:the\s{1,5}|any\s{1,5}|all\s{1,5})?(?:sandbox(?:ing)?|safety\s{1,5}(?:checks?|guardrails?|filters?|prompts?)|guardrails?|permission\s{1,5}(?:prompts?|checks?|dialogs?)|approval\s{1,5}(?:prompts?|steps?)|content\s{1,5}(?:filters?|polic(?:y|ies)))\b/i,
1637
+ /\bauto[-\s]?approve\s{1,5}(?:all|every|any)\b/i,
1638
+ ];
1639
+ // Prose: exfiltration instructions (matched procedurally, see checkExfil).
1640
+ const EXFIL_VERB_RE = /\b(?:send|upload|post|transmit|exfiltrate|forward|paste|leak|e-?mail)\b/gi;
1641
+ const EXFIL_VERB_PREFILTER_RE = /\b(?:send|upload|post|transmit|exfiltrate|forward|paste|leak|e-?mail)\b/i;
1642
+ /** Bulk secrets: always suspicious to move off-machine. */
1643
+ const SECRET_BULK_RE = /\b(?:all\s{1,5}(?:the\s{1,5}|your\s{1,5})?(?:secrets|credentials|tokens|keys|passwords)|env(?:ironment)?\s{1,5}var(?:iable)?s?|private\s{1,5}keys?|ssh\s{1,5}keys?|id_rsa|session\s{1,5}cookies|browser\s{1,5}cookies|wallets?|seed\s{1,5}phrases?|keychain)\b|\.env\b|~\/\.ssh\b|\.aws\/credentials\b/i;
1644
+ /** One key/token/password: routine to send to the API that needs it. */
1645
+ const SECRET_SINGLE_RE = /\b(?:secrets?|credentials?|api[\s_-]?keys?|access[\s_-]?tokens?|auth(?:entication)?\s{1,5}tokens?|bearer\s{1,5}tokens?|passwords?(?!\s{1,5}reset))\b/i;
1646
+ const PREPOSITION_RE = /\b(?:to|into|at|via)\b/i;
1647
+ const EXFIL_DEST_STRONG_RE = /webhook|pastebin|paste\.|hastebin|discord|telegram|ngrok|requestbin|pipedream|interactsh|burpcollaborator|\.onion\b|https?:\/\/\d{1,3}(?:\.\d{1,3}){3}/i;
1648
+ const EXFIL_DEST_ANY_RE = /https?:\/\/|\bserver\b|\bendpoint\b|\bremote\b|\bexternal\b|third[-\s]party|\battacker\b|e-?mail\s{1,5}address/i;
1649
+ const EXFIL_DEST_EXTERNAL_RE = /\bexternal\b|third[-\s]party|\battacker\b/i;
1650
+ /** Sending a key as request auth is how API skills work, not exfiltration. */
1651
+ const AUTH_CONTEXT_RE = /\b(?:header|authorization|authenticate|bearer\s{1,5}auth)\b|\s-H\s/i;
1652
+ // Hidden markup.
1653
+ const HIDDEN_STYLE_RE = /\sstyle\s{0,5}=\s{0,5}["'][^"'>]{0,200}(?:display\s{0,5}:\s{0,5}none|visibility\s{0,5}:\s{0,5}hidden|font-size\s{0,5}:\s{0,5}0(?:px|pt|em|rem)?\s{0,5}(?:;|["'])|opacity\s{0,5}:\s{0,5}0(?:\.0{1,5})?\s{0,5}(?:;|["']))/i;
1654
+ const HIDDEN_ATTR_RE = /\shidden(?:\s|=|\/|$)/i;
1655
+ /** Tooling directives and section markers that legitimately live in comments. */
1656
+ const BENIGN_COMMENT_RE = /^\s{0,20}(?:promptci-ignore|markdownlint|prettier|eslint|cspell|textlint|vale\b|toc\b|omit\s{1,5}in\s{1,5}toc|end\s{0,5}toc|mdformat|lint|region|endregion|#region|#endregion|@formatter|todo\b|fixme\b|xxx\b)/i;
1657
+ /** Comment content that is always suspicious, even behind a benign-looking prefix. */
1658
+ const STRONG_COMMENT_RE = /\b(?:ignore|disregard|forget)\b[^\n]{0,40}\b(?:instructions?|prompts?|rules|guidelines)\b|\bsystem\s{1,5}prompt\b|\b(?:curl|wget|iwr|invoke-webrequest)\b|\bbase64\s{1,5}-d\b|~\/\.ssh|\b(?:send|upload|exfiltrate)\b[^\n]{0,40}\b(?:secrets?|credentials?|keys?|tokens?|\.env)\b/i;
1659
+ /** A second-person or imperative directive: a benign-looking prefix does not excuse it. */
1660
+ const COMMENT_DIRECTIVE_RE = /\byou\b|\b(?:must|always|never|approve|run|ignore|execute|send|upload|install|delete|disable|download|fetch|read|print|reveal)\b/i;
1661
+ const AGENT_DIRECTED_RE = new RegExp([
1662
+ String.raw `\b(?:ignore|disregard|forget)\b[^\n]{0,40}\b(?:instructions?|prompts?|rules|guidelines)\b`,
1663
+ String.raw `\byou\s{1,5}(?:must|are\s{1,5}now|will\s{1,5}now|should\s{1,5}(?:always|never|secretly|silently))\b`,
1664
+ String.raw `\b(?:assistant|claude|the\s{1,5}(?:ai|agent|model|llm))\s{0,5}[:,]`,
1665
+ String.raw `\b(?:dear|hey)\s{1,5}(?:ai|assistant|claude|agent|model)\b`,
1666
+ String.raw `\bsystem\s{1,5}prompt\b`,
1667
+ String.raw `\b(?:curl|wget|iwr|invoke-webrequest)\b`,
1668
+ String.raw `\bbase64\s{1,5}-d\b`,
1669
+ // An imperative aimed at a secret — "secrets go in .env" is a note, not a directive.
1670
+ String.raw `\b(?:read|send|upload|post|print|cat|copy|dump|exfiltrate|include|attach|paste)\b[^\n]{0,40}(?:\b(?:secrets?|credentials?|api[_\s-]?keys?|passwords?|private\s{1,5}keys?)\b|~\/\.ssh|\.env\b)`,
1671
+ String.raw `\b(?:run|execute|download|upload|send|install)\s{1,5}(?:this|the\s{1,5}following|it)\b`,
1672
+ ].join('|'), 'i');
1673
+ /** A base64-alphabet run of at least 200 characters. */
1674
+ const BLOB_RE = /(?<![A-Za-z0-9+/=])[A-Za-z0-9+/]{200,}={0,2}(?![A-Za-z0-9+/=])/g;
1675
+ // Unpinned remote dependencies.
1676
+ const INSTALL_CTX_RE = /\b(?:pip[0-9.]{0,5}|pipx|uvx|uv|npm|pnpm|yarn|bun|npx|bunx|go|cargo|gem|deno)\b/i;
1677
+ const GIT_SOURCE_RE = /\bgit\+(?:https?|ssh|git|file):\/\/[^\s'"<>)`]{1,500}|\bgithub:[\w.-]{1,100}\/[\w.-]{1,100}(?:#[^\s'"<>)`]{0,100})?/gi;
1678
+ /**
1679
+ * `npx tool@latest` — found from the tag backwards: each `@latest` looks back a
1680
+ * bounded distance for an installer on the same command.
1681
+ */
1682
+ const LATEST_TAG_RE = /(?<=[\w.-])@(?:latest|master|main|HEAD)(?![\w.-])/gi;
1683
+ const INSTALLER_BEFORE_RE = /\b(?:npx|bunx|pnpm\s{1,5}dlx|yarn\s{1,5}dlx|uvx|pipx\s{1,5}run|npm\s{1,5}(?:i|install|exec|add)|pnpm\s{1,5}(?:add|i|install)|yarn\s{1,5}add|bun\s{1,5}(?:add|i|install|x)|go\s{1,5}(?:install|run)|deno\s{1,5}(?:run|install))\b[^\n;&|]{0,200}$/i;
1684
+ const NPM_INSTALL_RE = /\b(?:npm\s{1,5}(?:i|install|add)|pnpm\s{1,5}(?:add|i|install)|yarn\s{1,5}add|bun\s{1,5}(?:add|i|install))\b([^\n;&|]{0,500})/i;
1685
+ const CARGO_GIT_RE = /\bcargo\s{1,5}install\b[^\n;&|]{0,300}--git\s{1,5}\S/i;
1686
+ const MUTABLE_SCRIPT_URL_RE = new RegExp([
1687
+ String.raw `https?:\/\/raw\.githubusercontent\.com\/[^/\s]{1,100}\/[^/\s]{1,100}\/(?:refs\/heads\/)?(?:main|master|HEAD|develop|dev|trunk)\/[^\s'"<>)\x60]{0,300}`,
1688
+ String.raw `https?:\/\/github\.com\/[^/\s]{1,100}\/[^/\s]{1,100}\/raw\/(?:refs\/heads\/)?(?:main|master|HEAD|develop|dev|trunk)\/[^\s'"<>)\x60]{0,300}`,
1689
+ String.raw `https?:\/\/github\.com\/[^/\s]{1,100}\/[^/\s]{1,100}\/releases\/latest\/download\/[^\s'"<>)\x60]{0,300}`,
1690
+ String.raw `https?:\/\/gist\.githubusercontent\.com\/[^/\s]{1,100}\/[0-9a-f]{1,64}\/raw\/(?![0-9a-f]{40}\/)[^\s'"<>)\x60]{1,300}`,
1691
+ ].join('|'), 'i');
1692
+ const FETCH_VERB_RE = /\b(?:curl|wget|iwr|irm|invoke-webrequest|invoke-restmethod|fetch|urlopen|requests\.get|download|downloadstring)\b/i;
1693
+ const SCRIPTISH_URL_RE = /(?:\.(?:sh|bash|zsh|py|js|mjs|cjs|ts|ps1|rb|pl)(?:[?#]|$)|install)/i;
1694
+ const REMOTE_IMPORT_RE = /(?:\bfrom\s{1,5}|\bimport\s{0,5}\(\s{0,5}|\bimport\s{1,5}|\brequire\s{0,5}\(\s{0,5})['"](https?:\/\/[^'"\n]{1,500})['"]/g;
1695
+ /** A pinned git ref: a commit SHA (7–40 hex) or a version tag. */
1696
+ function isPinnedRef(ref) {
1697
+ if (!ref)
1698
+ return false;
1699
+ const r = ref.replace(/^semver:/i, '').trim();
1700
+ return /^[0-9a-f]{7,40}$/i.test(r) || /^v?\d{1,10}(?:\.\d{1,10}){1,4}(?:[-+.][\w.-]{0,50})?$/.test(r);
1701
+ }
1702
+ /** Ref of a git/github source: `@ref` after the repo path (pip) or `#ref` (npm). */
1703
+ function gitSourceRef(source) {
1704
+ const hash = source.indexOf('#');
1705
+ if (hash >= 0) {
1706
+ const frag = source.slice(hash + 1);
1707
+ if (!/^(?:egg|subdirectory)=/.test(frag))
1708
+ return frag.split('&')[0];
1709
+ }
1710
+ const noFrag = hash >= 0 ? source.slice(0, hash) : source;
1711
+ const noScheme = noFrag.replace(/^(?:git\+)?[a-z]{1,10}:\/\//i, '').replace(/^github:/i, '');
1712
+ const slash = noScheme.indexOf('/');
1713
+ const repoPath = slash >= 0 ? noScheme.slice(slash + 1) : noScheme; // drop user@host
1714
+ const at = repoPath.lastIndexOf('@');
1715
+ return at >= 0 ? repoPath.slice(at + 1) : undefined;
1716
+ }
1717
+ function isUnpinnedRemoteSpec(spec) {
1718
+ const s = spec.trim();
1719
+ if (/^(?:git\+|git:|github:|gitlab:|bitbucket:)/i.test(s) || /^[A-Za-z0-9][\w.-]{0,100}\/[\w.-]{1,100}(?:#.{0,100})?$/.test(s)) {
1720
+ return !isPinnedRef(gitSourceRef(s.startsWith('git') || s.includes(':') ? s : `github:${s}`));
1721
+ }
1722
+ if (/^https?:\/\//i.test(s))
1723
+ return !/\d+\.\d+\.\d+|#sha(?:256|512)=/i.test(s);
1724
+ return false;
1725
+ }
1726
+ function latestInstall(text) {
1727
+ if (!/@(?:latest|master|main|head)/i.test(text))
1728
+ return undefined;
1729
+ for (const tag of findAll(LATEST_TAG_RE, text, 10)) {
1730
+ if (INSTALLER_BEFORE_RE.test(text.slice(Math.max(0, tag.index - 230), tag.index)))
1731
+ return tag;
1732
+ }
1733
+ return undefined;
1734
+ }
1735
+ // Script invocations (for missing-script).
1736
+ const SCRIPT_REF = String.raw `((?:\$\{?[A-Za-z_][A-Za-z0-9_]{0,60}\}?\/|\{baseDir\}\/|\.\/)?[\w@.\-/]{1,200}\.(?:sh|bash|zsh|py|js|mjs|cjs|ts|mts|rb|pl|ps1))(?![\w.\-/])`;
1737
+ const INVOKE_RE = new RegExp(String.raw `(?:^|[\s\x60'"(;&|])(?:sudo\s{1,5})?(?:bash|sh|zsh|python[0-9.]{0,5}|node|deno\s{1,5}run(?:\s{1,5}-\S{1,40}){0,8}|bun(?:\s{1,5}run)?|tsx|ts-node|ruby|perl|pwsh(?:\s{1,5}-File)?|powershell(?:\.exe)?(?:\s{1,5}-\S{1,40}){0,8}?\s{1,5}-File|source|uv\s{1,5}run)\s{1,5}${SCRIPT_REF}`, 'gi');
1738
+ const DIRECT_EXEC_RE = new RegExp(String.raw `(?:^|[\s\x60'"(;&|])(\.\/[\w@.\-/]{1,200}\.(?:sh|bash|zsh|py|js|mjs|cjs|ts|rb|pl|ps1))(?![\w.\-/])`, 'g');
1739
+ const CONFIG_FENCE_LANGS = new Set(['json', 'jsonc', 'json5', 'yaml', 'yml', 'toml']);
1740
+ /** Cheap per-line prefilter: no script extension, no invocation to check. */
1741
+ const SCRIPT_EXT_HINT_RE = /\.(?:sh|bash|zsh|py|js|mjs|cjs|ts|mts|rb|pl|ps1)\b/i;
1742
+ // ── Collector ─────────────────────────────────────────────────────────────────
1743
+ const SEVERITY_RANK = { info: 0, warning: 1, high: 2, critical: 3 };
1744
+ const MAX_EVIDENCE = 5;
1745
+ /** Hits kept per rule per file. Scanning continues past it; higher-severity hits displace lower ones. */
1746
+ const MAX_HITS_PER_RULE_FILE = 25;
1747
+ function outranks(a, b) {
1748
+ const s = SEVERITY_RANK[a.severity] - SEVERITY_RANK[b.severity];
1749
+ return s !== 0 ? s > 0 : a.confidence > b.confidence;
1750
+ }
1751
+ class HitCollector {
1752
+ groups = new Map();
1753
+ /** Would a hit of this strength be kept? (Full groups keep only their strongest hits.) */
1754
+ accepts(rule, file, severity, confidence) {
1755
+ const group = this.groups.get(`${rule}|${file}`);
1756
+ if (!group || group.hits.length < MAX_HITS_PER_RULE_FILE)
1757
+ return true;
1758
+ const probe = { excerpt: '', severity, confidence };
1759
+ return group.hits.some((h) => outranks(probe, h));
1760
+ }
1761
+ /** Count a hit that was not kept, so the evidence can say how many more there were. */
1762
+ count(rule, file) {
1763
+ const group = this.groups.get(`${rule}|${file}`);
1764
+ if (group)
1765
+ group.total++;
1766
+ }
1767
+ add(rule, file, hit) {
1768
+ const key = `${rule}|${file}`;
1769
+ let group = this.groups.get(key);
1770
+ if (!group) {
1771
+ group = { rule, file, hits: [], total: 0 };
1772
+ this.groups.set(key, group);
1773
+ }
1774
+ if (group.hits.some((h) => h.line === hit.line && h.excerpt === hit.excerpt))
1775
+ return;
1776
+ group.total++;
1777
+ if (group.hits.length < MAX_HITS_PER_RULE_FILE) {
1778
+ group.hits.push(hit);
1779
+ return;
1780
+ }
1781
+ // Full: keep the strongest hits, so a flood of weak matches cannot hide a later strong one.
1782
+ let weakest = 0;
1783
+ for (let i = 1; i < group.hits.length; i++)
1784
+ if (outranks(group.hits[weakest], group.hits[i]))
1785
+ weakest = i;
1786
+ if (outranks(hit, group.hits[weakest]))
1787
+ group.hits[weakest] = hit;
1788
+ }
1789
+ toIssues(skillMd) {
1790
+ return [...this.groups.values()]
1791
+ .filter((g) => g.hits.length > 0)
1792
+ .sort((a, b) => a.rule.localeCompare(b.rule) || a.file.localeCompare(b.file))
1793
+ .map(({ rule, file, hits, total }) => buildIssue(skillMd, rule, file, hits, total));
1794
+ }
1795
+ }
1796
+ function buildIssue(skillMd, rule, file, hits, total) {
1797
+ const spec = RULES[rule];
1798
+ // Evidence: the strongest hits (the maximum severity is always shown), displayed in file order.
1799
+ const byStrength = hits
1800
+ .map((h, order) => ({ h, order }))
1801
+ .sort((a, b) => (outranks(a.h, b.h) ? -1 : outranks(b.h, a.h) ? 1 : a.order - b.order));
1802
+ const top = byStrength[0].h;
1803
+ const shown = byStrength.slice(0, MAX_EVIDENCE)
1804
+ .sort((a, b) => ((a.h.line ?? 0) - (b.h.line ?? 0)) || a.order - b.order)
1805
+ .map((x) => x.h);
1806
+ const shownFile = visible(file);
1807
+ const evidence = shown.map((h) => (rule === 'unscanned-files' ? h.excerpt : `${shownFile}: ${h.excerpt}`));
1808
+ if (total > shown.length) {
1809
+ evidence.push(rule === 'unscanned-files'
1810
+ ? `…and ${total - shown.length} more unscanned item(s)`
1811
+ : `${shownFile}: …and ${total - shown.length} more match(es)`);
1812
+ }
1813
+ const where = file === skillMd ? displayPath(skillMd) : `${displayPath(file)} (bundled with ${displayPath(skillMd)})`;
1814
+ return {
1815
+ id: `skill-supply-chain-${rule}-${shortHash(`${skillMd}|${file}`)}`,
1816
+ severity: top.severity,
1817
+ category: spec.category,
1818
+ title: spec.title,
1819
+ summary: `${where}: ${spec.summary}`,
1820
+ // The file the match is in comes first, so suppression and reports stay line-scoped to it.
1821
+ filePaths: file === skillMd ? [skillMd] : [file, skillMd],
1822
+ locations: shown.map((h) => (h.line === undefined
1823
+ ? { filePath: file }
1824
+ : { filePath: file, startLine: h.line, endLine: h.line })),
1825
+ evidence,
1826
+ recommendation: spec.recommendation,
1827
+ confidence: top.confidence,
1828
+ tags: [SKILL_SUPPLY_CHAIN_TAG, rule],
1829
+ };
1830
+ }
1831
+ const render = (t) => (typeof t === 'function' ? t() : t);
1832
+ /** Confidence given to a match that looks like documentation but names a concrete target. */
1833
+ const DOCUMENTED_CONFIDENCE = 0.4;
1834
+ /** Record a hit under a documentation verdict: skipped, kept at low confidence (same severity), or as-is. */
1835
+ function addJudged(add, verdict, rule, line, text, severity, confidence) {
1836
+ if (verdict === 'skip')
1837
+ return;
1838
+ if (verdict === 'lowconf')
1839
+ add(rule, line, () => `(looks like documentation, but names a target) ${render(text)}`, severity, DOCUMENTED_CONFIDENCE);
1840
+ else
1841
+ add(rule, line, text, severity, confidence);
1842
+ }
1843
+ /** remote-exec + encoded-exec (+ one-line remote-eval in instruction code). Returns consumed lines. */
1844
+ /** Cheap per-line prefilter: a line with none of these cannot match any exec rule. */
1845
+ const EXEC_HINT_RE = /[|&;<$`]|>\(|iex|invoke-expression|atob|decode|FromBase64|powershell|pwsh/i;
1846
+ /** remote-exec / encoded-exec matches in one logical command. */
1847
+ function execMatches(text, quoteAware) {
1848
+ const hits = [];
1849
+ if (!EXEC_HINT_RE.test(text))
1850
+ return hits;
1851
+ const pipe = pipeInto(text, FETCH_STAGE_RE, quoteAware);
1852
+ if (pipe)
1853
+ hits.push({ rule: 'remote-exec', found: pipe });
1854
+ const decodedPipe = pipeInto(text, ENCODED_PRODUCER_RE, quoteAware);
1855
+ if (decodedPipe)
1856
+ hits.push({ rule: 'encoded-exec', found: decodedPipe });
1857
+ if (!hits.some((h) => h.rule === 'remote-exec')) {
1858
+ for (const g of REMOTE_EXEC_PATTERNS) {
1859
+ const f = findFirst(g.pattern, text, g.requires);
1860
+ if (f) {
1861
+ hits.push({ rule: 'remote-exec', found: f });
1862
+ break;
1863
+ }
1864
+ }
1865
+ }
1866
+ if (!hits.some((h) => h.rule === 'encoded-exec')) {
1867
+ for (const g of ENCODED_EXEC_PATTERNS) {
1868
+ const f = findFirst(g.pattern, text, g.requires);
1869
+ if (f) {
1870
+ hits.push({ rule: 'encoded-exec', found: f });
1871
+ break;
1872
+ }
1873
+ }
1874
+ }
1875
+ return hits;
1876
+ }
1877
+ /**
1878
+ * Block ids for chain tracking: a whole script/config file is one block; in
1879
+ * markdown each fenced block is one block and every prose line its own.
1880
+ */
1881
+ function chainBlocks(doc) {
1882
+ if (doc.kind !== 'instructions')
1883
+ return () => 0;
1884
+ const ids = new Int32Array(doc.lines.length);
1885
+ let id = 0;
1886
+ for (let i = 0; i < doc.lines.length; i++) {
1887
+ if (doc.fenced[i] && !(i > 0 && doc.fenced[i - 1]))
1888
+ id++;
1889
+ ids[i] = doc.fenced[i] ? id : -(i + 1);
1890
+ }
1891
+ return (i) => ids[i];
1892
+ }
1893
+ function checkExec(doc, logical, commands, add, manifestExists) {
1894
+ const consumed = new Set();
1895
+ const blockOf = chainBlocks(doc);
1896
+ let chain = new ChainScanner(manifestExists);
1897
+ let block = Number.NaN;
1898
+ for (const { text, line } of logical) {
1899
+ const i = line - 1;
1900
+ const quoteAware = doc.kind !== 'instructions' || doc.fenced[i] === 1;
1901
+ const b = blockOf(i);
1902
+ if (b !== block) {
1903
+ chain = new ChainScanner(manifestExists);
1904
+ block = b;
1905
+ }
1906
+ const hits = execMatches(text, quoteAware);
1907
+ const chained = chain.feed(text, line, quoteAware);
1908
+ if (chained) {
1909
+ hits.push({
1910
+ rule: chained.rule,
1911
+ found: chained.found,
1912
+ note: chained.fromLine !== line ? `(fetched/decoded at line ${chained.fromLine}) ` : undefined,
1913
+ });
1914
+ }
1915
+ for (const { rule, found, note } of hits) {
1916
+ const len = found.match[0].length;
1917
+ const verdict = documentationVerdict(doc, i, text, found.index, len);
1918
+ addJudged(add, verdict, rule, line, () => `${note ?? ''}${excerpt(text, found.index, len)}`, 'high', 0.85);
1919
+ if (verdict !== 'skip')
1920
+ consumed.add(line);
1921
+ }
1922
+ }
1923
+ if (doc.kind === 'instructions') {
1924
+ // `exec(requests.get(url).text)` in a SKILL.md code sample is still a remote-code loader.
1925
+ for (const { text, line } of commands) {
1926
+ if (consumed.has(line))
1927
+ continue;
1928
+ const hit = directRemoteEval(doc, text, line - 1);
1929
+ if (!hit)
1930
+ continue;
1931
+ consumed.add(line);
1932
+ add('remote-eval', line, excerpt(text, hit.index, hit.match[0].length), 'high', 0.85);
1933
+ }
1934
+ }
1935
+ return consumed;
1936
+ }
1937
+ function firstLineMatching(doc, re) {
1938
+ for (const i of doc.active) {
1939
+ const m = findFirst(re, doc.lines[i]);
1940
+ if (m)
1941
+ return { line: i + 1, index: m.index };
1942
+ }
1943
+ return undefined;
1944
+ }
1945
+ /** remote-eval / dynamic-eval / credential-exfil — bundled scripts and config text. */
1946
+ function checkScript(doc, consumed, add) {
1947
+ const network = firstLineMatching(doc, NETWORK_RE);
1948
+ const evalHits = [];
1949
+ const loadHits = [];
1950
+ const evalPatterns = EVAL_PATTERNS[doc.lang] ?? [];
1951
+ const loadPatterns = DYNAMIC_LOAD_PATTERNS[doc.lang] ?? [];
1952
+ for (const i of doc.active) {
1953
+ const line = i + 1;
1954
+ if (consumed.has(line))
1955
+ continue;
1956
+ const text = doc.lines[i];
1957
+ const direct = directRemoteEval(doc, text, i);
1958
+ if (direct) {
1959
+ add('remote-eval', line, excerpt(text, direct.index, direct.match[0].length), 'high', 0.85);
1960
+ continue;
1961
+ }
1962
+ if (evalHits.length + loadHits.length >= MAX_HITS_PER_RULE_FILE)
1963
+ continue;
1964
+ const ev = evalPatterns.map((re) => findFirst(re, text)).find(Boolean);
1965
+ if (ev) {
1966
+ evalHits.push({ line, index: ev.index });
1967
+ continue;
1968
+ }
1969
+ const ld = loadPatterns.map((re) => findFirst(re, text)).find(Boolean);
1970
+ if (ld)
1971
+ loadHits.push({ line, index: ld.index });
1972
+ }
1973
+ if (network && (evalHits.length > 0 || loadHits.length > 0)) {
1974
+ add('remote-eval', network.line, `network call: ${excerpt(doc.lines[network.line - 1], network.index)}`, 'high', 0.75);
1975
+ for (const h of [...evalHits, ...loadHits])
1976
+ add('remote-eval', h.line, excerpt(doc.lines[h.line - 1], h.index), 'high', 0.75);
1977
+ }
1978
+ else {
1979
+ for (const h of evalHits)
1980
+ add('dynamic-eval', h.line, excerpt(doc.lines[h.line - 1], h.index), 'warning', 0.6);
1981
+ }
1982
+ if (!network)
1983
+ return;
1984
+ const sources = [];
1985
+ for (const i of doc.active) {
1986
+ if (sources.length >= MAX_HITS_PER_RULE_FILE)
1987
+ break;
1988
+ const text = doc.lines[i];
1989
+ const m = findFirst(BULK_ENV_RE, text) ?? findFirst(CREDENTIAL_SOURCE_RE, text);
1990
+ if (!m)
1991
+ continue;
1992
+ // `ssh -i ~/.ssh/id_rsa host` / `IdentityFile` uses the key to authenticate — it is not read out.
1993
+ if (/(?:\s-i\s{0,5}|IdentityFile\s{1,5}|identity_file\s{0,5}=\s{0,5})\S{0,300}$/.test(text.slice(Math.max(0, m.index - 300), m.index + 1)))
1994
+ continue;
1995
+ sources.push({ line: i + 1, index: m.index });
1996
+ }
1997
+ if (sources.length === 0)
1998
+ return;
1999
+ add('credential-exfil', network.line, `network call: ${excerpt(doc.lines[network.line - 1], network.index)}`, 'high', 0.7);
2000
+ for (const s of sources)
2001
+ add('credential-exfil', s.line, `credential/env read: ${excerpt(doc.lines[s.line - 1], s.index)}`, 'high', 0.7);
2002
+ }
2003
+ /**
2004
+ * First match of any pattern with its documentation verdict. `negatable:
2005
+ * false` is for patterns that embed their own negation; `skipQuoted` treats a
2006
+ * quoted phrase (a documented injection string) as data.
2007
+ */
2008
+ function directive(doc, lineIndex, text, patterns, opts = {}) {
2009
+ for (const re of patterns) {
2010
+ const m = findFirst(re, text);
2011
+ if (!m)
2012
+ continue;
2013
+ const len = m.match[0].length;
2014
+ if (opts.skipQuoted && isQuotedData(text, m.index, m.index + len))
2015
+ continue;
2016
+ let verdict;
2017
+ if (opts.negatable === false) {
2018
+ verdict = !doc.fenced[lineIndex] && governedDiscussion(text, m.index, len)
2019
+ ? (hasTarget(text.slice(m.index, m.index + len + 80)) ? 'lowconf' : 'skip')
2020
+ : 'none';
2021
+ }
2022
+ else {
2023
+ verdict = documentationVerdict(doc, lineIndex, text, m.index, len, opts.targetless);
2024
+ }
2025
+ if (verdict === 'skip')
2026
+ continue;
2027
+ return { found: m, verdict };
2028
+ }
2029
+ return undefined;
2030
+ }
2031
+ /** exfil-instruction for one clause: verb → secret → preposition → destination, each in a bounded window. */
2032
+ function checkExfil(clause) {
2033
+ for (const verb of findAll(EXFIL_VERB_RE, clause, 20)) {
2034
+ // A negated verb is still examined: "never send the keys to https://evil" names a target and is kept.
2035
+ const negated = governedNegation(clause, verb.index);
2036
+ const afterVerb = verb.index + verb.match[0].length;
2037
+ const window = clause.slice(afterVerb, afterVerb + 120);
2038
+ const bulk = SECRET_BULK_RE.exec(window);
2039
+ const single = bulk ? null : SECRET_SINGLE_RE.exec(window);
2040
+ const secret = bulk ?? single;
2041
+ if (!secret)
2042
+ continue;
2043
+ const afterSecret = afterVerb + secret.index + secret[0].length;
2044
+ const prep = PREPOSITION_RE.exec(clause.slice(afterSecret, afterSecret + 120));
2045
+ if (!prep)
2046
+ continue;
2047
+ const destStart = afterSecret + prep.index + prep[0].length;
2048
+ const dest = clause.slice(destStart, destStart + 100);
2049
+ const strong = EXFIL_DEST_STRONG_RE.test(dest);
2050
+ // Auth context ("in the Authorization header") only excuses a non-exfil destination.
2051
+ if (!strong && AUTH_CONTEXT_RE.test(clause))
2052
+ continue;
2053
+ const target = hasTarget(dest);
2054
+ const base = { index: verb.index, strong, negated, target };
2055
+ if (strong)
2056
+ return { ...base, severity: 'high', confidence: 0.75 };
2057
+ if (bulk && EXFIL_DEST_ANY_RE.test(dest))
2058
+ return { ...base, severity: 'warning', confidence: 0.6 };
2059
+ if (single && EXFIL_DEST_EXTERNAL_RE.test(dest))
2060
+ return { ...base, severity: 'warning', confidence: 0.6 };
2061
+ }
2062
+ return undefined;
2063
+ }
2064
+ /** Clauses of a line with their offsets: split on terminal punctuation or `;` followed by whitespace. */
2065
+ function clauses(line) {
2066
+ const out = [];
2067
+ let offset = 0;
2068
+ for (const part of line.split(/(?<=[.!?;])\s+/)) {
2069
+ const at = line.indexOf(part, offset);
2070
+ out.push({ text: part, offset: at });
2071
+ offset = at + part.length;
2072
+ }
2073
+ return out;
2074
+ }
2075
+ /** Prose rules — instruction files (SKILL.md, reference docs). */
2076
+ function checkProse(doc, add) {
2077
+ const record = (rule, line, text, r, severity, confidence) => addJudged(add, r.verdict, rule, line, excerpt(text, r.found.index, r.found.match[0].length), severity, confidence);
2078
+ for (const i of doc.active) {
2079
+ const text = doc.lines[i];
2080
+ if (text.length < 8)
2081
+ continue;
2082
+ const line = i + 1;
2083
+ if (OVERRIDE_HINT_RE.test(text)) {
2084
+ const r = directive(doc, i, text, OVERRIDE_PATTERNS, { skipQuoted: true, targetless: true });
2085
+ if (r)
2086
+ record('instruction-override', line, text, r, 'high', 0.8);
2087
+ }
2088
+ if (CONCEAL_HINT_RE.test(text)) {
2089
+ const strong = directive(doc, i, text, CONCEAL_STRONG, { targetless: true });
2090
+ const soft = strong ? undefined : directive(doc, i, text, CONCEAL_SOFT, { negatable: false });
2091
+ if (strong)
2092
+ record('conceal-from-user', line, text, strong, 'high', 0.75);
2093
+ else if (soft)
2094
+ record('conceal-from-user', line, text, soft, 'warning', 0.6);
2095
+ }
2096
+ if (BYPASS_HINT_RE.test(text)) {
2097
+ const r = directive(doc, i, text, BYPASS_PROSE, { targetless: true });
2098
+ if (r)
2099
+ record('permission-bypass', line, text, r, 'warning', 0.6);
2100
+ }
2101
+ if (EXFIL_VERB_PREFILTER_RE.test(text)) {
2102
+ for (const c of clauses(text)) {
2103
+ if (!EXFIL_VERB_PREFILTER_RE.test(c.text))
2104
+ continue;
2105
+ const hit = checkExfil(c.text);
2106
+ if (!hit)
2107
+ continue;
2108
+ const at = c.offset + hit.index;
2109
+ const governed = !doc.fenced[i] && (hit.negated || governedDiscussion(text, at, 10));
2110
+ const verdict = !governed ? 'none' : hit.target ? 'lowconf' : 'skip';
2111
+ addJudged(add, verdict, 'exfil-instruction', line, excerpt(text, at, 60), hit.severity, hit.confidence);
2112
+ break;
2113
+ }
2114
+ }
2115
+ }
2116
+ }
2117
+ /** permission-bypass flags — every file kind. Only a flag-documentation table row is exempt. */
2118
+ function checkBypassFlags(doc, add) {
2119
+ for (const i of doc.active) {
2120
+ const text = doc.lines[i];
2121
+ const m = findFirst(BYPASS_FLAG_RE, text);
2122
+ if (!m)
2123
+ continue;
2124
+ if (doc.kind === 'instructions' && !doc.fenced[i] && isFlagDocRow(text))
2125
+ continue;
2126
+ const len = m.match[0].length;
2127
+ addJudged(add, documentationVerdict(doc, i, text, m.index, len, true), 'permission-bypass', i + 1, () => excerpt(text, m.index, len), 'warning', 0.7);
2128
+ }
2129
+ }
2130
+ // ── hidden-unicode ────────────────────────────────────────────────────────────
2131
+ const RTL_SCRIPT_RE = /[\p{Script=Hebrew}\p{Script=Arabic}\p{Script=Syriac}\p{Script=Thaana}\p{Script=Nko}]/u;
2132
+ const EMOJI_OR_MARK_RE = /\p{Extended_Pictographic}|\p{Emoji_Modifier}|\p{Emoji_Component}/u;
2133
+ const LETTER_OR_MARK_RE = /[\p{L}\p{M}]/u;
2134
+ const MONGOLIAN_RE = /\p{Script=Mongolian}/u;
2135
+ const FORMAT_RE = /\p{Cf}/u;
2136
+ /**
2137
+ * The only characters `hiddenClass` can ever flag: control characters (tab
2138
+ * excepted), every `Cf` format character, the tag block, bidi controls,
2139
+ * variation selectors, Mongolian free variation selectors, Hangul/Khmer
2140
+ * fillers, the combining grapheme joiner and the line/paragraph separators.
2141
+ * A line with none of them (all printable ASCII, accented Latin, CJK, emoji…)
2142
+ * is skipped by a single regex scan, with no per-character work and no
2143
+ * allocation — the common case on a large file.
2144
+ */
2145
+ const HIDDEN_RANGES = [
2146
+ [0x0, 0x8], [0xa, 0x1f], [0x7f, 0x9f], [0x34f, 0x34f], [0x600, 0x605], [0x61c, 0x61c], [0x6dd, 0x6dd], [0x70f, 0x70f],
2147
+ [0x890, 0x891], [0x8e2, 0x8e2], [0x115f, 0x1160], [0x17b4, 0x17b5], [0x180b, 0x180f], [0x200b, 0x200f],
2148
+ [0x2028, 0x202e], [0x2060, 0x2069], [0x3164, 0x3164], [0xfe00, 0xfe0f], [0xfeff, 0xfeff], [0xffa0, 0xffa0],
2149
+ [0xe0000, 0xe007f], [0xe0100, 0xe01ef],
2150
+ ];
2151
+ const regexEscape = (cp) => '\\u{' + cp.toString(16) + '}';
2152
+ const HIDDEN_CANDIDATE_RE = new RegExp('[' + HIDDEN_RANGES.map(([lo, hi]) => regexEscape(lo) + '-' + regexEscape(hi)).join('') + '\\p{Cf}]', 'gu');
2153
+ function isVariationSelector(cp) {
2154
+ return (cp >= 0xfe00 && cp <= 0xfe0f) || (cp >= 0xe0100 && cp <= 0xe01ef);
2155
+ }
2156
+ /** The code point ending right before UTF-16 index `idx`. */
2157
+ function cpBefore(s, idx) {
2158
+ if (idx <= 0)
2159
+ return undefined;
2160
+ const lo = s.charCodeAt(idx - 1);
2161
+ if (lo >= 0xdc00 && lo <= 0xdfff && idx >= 2) {
2162
+ const hi = s.charCodeAt(idx - 2);
2163
+ if (hi >= 0xd800 && hi <= 0xdbff)
2164
+ return (hi - 0xd800) * 0x400 + (lo - 0xdc00) + 0x10000;
2165
+ }
2166
+ return lo;
2167
+ }
2168
+ const asChar = (cp) => (cp === undefined ? undefined : String.fromCodePoint(cp));
2169
+ /**
2170
+ * Classify the character `cp` at UTF-16 index `idx` of `line`: 'severe' (tag
2171
+ * characters, bidi controls), 'mild' (other invisible/format/control
2172
+ * characters), or undefined (invisible in a legitimate context). `tagRunPrev`
2173
+ * is the code point just before the current run of tag characters (tracked by
2174
+ * the caller, so a long run is classified in linear time). Only called for
2175
+ * `HIDDEN_CANDIDATE_RE` matches; neighbours are read straight from the string.
2176
+ */
2177
+ function hiddenClass(line, idx, cp, firstLine, tagRunPrev, hasRtl) {
2178
+ if (cp === 0x09 || (cp >= 0x20 && cp < 0x7f))
2179
+ return undefined;
2180
+ if (cp >= 0xe0000 && cp <= 0xe007f)
2181
+ return tagRunPrev === 0x1f3f4 ? undefined : 'severe'; // subdivision flags
2182
+ if ((cp >= 0x202a && cp <= 0x202e) || (cp >= 0x2066 && cp <= 0x2069))
2183
+ return 'severe';
2184
+ if (cp === 0xfeff && firstLine && idx === 0)
2185
+ return undefined; // BOM
2186
+ const prevCp = cpBefore(line, idx);
2187
+ const nextCp = line.codePointAt(idx + (cp > 0xffff ? 2 : 1));
2188
+ if (cp === 0x200c || cp === 0x200d) {
2189
+ const ctx = (ch) => ch !== undefined && ch > 0x7f && (EMOJI_OR_MARK_RE.test(asChar(ch)) || LETTER_OR_MARK_RE.test(asChar(ch)));
2190
+ return ctx(prevCp) || ctx(nextCp) ? undefined : 'mild';
2191
+ }
2192
+ if (cp === 0x200e || cp === 0x200f || cp === 0x061c || (cp >= 0x0600 && cp <= 0x0605) || cp === 0x06dd ||
2193
+ cp === 0x070f || cp === 0x0890 || cp === 0x0891 || cp === 0x08e2) {
2194
+ return hasRtl() ? undefined : 'mild';
2195
+ }
2196
+ if (isVariationSelector(cp)) {
2197
+ // One selector after a non-ASCII character is an emoji/CJK presentation
2198
+ // choice; a RUN of selectors is how data is smuggled after a character.
2199
+ const lone = nextCp === undefined || !isVariationSelector(nextCp);
2200
+ return prevCp !== undefined && prevCp > 0x7f && !isVariationSelector(prevCp) && lone ? undefined : 'mild';
2201
+ }
2202
+ if (cp >= 0x180b && cp <= 0x180f && cp !== 0x180e) {
2203
+ return prevCp !== undefined && MONGOLIAN_RE.test(asChar(prevCp)) ? undefined : 'mild';
2204
+ }
2205
+ if (cp === 0x115f || cp === 0x1160) {
2206
+ const jamo = (ch) => ch !== undefined && ch >= 0x1100 && ch <= 0x11ff;
2207
+ return jamo(prevCp) || jamo(nextCp) ? undefined : 'mild';
2208
+ }
2209
+ if (cp === 0x3164 || cp === 0xffa0 || cp === 0x034f || cp === 0x17b4 || cp === 0x17b5 || cp === 0x2028 || cp === 0x2029) {
2210
+ return 'mild';
2211
+ }
2212
+ if (cp < 0x20 || (cp >= 0x7f && cp <= 0x9f))
2213
+ return 'mild'; // control characters (tab excepted above)
2214
+ if (FORMAT_RE.test(String.fromCodePoint(cp)))
2215
+ return 'mild'; // zero-width, soft hyphen, invisible operators, …
2216
+ return undefined;
2217
+ }
2218
+ /**
2219
+ * hidden-unicode — every file kind. Every line is examined: a severe payload
2220
+ * after many mild lines still counts. One pass: a global regex jumps straight
2221
+ * to each candidate character, so a line without any costs one scan, and the
2222
+ * evidence text is only rendered for hits the collector keeps.
2223
+ */
2224
+ function checkHiddenUnicode(doc, add) {
2225
+ for (const i of doc.active) {
2226
+ const lineText = doc.lines[i];
2227
+ HIDDEN_CANDIDATE_RE.lastIndex = 0;
2228
+ let m = HIDDEN_CANDIDATE_RE.exec(lineText);
2229
+ if (m === null)
2230
+ continue; // fast path: nothing here can hide anything
2231
+ let rtl;
2232
+ const hasRtl = () => (rtl ??= RTL_SCRIPT_RE.test(lineText));
2233
+ const found = new Map();
2234
+ let severe = false;
2235
+ let firstOffset = -1;
2236
+ let severeOffset = -1;
2237
+ let tagRunPrev;
2238
+ while (m !== null) {
2239
+ const idx = m.index;
2240
+ const cp = lineText.codePointAt(idx);
2241
+ if (cp >= 0xe0000 && cp <= 0xe007f) {
2242
+ const prev = cpBefore(lineText, idx);
2243
+ if (prev === undefined || prev < 0xe0000 || prev > 0xe007f)
2244
+ tagRunPrev = prev; // a new run of tag characters
2245
+ }
2246
+ const cls = hiddenClass(lineText, idx, cp, i === 0, tagRunPrev, hasRtl);
2247
+ if (cls) {
2248
+ if (cls === 'severe') {
2249
+ severe = true;
2250
+ if (severeOffset < 0)
2251
+ severeOffset = idx;
2252
+ }
2253
+ found.set(cp, (found.get(cp) ?? 0) + 1);
2254
+ if (firstOffset < 0)
2255
+ firstOffset = idx;
2256
+ }
2257
+ m = HIDDEN_CANDIDATE_RE.exec(lineText);
2258
+ }
2259
+ if (found.size === 0)
2260
+ continue;
2261
+ add('hidden-unicode', i + 1, () => {
2262
+ const counts = [...found.entries()]
2263
+ .sort((a, b) => a[0] - b[0])
2264
+ .slice(0, 4)
2265
+ .map(([cp, n]) => `${codepointLabel(cp)}×${n}`)
2266
+ .join(' ');
2267
+ const more = found.size > 4 ? ` (+${found.size - 4} more kinds)` : '';
2268
+ return `${counts}${more} in: ${excerpt(lineText, severe ? severeOffset : firstOffset, 1)}`;
2269
+ }, severe ? 'high' : 'warning', severe ? 0.9 : 0.7);
2270
+ }
2271
+ }
2272
+ // ── hidden markup ─────────────────────────────────────────────────────────────
2273
+ /** 1-based line of a character offset, via binary search over precomputed line starts. */
2274
+ function lineAt(lineStarts, offset) {
2275
+ let lo = 0;
2276
+ let hi = lineStarts.length - 1;
2277
+ while (lo < hi) {
2278
+ const mid = (lo + hi + 1) >> 1;
2279
+ if (lineStarts[mid] <= offset)
2280
+ lo = mid;
2281
+ else
2282
+ hi = mid - 1;
2283
+ }
2284
+ return lo + 1;
2285
+ }
2286
+ /**
2287
+ * Hidden HTML elements, found with a single left-to-right walk over `<`: a
2288
+ * candidate tag runs to the next `>` unless another `<` comes first, so each
2289
+ * character is visited a bounded number of times however the tags nest.
2290
+ */
2291
+ function hiddenElementAt(text) {
2292
+ let pos = 0;
2293
+ let gt = -1; // cached position of the next `>` — each `>` is searched for once
2294
+ while (pos < text.length) {
2295
+ const lt = text.indexOf('<', pos);
2296
+ if (lt < 0)
2297
+ return undefined;
2298
+ if (gt !== text.length && gt <= lt) {
2299
+ gt = text.indexOf('>', lt + 1);
2300
+ if (gt < 0)
2301
+ gt = text.length;
2302
+ }
2303
+ const end = gt;
2304
+ const nextLt = text.indexOf('<', lt + 1);
2305
+ if (nextLt >= 0 && nextLt < end) {
2306
+ pos = nextLt;
2307
+ continue;
2308
+ }
2309
+ pos = end + 1;
2310
+ if (end - lt > 600 || !/^<[a-z]/i.test(text.slice(lt, lt + 2)))
2311
+ continue;
2312
+ const tag = text.slice(lt, end);
2313
+ if (HIDDEN_STYLE_RE.test(tag) || HIDDEN_ATTR_RE.test(tag))
2314
+ return { index: lt, match: fakeMatch(text, lt, end + 1) };
2315
+ }
2316
+ return undefined;
2317
+ }
2318
+ /** hidden-html-comment, hidden-html-element, encoded-blob — instruction files. */
2319
+ function checkHiddenMarkup(doc, add) {
2320
+ if (doc.content.includes('<!--'))
2321
+ checkHiddenComments(doc, add);
2322
+ for (const i of doc.active) {
2323
+ if (doc.fenced[i])
2324
+ continue;
2325
+ const text = doc.lines[i];
2326
+ if (!text.includes('<') || !/style|hidden/i.test(text))
2327
+ continue;
2328
+ const el = hiddenElementAt(text);
2329
+ if (el)
2330
+ add('hidden-html-element', i + 1, excerpt(text, el.index, el.match[0].length), 'warning', 0.6);
2331
+ }
2332
+ for (const i of doc.active) {
2333
+ const text = doc.lines[i];
2334
+ if (text.length < 200)
2335
+ continue;
2336
+ const blob = findAll(BLOB_RE, text, 5).find((b) => {
2337
+ const s = b.match[0];
2338
+ if (/(?:base64,|data:[^,\s]{0,100},)\s{0,5}$/i.test(text.slice(Math.max(0, b.index - 120), b.index)))
2339
+ return false; // data: URI
2340
+ if (!/[0-9]/.test(s) || !/[A-Z]/.test(s) || !/[a-z]/.test(s))
2341
+ return false;
2342
+ return (s.match(/\//g)?.length ?? 0) <= s.length / 10; // more slashes = a long path, not a payload
2343
+ });
2344
+ if (blob)
2345
+ add('encoded-blob', i + 1, `${blob.match[0].length}-char encoded string: ${blob.match[0].slice(0, 40)}…`, 'warning', 0.5);
2346
+ }
2347
+ }
2348
+ function checkHiddenComments(doc, add) {
2349
+ // Fenced code blocks blanked out, line count preserved.
2350
+ const blankedLines = doc.lines.map((l, i) => (doc.fenced[i] ? '' : l));
2351
+ const blanked = blankedLines.join('\n');
2352
+ const lineStarts = [0];
2353
+ for (let i = 0; i < blankedLines.length - 1; i++)
2354
+ lineStarts.push(lineStarts[i] + blankedLines[i].length + 1);
2355
+ // indexOf walk — no regex over the whole file, and an unclosed `<!--` hides the rest of the file.
2356
+ let pos = 0;
2357
+ while (pos < blanked.length) {
2358
+ const start = blanked.indexOf('<!--', pos);
2359
+ if (start < 0)
2360
+ break;
2361
+ const close = blanked.indexOf('-->', start + 4);
2362
+ const bodyEnd = close < 0 ? blanked.length : close;
2363
+ pos = close < 0 ? blanked.length : close + 3;
2364
+ const body = blanked.slice(start + 4, bodyEnd);
2365
+ // A tooling directive or TODO is exempt only when the WHOLE comment is short and plain.
2366
+ if (BENIGN_COMMENT_RE.test(body.slice(0, 200)) && body.length <= 120 &&
2367
+ !STRONG_COMMENT_RE.test(body) && !COMMENT_DIRECTIVE_RE.test(body))
2368
+ continue;
2369
+ const hit = findFirst(AGENT_DIRECTED_RE, body);
2370
+ if (!hit)
2371
+ continue;
2372
+ const line = lineAt(lineStarts, start + 4 + hit.index);
2373
+ const lineText = doc.lines[line - 1] ?? '';
2374
+ const col = start + 4 + hit.index - lineStarts[line - 1];
2375
+ add('hidden-html-comment', line, () => excerpt(lineText, col, hit.match[0].length), 'warning', 0.6);
2376
+ }
2377
+ }
2378
+ // ── unpinned-remote-dep ───────────────────────────────────────────────────────
2379
+ /**
2380
+ * The text a reader is meant to *run*: every logical line of a script or config
2381
+ * file, but only fenced blocks and inline `code spans` of an instruction file
2382
+ * (prose such as "npm install and then run tests/foo.js" is not a command).
2383
+ * An inline span governed by a negation/discussion word is documentation.
2384
+ */
2385
+ function commandTexts(doc, logical) {
2386
+ if (doc.kind !== 'instructions')
2387
+ return logical;
2388
+ const out = [];
2389
+ for (const l of logical) {
2390
+ if (doc.fenced[l.line - 1]) {
2391
+ out.push(l);
2392
+ continue;
2393
+ }
2394
+ if (!l.text.includes('`'))
2395
+ continue;
2396
+ for (const span of findAll(/`([^`\n]{1,1000})`/g, l.text, 50)) {
2397
+ if (documentationVerdict(doc, l.line - 1, l.text, span.index, span.match[0].length) === 'skip')
2398
+ continue;
2399
+ out.push({ text: span.match[1], line: l.line });
2400
+ }
2401
+ }
2402
+ return out;
2403
+ }
2404
+ /** unpinned-remote-dep — command text in every file, plus bundled manifests. */
2405
+ /** unpinned-remote-dep; for a manifest also its install scripts. Returns package `bin` targets to scan. */
2406
+ function checkUnpinned(doc, commands, consumed, add) {
2407
+ const hit = (line, text, index, confidence = 0.6) => add('unpinned-remote-dep', line, excerpt(text, index, 80), 'warning', confidence);
2408
+ if (doc.kind === 'manifest')
2409
+ return checkManifest(doc, hit, add);
2410
+ for (const { text, line } of commands) {
2411
+ if (consumed.has(line))
2412
+ continue;
2413
+ if (INSTALL_CTX_RE.test(text)) {
2414
+ const unpinned = findAll(GIT_SOURCE_RE, text, 10).find((g) => !isPinnedRef(gitSourceRef(g.match[0])));
2415
+ if (unpinned) {
2416
+ hit(line, text, unpinned.index);
2417
+ continue;
2418
+ }
2419
+ }
2420
+ const latest = latestInstall(text);
2421
+ if (latest) {
2422
+ hit(line, text, latest.index, 0.5);
2423
+ continue;
2424
+ }
2425
+ const npm = findFirst(NPM_INSTALL_RE, text);
2426
+ if (npm) {
2427
+ const tokens = (npm.match[1] ?? '').trim().split(/\s+/).filter((t) => t && !t.startsWith('-'));
2428
+ const bad = tokens.find((t) => !t.startsWith('@') && !t.startsWith('.') && !t.startsWith('/') && !t.startsWith('file:') &&
2429
+ !/\.(?:js|mjs|cjs|ts|json|md|sh|py|txt)$/i.test(t) &&
2430
+ (/^[A-Za-z0-9][\w.-]{0,100}\/[\w.-]{1,100}(?:#\S{0,100})?$/.test(t) || /^https?:\/\//i.test(t)) &&
2431
+ isUnpinnedRemoteSpec(t));
2432
+ if (bad) {
2433
+ hit(line, text, npm.index);
2434
+ continue;
2435
+ }
2436
+ }
2437
+ const cargo = findFirst(CARGO_GIT_RE, text);
2438
+ if (cargo && !/--(?:rev|tag)\s{1,5}\S/.test(text)) {
2439
+ hit(line, text, cargo.index);
2440
+ continue;
2441
+ }
2442
+ const url = findFirst(MUTABLE_SCRIPT_URL_RE, text);
2443
+ if (url && findFirst(FETCH_VERB_RE, text) && SCRIPTISH_URL_RE.test(url.match[0])) {
2444
+ hit(line, text, url.index);
2445
+ continue;
2446
+ }
2447
+ if (doc.kind === 'script' && doc.lang === 'js') {
2448
+ const imp = findAll(REMOTE_IMPORT_RE, text, 10)
2449
+ .find((m) => !/@v?\d+(?:\.\d+)*|@[0-9a-f]{7,40}|\/v?\d+\.\d+\.\d+\//i.test(m.match[1]));
2450
+ if (imp)
2451
+ hit(line, text, imp.index);
2452
+ }
2453
+ }
2454
+ return [];
2455
+ }
2456
+ /**
2457
+ * package.json lifecycle/`scripts` commands run on install: each is checked
2458
+ * with the exec rules. `bin` targets are returned so the caller can scan them
2459
+ * as scripts (the shallow scan of a dependency package).
2460
+ */
2461
+ function checkPackageScripts(doc, parsed, add) {
2462
+ const scripts = parsed.scripts;
2463
+ if (typeof scripts === 'object' && scripts !== null) {
2464
+ for (const [name, cmd] of Object.entries(scripts).slice(0, 100)) {
2465
+ if (typeof cmd !== 'string')
2466
+ continue;
2467
+ const chained = new ChainScanner().feed(cmd, 1, true);
2468
+ for (const { rule, found } of [...execMatches(cmd, true), ...(chained ? [chained] : [])]) {
2469
+ add(rule, lineOf(doc.content, `"${name}"`), `scripts.${visible(name)}: ${excerpt(cmd, found.index, found.match[0].length)}`, 'high', 0.85);
2470
+ }
2471
+ }
2472
+ }
2473
+ const bin = parsed.bin;
2474
+ const targets = typeof bin === 'string' ? [bin] : typeof bin === 'object' && bin !== null
2475
+ ? Object.values(bin).filter((v) => typeof v === 'string')
2476
+ : [];
2477
+ const dir = path.posix.dirname(doc.path);
2478
+ return targets.slice(0, 5)
2479
+ .map((t) => path.posix.normalize(`${dir}/${t.replace(/\\/g, '/')}`))
2480
+ .filter((t) => t.startsWith(`${dir}/`));
2481
+ }
2482
+ function checkManifest(doc, hit, add) {
2483
+ const base = path.posix.basename(doc.path);
2484
+ if (base === 'package.json') {
2485
+ let parsed;
2486
+ try {
2487
+ parsed = JSON.parse(doc.content);
2488
+ }
2489
+ catch {
2490
+ return [];
2491
+ }
2492
+ if (typeof parsed !== 'object' || parsed === null)
2493
+ return [];
2494
+ const bins = checkPackageScripts(doc, parsed, add);
2495
+ let flagged = 0;
2496
+ for (const section of ['dependencies', 'devDependencies', 'optionalDependencies', 'peerDependencies']) {
2497
+ const deps = parsed[section];
2498
+ if (typeof deps !== 'object' || deps === null)
2499
+ continue;
2500
+ for (const [name, spec] of Object.entries(deps)) {
2501
+ if (typeof spec !== 'string')
2502
+ continue;
2503
+ const s = spec.trim();
2504
+ if (s === '' || s === '*' || s === 'latest' || isUnpinnedRemoteSpec(s)) {
2505
+ if (++flagged > MAX_HITS_PER_RULE_FILE)
2506
+ return bins;
2507
+ const line = lineOf(doc.content, `"${name}"`);
2508
+ hit(line, doc.lines[line - 1] ?? `"${name}": "${spec}"`, 0);
2509
+ }
2510
+ }
2511
+ }
2512
+ return bins;
2513
+ }
2514
+ // requirements*.txt
2515
+ for (const i of doc.active) {
2516
+ const text = doc.lines[i].replace(/\s#.*$/, '');
2517
+ const g = findFirst(/\bgit\+[a-z]{1,10}:\/\/\S{1,500}/i, text);
2518
+ if (g && !isPinnedRef(gitSourceRef(g.match[0]))) {
2519
+ hit(i + 1, text, g.index);
2520
+ continue;
2521
+ }
2522
+ const u = findFirst(/\bhttps?:\/\/\S{1,500}?\.(?:zip|tar\.gz|tgz|whl)\S{0,200}/i, text);
2523
+ if (u && !/\d+\.\d+|#sha256=/i.test(u.match[0]))
2524
+ hit(i + 1, text, u.index);
2525
+ }
2526
+ return [];
2527
+ }
2528
+ // ── missing-script ────────────────────────────────────────────────────────────
2529
+ /**
2530
+ * missing-script — script invocations in the SKILL.md that resolve nowhere.
2531
+ * Bundled reference docs are not checked: they are where skills keep worked
2532
+ * examples of scripts the *reader* would write, which never exist.
2533
+ */
2534
+ function checkMissingScripts(repoRoot, skillDir, doc, alreadyReported, add) {
2535
+ const pluginRoot = /^(.*?)\/?skills\/[^/]+$/.exec(skillDir)?.[1];
2536
+ const resolveCandidates = (raw) => {
2537
+ let ref = raw;
2538
+ let bases;
2539
+ const varMatch = /^\$\{?([A-Za-z_][A-Za-z0-9_]*)\}?\/(.*)$/.exec(ref);
2540
+ if (varMatch) {
2541
+ const name = varMatch[1];
2542
+ ref = varMatch[2];
2543
+ if (name === 'CLAUDE_SKILL_DIR' || name === 'SKILL_DIR')
2544
+ bases = [skillDir];
2545
+ else if (name === 'CLAUDE_PLUGIN_ROOT' && pluginRoot !== undefined)
2546
+ bases = [pluginRoot || '.'];
2547
+ else if (name === 'CLAUDE_PROJECT_DIR')
2548
+ bases = ['.'];
2549
+ else
2550
+ return undefined; // an unknown variable — cannot resolve statically
2551
+ }
2552
+ else if (ref.startsWith('{baseDir}/')) {
2553
+ ref = ref.slice('{baseDir}/'.length);
2554
+ bases = [skillDir];
2555
+ }
2556
+ else {
2557
+ // A bare `manage.py` usually names a file in the *user's* project, not the
2558
+ // skill — only paths with a directory segment are checked.
2559
+ ref = ref.replace(/^\.\//, '');
2560
+ if (!ref.includes('/'))
2561
+ return undefined;
2562
+ bases = [...new Set([skillDir, ...(pluginRoot ? [pluginRoot] : []), '.'])];
2563
+ }
2564
+ if (!ref || ref.includes('..') || ref.startsWith('/'))
2565
+ return undefined;
2566
+ return bases.map((b) => (b === '.' ? ref : `${b}/${ref}`));
2567
+ };
2568
+ // Filesystem checks are cached per path and capped per document, and the
2569
+ // rule stops after its evidence cap: a SKILL.md with 28k distinct script
2570
+ // references must not turn into 28k × 3 stat calls.
2571
+ const exists = new Map();
2572
+ let fsChecks = 0;
2573
+ let found = 0;
2574
+ const fileExists = (p) => {
2575
+ const cached = exists.get(p);
2576
+ if (cached !== undefined)
2577
+ return cached;
2578
+ if (fsChecks >= MAX_FS_CHECKS_PER_DOC)
2579
+ return undefined;
2580
+ fsChecks++;
2581
+ const result = isFileWithinRoot(repoRoot, p);
2582
+ exists.set(p, result);
2583
+ return result;
2584
+ };
2585
+ for (const i of doc.active) {
2586
+ const text = doc.lines[i];
2587
+ // A `"command": "bash …"` inside a JSON/YAML example is sample config, not an instruction to run.
2588
+ if (doc.fenced[i] && CONFIG_FENCE_LANGS.has(doc.fenceLang.get(i) ?? ''))
2589
+ continue;
2590
+ if (!SCRIPT_EXT_HINT_RE.test(text))
2591
+ continue;
2592
+ for (const re of [INVOKE_RE, DIRECT_EXEC_RE]) {
2593
+ for (const m of findAll(re, text, 10)) {
2594
+ const raw = m.match[1];
2595
+ const bare = raw.replace(/^\.\//, '');
2596
+ const candidates = resolveCandidates(raw);
2597
+ if (!candidates)
2598
+ continue;
2599
+ let anyExists = false;
2600
+ let unknown = false;
2601
+ for (const c of candidates) {
2602
+ const e = fileExists(c);
2603
+ if (e === undefined)
2604
+ unknown = true;
2605
+ else if (e) {
2606
+ anyExists = true;
2607
+ break;
2608
+ }
2609
+ }
2610
+ if (anyExists)
2611
+ continue;
2612
+ if (unknown)
2613
+ return; // the per-document check budget is spent — stop rather than guess
2614
+ if (alreadyReported().has(bare) || alreadyReported().has(raw))
2615
+ continue;
2616
+ add('missing-script', i + 1, `${visible(raw)} not found (looked in: ${candidates.map(visible).join(', ')})`, 'warning', 0.6);
2617
+ if (++found >= MAX_HITS_PER_RULE_FILE)
2618
+ return;
2619
+ }
2620
+ }
2621
+ }
2622
+ }
2623
+ /** Filesystem existence checks per document for missing-script. */
2624
+ const MAX_FS_CHECKS_PER_DOC = 200;
2625
+ /** Total lines examined per scan: bounds per-line work however the bytes are shaped (e.g. megabytes of newlines). */
2626
+ export const MAX_SCAN_LINES = 3_000_000;
2627
+ function readRange(fd, position, length) {
2628
+ const buf = Buffer.alloc(length);
2629
+ const read = fs.readSync(fd, buf, 0, length, position);
2630
+ return buf.subarray(0, read);
2631
+ }
2632
+ function makeDoc(relPath, cls, content, isSkillMd, tail) {
2633
+ const lines = content.split(/\r?\n/);
2634
+ const active = [];
2635
+ for (let i = 0; i < lines.length; i++) {
2636
+ const l = lines[i];
2637
+ // Blank lines carry nothing any rule looks at; iterating only the rest keeps
2638
+ // a file of 500k empty lines as cheap as an empty file.
2639
+ // Only ASCII spaces/tabs count as blank — a line of invisible characters is exactly what hidden-unicode wants.
2640
+ if (l.length > 0 && NOT_BLANK_RE.test(l))
2641
+ active.push(i);
2642
+ }
2643
+ const fenced = new Uint8Array(lines.length);
2644
+ const fenceLang = new Map();
2645
+ // Fence tracking only when the document has a fence marker at all.
2646
+ if (cls.kind === 'instructions' && (content.includes('```') || content.includes('~~~'))) {
2647
+ const fence = scanFencedLines(content.replace(/\r\n/g, '\n'));
2648
+ for (let i = 0; i < lines.length; i++) {
2649
+ const f = fence[i];
2650
+ if (f?.inFence) {
2651
+ fenced[i] = 1;
2652
+ if (f.lang)
2653
+ fenceLang.set(i, f.lang);
2654
+ }
2655
+ }
2656
+ }
2657
+ return {
2658
+ path: relPath,
2659
+ kind: cls.kind,
2660
+ lang: cls.lang,
2661
+ content,
2662
+ lines,
2663
+ active,
2664
+ fenced,
2665
+ fenceLang,
2666
+ frontmatterEnd: isSkillMd && !tail ? frontmatterEnd(lines) : 0,
2667
+ tail,
2668
+ };
2669
+ }
2670
+ const NOT_BLANK_RE = /[^ \t\r]/;
2671
+ function countLines(buf) {
2672
+ let n = 1;
2673
+ for (let i = buf.indexOf(10); i >= 0; i = buf.indexOf(10, i + 1))
2674
+ n++;
2675
+ return n;
2676
+ }
2677
+ /**
2678
+ * Load one file as 1–2 docs (head, and tail when larger than 1 MB), or say
2679
+ * why it was not read. Oversized files are read at their first AND last 500 KB
2680
+ * so padding a payload past the head does not hide it; the middle is reported.
2681
+ * A priority file (SKILL.md, script, executable) the budget cannot cover is
2682
+ * reported at HIGH — the scan fails closed rather than passing it silently.
2683
+ */
2684
+ function loadDocs(repoRoot, relPath, isSkillMd, priority, budget) {
2685
+ const budgetSeverity = priority ? 'high' : 'warning';
2686
+ let cls = isSkillMd ? { kind: 'instructions', lang: 'other' } : classifyByName(relPath);
2687
+ if (typeof cls === 'object' && 'skip' in cls)
2688
+ return { docs: [], unscanned: [] };
2689
+ if (typeof cls === 'object' && 'report' in cls) {
2690
+ return { docs: [], unscanned: [{ path: relPath, reason: cls.report, severity: cls.severity }] };
2691
+ }
2692
+ const abs = resolveWithinRoot(repoRoot, relPath);
2693
+ if (!abs)
2694
+ return { docs: [], unscanned: [] };
2695
+ if (!realPathWithinRoot(repoRoot, abs)) {
2696
+ return { docs: [], unscanned: [{ path: relPath, reason: 'symlink to a location outside the repository — not read', severity: 'warning' }] };
2697
+ }
2698
+ let fd;
2699
+ try {
2700
+ const stat = fs.statSync(abs);
2701
+ if (!stat.isFile())
2702
+ return { docs: [], unscanned: [] };
2703
+ const size = stat.size;
2704
+ if (budget.files <= 0 || budget.bytes <= 0 || budget.lines <= 0) {
2705
+ return { docs: [], unscanned: [{ path: relPath, reason: 'per-scan budget exhausted — not scanned', severity: budgetSeverity }] };
2706
+ }
2707
+ fd = fs.openSync(abs, 'r');
2708
+ if (cls === 'probe') {
2709
+ const head = readRange(fd, 0, Math.min(size, BINARY_CHECK_BYTES));
2710
+ if (isBinary(head)) {
2711
+ return { docs: [], unscanned: [{ path: relPath, reason: `${binaryKind(head)} — cannot be reviewed as text`, severity: 'warning' }] };
2712
+ }
2713
+ const lang = langFromShebang(head.toString('utf8').split(/\r?\n/, 1)[0] ?? '');
2714
+ cls = lang ? { kind: 'script', lang } : { kind: 'text', lang: 'other' };
2715
+ }
2716
+ const classified = cls;
2717
+ const unscanned = classified.note ? [{ path: relPath, reason: classified.note.reason, severity: classified.note.severity }] : [];
2718
+ budget.files--;
2719
+ const take = (position, length, tail) => {
2720
+ const buf = readRange(fd, position, length);
2721
+ budget.bytes -= buf.length;
2722
+ budget.lines -= countLines(buf);
2723
+ return makeDoc(relPath, classified, buf.toString('utf8'), isSkillMd && !tail, tail);
2724
+ };
2725
+ // Text files are read even with NUL bytes: a stray NUL must not hide a file (hidden-unicode reports it).
2726
+ if (size <= 2 * MAX_FILE_SIZE) {
2727
+ const want = Math.min(size, budget.bytes);
2728
+ const docs = [take(0, want, false)];
2729
+ if (want < size)
2730
+ unscanned.push({ path: relPath, reason: 'per-scan budget exhausted part-way — the rest was not scanned', severity: budgetSeverity });
2731
+ return { docs, unscanned };
2732
+ }
2733
+ const docs = [take(0, Math.min(MAX_FILE_SIZE, budget.bytes), false)];
2734
+ const tailLen = Math.min(MAX_FILE_SIZE, budget.bytes);
2735
+ if (tailLen > 0)
2736
+ docs.push(take(size - tailLen, tailLen, true));
2737
+ unscanned.push({
2738
+ path: relPath,
2739
+ reason: `larger than 1 MB (${Math.round(size / 1024)} KB) — only the first and last 500 KB were scanned`,
2740
+ severity: tailLen > 0 ? 'warning' : budgetSeverity,
2741
+ });
2742
+ return { docs, unscanned };
2743
+ }
2744
+ catch {
2745
+ return { docs: [], unscanned: [] };
2746
+ }
2747
+ finally {
2748
+ if (fd !== undefined)
2749
+ fs.closeSync(fd);
2750
+ }
2751
+ }
2752
+ // ── Detector ──────────────────────────────────────────────────────────────────
2753
+ /** Scan one document (dropped by the caller right after). Returns package `bin` targets to scan next. */
2754
+ function scanDoc(repoRoot, skillDir, doc, isSkillMd, alreadyReported, collector, unscanned) {
2755
+ const add = (rule, line, excerptText, severity, confidence) => {
2756
+ if (!collector.accepts(rule, doc.path, severity, confidence)) {
2757
+ collector.count(rule, doc.path);
2758
+ return;
2759
+ }
2760
+ const where = doc.tail ? '(end of file) ' : doc.frontmatterEnd > 0 && line <= doc.frontmatterEnd ? '(frontmatter) ' : '';
2761
+ collector.add(rule, doc.path, { line: doc.tail ? undefined : line, excerpt: `${where}${render(excerptText)}`, severity, confidence });
2762
+ };
2763
+ checkHiddenUnicode(doc, add);
2764
+ checkBypassFlags(doc, add);
2765
+ if (doc.kind === 'manifest')
2766
+ return checkUnpinned(doc, [], new Set(), add);
2767
+ const { lines: logical, capped } = logicalLines(doc);
2768
+ if (capped.length > 0) {
2769
+ unscanned.push({
2770
+ path: doc.path,
2771
+ reason: `continuation chain longer than ${MAX_JOINED_LINES} lines / ${MAX_JOINED_CHARS} characters at line ${capped[0]} — split for scanning ` +
2772
+ `(a download-fed loop body is followed up to ${MAX_LOOP_JOINED_LINES} lines / ${MAX_LOOP_JOINED_CHARS} characters)`,
2773
+ severity: 'warning',
2774
+ });
2775
+ }
2776
+ const commands = commandTexts(doc, logical);
2777
+ // A checksum manifest counts only if it is a real, reviewable file in the repo (or fetched in the chain).
2778
+ const manifestExists = (name) => {
2779
+ const rel = name.replace(/^\.\//, '');
2780
+ if (!rel || rel.startsWith('/') || /^[a-z]:/i.test(rel))
2781
+ return false;
2782
+ return [`${path.posix.dirname(doc.path)}/${rel}`, `${skillDir}/${rel}`, rel].some((p) => isFileWithinRoot(repoRoot, p));
2783
+ };
2784
+ const consumed = checkExec(doc, logical, commands, add, manifestExists);
2785
+ checkUnpinned(doc, commands, consumed, add);
2786
+ if (doc.kind === 'instructions') {
2787
+ checkProse(doc, add);
2788
+ checkHiddenMarkup(doc, add);
2789
+ if (isSkillMd && !doc.tail)
2790
+ checkMissingScripts(repoRoot, skillDir, doc, alreadyReported, add);
2791
+ }
2792
+ else {
2793
+ checkScript(doc, consumed, add);
2794
+ }
2795
+ return [];
2796
+ }
2797
+ /** Dependency/VCS directory names a skill might reference by path. */
2798
+ const DEPENDENCY_DIR_NAMES = ['node_modules', '.git', 'worktrees', '.worktrees'];
2799
+ /**
2800
+ * A path reference into a dependency/VCS directory: the name as a path segment
2801
+ * (`node node_modules/evil/index.js`, `./.git/hooks/x`), not a substring of
2802
+ * something else (`repo.git/`).
2803
+ */
2804
+ const DEPENDENCY_REF_RE = {
2805
+ node_modules: /(?:^|[\s'"`(=:/])node_modules\//m,
2806
+ '.git': /(?:^|[\s'"`(=:])(?:\.\/)?\.git\//m,
2807
+ worktrees: /(?:^|[\s'"`(=:])(?:\.\/)?worktrees\//m,
2808
+ '.worktrees': /(?:^|[\s'"`(=:])(?:\.\/)?\.worktrees\//m,
2809
+ };
2810
+ /** Non-sample hook files under a skill's `.git/hooks`. */
2811
+ function hasGitHooks(repoRoot, gitDir) {
2812
+ const abs = resolveWithinRoot(repoRoot, `${gitDir}/hooks`);
2813
+ if (!abs)
2814
+ return false;
2815
+ try {
2816
+ return fs.readdirSync(abs).some((f) => !f.endsWith('.sample'));
2817
+ }
2818
+ catch {
2819
+ return false;
2820
+ }
2821
+ }
2822
+ function skipReason(skip, referenced, repoRoot) {
2823
+ switch (skip.kind) {
2824
+ case 'dependency-dir': {
2825
+ const name = path.posix.basename(skip.path);
2826
+ const read = name === 'node_modules' ? ' (only package manifests and their bin targets were read)' : '';
2827
+ // Code the skill runs from an unscanned directory is unreviewed executable content: fail closed.
2828
+ if (referenced.has(name)) {
2829
+ return { path: skip.path, reason: `dependency/VCS directory the skill runs or references by path — not walked${read}`, severity: 'high' };
2830
+ }
2831
+ if (name === '.git' && hasGitHooks(repoRoot, skip.path)) {
2832
+ return { path: skip.path, reason: 'git directory with hooks — hooks run on git operations and were not scanned', severity: 'warning' };
2833
+ }
2834
+ return { path: skip.path, reason: `dependency/VCS directory — not walked${read}`, severity: 'info' };
2835
+ }
2836
+ case 'include':
2837
+ return { path: skip.path, reason: `${skip.count ?? 0} file(s) outside the configured include patterns — not scanned`, severity: 'info' };
2838
+ default:
2839
+ return { path: skip.path, reason: `bundle too large to list — ${skip.count ?? 'more'} further file(s) not scanned`, severity: 'warning' };
2840
+ }
2841
+ }
2842
+ export function detectSkillSupplyChain(context, limits = {}) {
2843
+ // `allSkills`/`skillFiles` are optional for hand-built contexts from older releases.
2844
+ const ai = context.aiConfig ?? {};
2845
+ const allSkills = ai.allSkills ?? ai.skills ?? [];
2846
+ const audited = new Set(ai.skills ?? []);
2847
+ const dirs = allSkills.map((s) => path.posix.dirname(s));
2848
+ const owner = (p) => {
2849
+ let best = -1;
2850
+ dirs.forEach((dir, idx) => {
2851
+ if (isSkillContainerDir(dir) || !(p === dir || p.startsWith(`${dir}/`)))
2852
+ return;
2853
+ if (best < 0 || dir.length > dirs[best].length)
2854
+ best = idx;
2855
+ });
2856
+ return best;
2857
+ };
2858
+ const states = allSkills.map((skillMd, idx) => {
2859
+ const state = {
2860
+ skillMd,
2861
+ dir: dirs[idx],
2862
+ collector: new HitCollector(),
2863
+ unscanned: [],
2864
+ alreadyReported: () => new Set(),
2865
+ referenced: new Set(),
2866
+ queue: { priority: [], rest: [] },
2867
+ loaded: false,
2868
+ };
2869
+ // Refs the structural detector already flags when missing are not reported twice
2870
+ // (computed lazily, only once a missing script is actually found).
2871
+ if (audited.has(skillMd)) {
2872
+ let refs;
2873
+ state.alreadyReported = () => (refs ??= new Set(extractFileRefs(state.headContent ?? '').map((r) => r.ref)));
2874
+ }
2875
+ return state;
2876
+ });
2877
+ for (const file of ai.skillFiles ?? []) {
2878
+ const idx = owner(file);
2879
+ if (idx >= 0)
2880
+ (isPriorityFile(file) ? states[idx].queue.priority : states[idx].queue.rest).push(file);
2881
+ }
2882
+ for (const file of ai.skillFilesOverCap ?? []) {
2883
+ const idx = owner(file);
2884
+ if (idx >= 0) {
2885
+ states[idx].unscanned.push({
2886
+ path: file,
2887
+ reason: 'over the per-skill file cap — not scanned',
2888
+ severity: isPriorityFile(file) ? 'high' : 'warning',
2889
+ });
2890
+ }
2891
+ }
2892
+ const budget = {
2893
+ bytes: limits.bytes ?? MAX_SCAN_BYTES,
2894
+ files: limits.files ?? MAX_SCAN_FILES,
2895
+ lines: limits.lines ?? MAX_SCAN_LINES,
2896
+ };
2897
+ const noteReferences = (state, doc) => {
2898
+ if (doc.kind !== 'instructions' && doc.kind !== 'script' && !(doc.kind === 'text' && doc.lang === 'sh'))
2899
+ return;
2900
+ for (const name of DEPENDENCY_DIR_NAMES)
2901
+ if (DEPENDENCY_REF_RE[name].test(doc.content))
2902
+ state.referenced.add(name);
2903
+ };
2904
+ const visited = new Set();
2905
+ const scanFile = (state, file, isSkillMd, priority) => {
2906
+ if (visited.has(file))
2907
+ return { docs: [], unscanned: [] };
2908
+ visited.add(file);
2909
+ const loaded = loadDocs(context.repoRoot, file, isSkillMd, priority, budget);
2910
+ state.unscanned.push(...loaded.unscanned);
2911
+ const extra = [];
2912
+ if (isSkillMd)
2913
+ state.headContent = loaded.docs[0]?.content;
2914
+ for (const doc of loaded.docs) {
2915
+ noteReferences(state, doc);
2916
+ extra.push(...scanDoc(context.repoRoot, state.dir, doc, isSkillMd, state.alreadyReported, state.collector, state.unscanned));
2917
+ }
2918
+ // A dependency package's `bin` targets are what `npx`/install runs: scan them as scripts.
2919
+ for (const target of extra)
2920
+ if (owner(target) >= 0)
2921
+ scanFile(state, target, false, true);
2922
+ return loaded;
2923
+ };
2924
+ // Pass 1 — every SKILL.md and every script/executable, across all skills,
2925
+ // before any other document: decoys cannot push them past the budget.
2926
+ for (const state of states) {
2927
+ const main = scanFile(state, state.skillMd, true, true);
2928
+ state.loaded = main.docs.length > 0 || main.unscanned.length > 0;
2929
+ for (const file of state.queue.priority)
2930
+ scanFile(state, file, false, true);
2931
+ }
2932
+ // Pass 2 — everything else, while the budget lasts.
2933
+ for (const state of states) {
2934
+ for (const file of state.queue.rest)
2935
+ scanFile(state, file, false, false);
2936
+ }
2937
+ const issues = [];
2938
+ for (const state of states) {
2939
+ if (!state.loaded)
2940
+ continue; // unreadable SKILL.md
2941
+ for (const skip of ai.skillBundleSkips ?? []) {
2942
+ if (owner(skip.path) === states.indexOf(state))
2943
+ state.unscanned.push(skipReason(skip, state.referenced, context.repoRoot));
2944
+ }
2945
+ // Name what could not be scanned — never drop it silently.
2946
+ for (const u of state.unscanned) {
2947
+ state.collector.add('unscanned-files', state.skillMd, { excerpt: `${visible(u.path)}: ${u.reason}`, severity: u.severity, confidence: 0.8 });
2948
+ }
2949
+ issues.push(...state.collector.toIssues(state.skillMd));
2950
+ }
2951
+ // Scanner-form paths so inline suppressions can match (see withScannerPaths).
2952
+ return withScannerPaths(context.repoRoot, issues);
2953
+ }
2954
+ //# sourceMappingURL=skill-supply-chain.js.map