@vibe-agent-toolkit/agent-skills 0.1.42 → 0.2.0-rc.10

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 (322) hide show
  1. package/README.md +5 -5
  2. package/dist/builder.d.ts.map +1 -1
  3. package/dist/builder.js +7 -17
  4. package/dist/builder.js.map +1 -1
  5. package/dist/content-type-routing.d.ts +32 -0
  6. package/dist/content-type-routing.d.ts.map +1 -1
  7. package/dist/content-type-routing.js +23 -0
  8. package/dist/content-type-routing.js.map +1 -1
  9. package/dist/files-config.d.ts +34 -1
  10. package/dist/files-config.d.ts.map +1 -1
  11. package/dist/files-config.js +277 -39
  12. package/dist/files-config.js.map +1 -1
  13. package/dist/fs-attribution.d.ts +45 -0
  14. package/dist/fs-attribution.d.ts.map +1 -0
  15. package/dist/fs-attribution.js +57 -0
  16. package/dist/fs-attribution.js.map +1 -0
  17. package/dist/import.d.ts.map +1 -1
  18. package/dist/import.js +0 -4
  19. package/dist/import.js.map +1 -1
  20. package/dist/index.d.ts +13 -8
  21. package/dist/index.d.ts.map +1 -1
  22. package/dist/index.js +25 -7
  23. package/dist/index.js.map +1 -1
  24. package/dist/inventory/detectors/declared-but-missing.d.ts +1 -1
  25. package/dist/inventory/detectors/declared-but-missing.d.ts.map +1 -1
  26. package/dist/inventory/detectors/marketplace-source-missing.d.ts +1 -1
  27. package/dist/inventory/detectors/marketplace-source-missing.d.ts.map +1 -1
  28. package/dist/inventory/detectors/present-but-undeclared.d.ts +1 -1
  29. package/dist/inventory/detectors/present-but-undeclared.d.ts.map +1 -1
  30. package/dist/inventory/detectors/reference-target-missing.d.ts +1 -1
  31. package/dist/inventory/detectors/reference-target-missing.d.ts.map +1 -1
  32. package/dist/inventory/index.d.ts +1 -1
  33. package/dist/inventory/index.d.ts.map +1 -1
  34. package/dist/inventory/index.js +1 -1
  35. package/dist/inventory/index.js.map +1 -1
  36. package/dist/inventory/serialize.d.ts +4 -4
  37. package/dist/inventory/serialize.d.ts.map +1 -1
  38. package/dist/inventory/serialize.js +6 -6
  39. package/dist/inventory/serialize.js.map +1 -1
  40. package/dist/inventory/types.d.ts +8 -0
  41. package/dist/inventory/types.d.ts.map +1 -1
  42. package/dist/inventory/types.js.map +1 -1
  43. package/dist/link-graph-members.d.ts +18 -0
  44. package/dist/link-graph-members.d.ts.map +1 -0
  45. package/dist/link-graph-members.js +18 -0
  46. package/dist/link-graph-members.js.map +1 -0
  47. package/dist/parsers/frontmatter-parser.d.ts.map +1 -1
  48. package/dist/parsers/frontmatter-parser.js +30 -6
  49. package/dist/parsers/frontmatter-parser.js.map +1 -1
  50. package/dist/plugin-distribution-layout.d.ts +70 -6
  51. package/dist/plugin-distribution-layout.d.ts.map +1 -1
  52. package/dist/plugin-distribution-layout.js +84 -11
  53. package/dist/plugin-distribution-layout.js.map +1 -1
  54. package/dist/post-build-checks.d.ts +38 -1
  55. package/dist/post-build-checks.d.ts.map +1 -1
  56. package/dist/post-build-checks.js +68 -17
  57. package/dist/post-build-checks.js.map +1 -1
  58. package/dist/projection/skill-extent.d.ts +247 -0
  59. package/dist/projection/skill-extent.d.ts.map +1 -0
  60. package/dist/projection/skill-extent.js +560 -0
  61. package/dist/projection/skill-extent.js.map +1 -0
  62. package/dist/schemas/agent-skill-frontmatter.js +1 -1
  63. package/dist/schemas/agent-skill-frontmatter.js.map +1 -1
  64. package/dist/schemas/allowed-tools.d.ts +31 -0
  65. package/dist/schemas/allowed-tools.d.ts.map +1 -0
  66. package/dist/schemas/allowed-tools.js +88 -0
  67. package/dist/schemas/allowed-tools.js.map +1 -0
  68. package/dist/schemas/index.d.ts +1 -0
  69. package/dist/schemas/index.d.ts.map +1 -1
  70. package/dist/schemas/index.js +1 -0
  71. package/dist/schemas/index.js.map +1 -1
  72. package/dist/schemas/installed-plugins-registry.d.ts.map +1 -1
  73. package/dist/schemas/installed-plugins-registry.js +7 -1
  74. package/dist/schemas/installed-plugins-registry.js.map +1 -1
  75. package/dist/schemas/marketplace-manifest.d.ts.map +1 -1
  76. package/dist/schemas/marketplace-manifest.js +17 -6
  77. package/dist/schemas/marketplace-manifest.js.map +1 -1
  78. package/dist/skill-identity.d.ts +18 -3
  79. package/dist/skill-identity.d.ts.map +1 -1
  80. package/dist/skill-identity.js +25 -6
  81. package/dist/skill-identity.js.map +1 -1
  82. package/dist/skill-packager.d.ts +144 -20
  83. package/dist/skill-packager.d.ts.map +1 -1
  84. package/dist/skill-packager.js +447 -105
  85. package/dist/skill-packager.js.map +1 -1
  86. package/dist/skill-source/content-hash.d.ts.map +1 -1
  87. package/dist/skill-source/content-hash.js +21 -10
  88. package/dist/skill-source/content-hash.js.map +1 -1
  89. package/dist/skill-source/fetch-cache.d.ts.map +1 -1
  90. package/dist/skill-source/fetch-cache.js +0 -5
  91. package/dist/skill-source/fetch-cache.js.map +1 -1
  92. package/dist/skill-source/git-clone.d.ts +1 -1
  93. package/dist/skill-source/git-clone.d.ts.map +1 -1
  94. package/dist/skill-source/git-clone.js +10 -10
  95. package/dist/skill-source/git-clone.js.map +1 -1
  96. package/dist/skill-source/sources/url-source.d.ts.map +1 -1
  97. package/dist/skill-source/sources/url-source.js +2 -2
  98. package/dist/skill-source/sources/url-source.js.map +1 -1
  99. package/dist/skill-source/stage.d.ts.map +1 -1
  100. package/dist/skill-source/stage.js +0 -6
  101. package/dist/skill-source/stage.js.map +1 -1
  102. package/dist/skill-test/baseline-delta.d.ts +1575 -0
  103. package/dist/skill-test/baseline-delta.d.ts.map +1 -0
  104. package/dist/skill-test/baseline-delta.js +272 -0
  105. package/dist/skill-test/baseline-delta.js.map +1 -0
  106. package/dist/skill-test/baseline-integrity.d.ts +944 -0
  107. package/dist/skill-test/baseline-integrity.d.ts.map +1 -0
  108. package/dist/skill-test/baseline-integrity.js +3090 -0
  109. package/dist/skill-test/baseline-integrity.js.map +1 -0
  110. package/dist/skill-test/build-hook.d.ts +3 -2
  111. package/dist/skill-test/build-hook.d.ts.map +1 -1
  112. package/dist/skill-test/build-hook.js +4 -5
  113. package/dist/skill-test/build-hook.js.map +1 -1
  114. package/dist/skill-test/configure-writer.js +1 -1
  115. package/dist/skill-test/configure-writer.js.map +1 -1
  116. package/dist/skill-test/declared-env.d.ts +5 -4
  117. package/dist/skill-test/declared-env.d.ts.map +1 -1
  118. package/dist/skill-test/declared-env.js +8 -9
  119. package/dist/skill-test/declared-env.js.map +1 -1
  120. package/dist/skill-test/eval-executor.d.ts +21 -5
  121. package/dist/skill-test/eval-executor.d.ts.map +1 -1
  122. package/dist/skill-test/eval-executor.js +6 -6
  123. package/dist/skill-test/eval-executor.js.map +1 -1
  124. package/dist/skill-test/eval-fragment.d.ts +106 -28
  125. package/dist/skill-test/eval-fragment.d.ts.map +1 -1
  126. package/dist/skill-test/eval-fragment.js +78 -16
  127. package/dist/skill-test/eval-fragment.js.map +1 -1
  128. package/dist/skill-test/eval-grader.d.ts +65 -7
  129. package/dist/skill-test/eval-grader.d.ts.map +1 -1
  130. package/dist/skill-test/eval-grader.js +263 -64
  131. package/dist/skill-test/eval-grader.js.map +1 -1
  132. package/dist/skill-test/eval-inputs.d.ts +110 -8
  133. package/dist/skill-test/eval-inputs.d.ts.map +1 -1
  134. package/dist/skill-test/eval-inputs.js +177 -22
  135. package/dist/skill-test/eval-inputs.js.map +1 -1
  136. package/dist/skill-test/eval-lint.d.ts +17 -4
  137. package/dist/skill-test/eval-lint.d.ts.map +1 -1
  138. package/dist/skill-test/eval-lint.js +22 -9
  139. package/dist/skill-test/eval-lint.js.map +1 -1
  140. package/dist/skill-test/eval-suite-isolation.d.ts.map +1 -1
  141. package/dist/skill-test/eval-suite-isolation.js +8 -5
  142. package/dist/skill-test/eval-suite-isolation.js.map +1 -1
  143. package/dist/skill-test/evals-template.d.ts +1 -1
  144. package/dist/skill-test/evals-template.d.ts.map +1 -1
  145. package/dist/skill-test/evals-template.js +1 -3
  146. package/dist/skill-test/evals-template.js.map +1 -1
  147. package/dist/skill-test/executor-prompt.d.ts +15 -7
  148. package/dist/skill-test/executor-prompt.d.ts.map +1 -1
  149. package/dist/skill-test/executor-prompt.js +10 -7
  150. package/dist/skill-test/executor-prompt.js.map +1 -1
  151. package/dist/skill-test/failure-reason.d.ts +113 -0
  152. package/dist/skill-test/failure-reason.d.ts.map +1 -0
  153. package/dist/skill-test/failure-reason.js +124 -0
  154. package/dist/skill-test/failure-reason.js.map +1 -0
  155. package/dist/skill-test/fragment-merge.d.ts +3 -20
  156. package/dist/skill-test/fragment-merge.d.ts.map +1 -1
  157. package/dist/skill-test/fragment-merge.js +67 -8
  158. package/dist/skill-test/fragment-merge.js.map +1 -1
  159. package/dist/skill-test/grader-prompt.d.ts +29 -9
  160. package/dist/skill-test/grader-prompt.d.ts.map +1 -1
  161. package/dist/skill-test/grader-prompt.js +264 -68
  162. package/dist/skill-test/grader-prompt.js.map +1 -1
  163. package/dist/skill-test/grader-text.d.ts +145 -0
  164. package/dist/skill-test/grader-text.d.ts.map +1 -0
  165. package/dist/skill-test/grader-text.js +410 -0
  166. package/dist/skill-test/grader-text.js.map +1 -0
  167. package/dist/skill-test/grading-adapter.d.ts +38 -2
  168. package/dist/skill-test/grading-adapter.d.ts.map +1 -1
  169. package/dist/skill-test/grading-adapter.js +57 -9
  170. package/dist/skill-test/grading-adapter.js.map +1 -1
  171. package/dist/skill-test/grading-schema.d.ts +160 -5
  172. package/dist/skill-test/grading-schema.d.ts.map +1 -1
  173. package/dist/skill-test/grading-schema.js +66 -7
  174. package/dist/skill-test/grading-schema.js.map +1 -1
  175. package/dist/skill-test/harness-location.d.ts +3 -2
  176. package/dist/skill-test/harness-location.d.ts.map +1 -1
  177. package/dist/skill-test/harness-location.js +5 -16
  178. package/dist/skill-test/harness-location.js.map +1 -1
  179. package/dist/skill-test/index.d.ts +2 -2
  180. package/dist/skill-test/index.d.ts.map +1 -1
  181. package/dist/skill-test/index.js +2 -2
  182. package/dist/skill-test/index.js.map +1 -1
  183. package/dist/skill-test/lock.d.ts +58 -1
  184. package/dist/skill-test/lock.d.ts.map +1 -1
  185. package/dist/skill-test/lock.js +78 -6
  186. package/dist/skill-test/lock.js.map +1 -1
  187. package/dist/skill-test/pipeline.d.ts +19 -1
  188. package/dist/skill-test/pipeline.d.ts.map +1 -1
  189. package/dist/skill-test/pipeline.js +21 -10
  190. package/dist/skill-test/pipeline.js.map +1 -1
  191. package/dist/skill-test/preflight.d.ts +1 -1
  192. package/dist/skill-test/preflight.d.ts.map +1 -1
  193. package/dist/skill-test/preflight.js +59 -16
  194. package/dist/skill-test/preflight.js.map +1 -1
  195. package/dist/skill-test/prompt-invariants.d.ts +4 -2
  196. package/dist/skill-test/prompt-invariants.d.ts.map +1 -1
  197. package/dist/skill-test/prompt-invariants.js +5 -4
  198. package/dist/skill-test/prompt-invariants.js.map +1 -1
  199. package/dist/skill-test/run-harness.d.ts +665 -85
  200. package/dist/skill-test/run-harness.d.ts.map +1 -1
  201. package/dist/skill-test/run-harness.js +1522 -237
  202. package/dist/skill-test/run-harness.js.map +1 -1
  203. package/dist/skill-test/staging.d.ts +20 -8
  204. package/dist/skill-test/staging.d.ts.map +1 -1
  205. package/dist/skill-test/staging.js +25 -18
  206. package/dist/skill-test/staging.js.map +1 -1
  207. package/dist/skill-test/tier-plan.d.ts +1 -1
  208. package/dist/skill-test/tier-plan.js +1 -1
  209. package/dist/skill-test/tool-eval-schema.d.ts +6 -6
  210. package/dist/skill-test/tool-eval-schema.js +2 -2
  211. package/dist/skill-test/vendor-manifest.d.ts +2 -1
  212. package/dist/skill-test/vendor-manifest.d.ts.map +1 -1
  213. package/dist/skill-test/vendor-manifest.js +10 -10
  214. package/dist/skill-test/vendor-manifest.js.map +1 -1
  215. package/dist/test-input.d.ts +59 -8
  216. package/dist/test-input.d.ts.map +1 -1
  217. package/dist/test-input.js +64 -19
  218. package/dist/test-input.js.map +1 -1
  219. package/dist/validators/agent-instruction-presence.d.ts +1 -1
  220. package/dist/validators/agent-instruction-presence.d.ts.map +1 -1
  221. package/dist/validators/agent-instruction-presence.js +45 -2
  222. package/dist/validators/agent-instruction-presence.js.map +1 -1
  223. package/dist/validators/bundled-resource-link-detection.d.ts +6 -4
  224. package/dist/validators/bundled-resource-link-detection.d.ts.map +1 -1
  225. package/dist/validators/bundled-resource-link-detection.js +39 -39
  226. package/dist/validators/bundled-resource-link-detection.js.map +1 -1
  227. package/dist/validators/compat-detectors.d.ts +18 -1
  228. package/dist/validators/compat-detectors.d.ts.map +1 -1
  229. package/dist/validators/compat-detectors.js +47 -20
  230. package/dist/validators/compat-detectors.js.map +1 -1
  231. package/dist/validators/cross-skill-dependency-detection.d.ts +1 -1
  232. package/dist/validators/cross-skill-dependency-detection.d.ts.map +1 -1
  233. package/dist/validators/cross-skill-dependency-detection.js +1 -1
  234. package/dist/validators/cross-skill-dependency-detection.js.map +1 -1
  235. package/dist/validators/description-style-detection.d.ts +1 -1
  236. package/dist/validators/description-style-detection.d.ts.map +1 -1
  237. package/dist/validators/description-style-detection.js +1 -1
  238. package/dist/validators/description-style-detection.js.map +1 -1
  239. package/dist/validators/format-detection.d.ts +3 -0
  240. package/dist/validators/format-detection.d.ts.map +1 -1
  241. package/dist/validators/format-detection.js +41 -26
  242. package/dist/validators/format-detection.js.map +1 -1
  243. package/dist/validators/frontmatter-validation.d.ts +1 -1
  244. package/dist/validators/frontmatter-validation.d.ts.map +1 -1
  245. package/dist/validators/frontmatter-validation.js +2 -2
  246. package/dist/validators/frontmatter-validation.js.map +1 -1
  247. package/dist/validators/imperative-body-detection.d.ts +1 -1
  248. package/dist/validators/imperative-body-detection.d.ts.map +1 -1
  249. package/dist/validators/imperative-body-detection.js +1 -1
  250. package/dist/validators/imperative-body-detection.js.map +1 -1
  251. package/dist/validators/index.d.ts +1 -1
  252. package/dist/validators/index.d.ts.map +1 -1
  253. package/dist/validators/kebab-case-detection.d.ts +1 -1
  254. package/dist/validators/kebab-case-detection.d.ts.map +1 -1
  255. package/dist/validators/kebab-case-detection.js +1 -1
  256. package/dist/validators/kebab-case-detection.js.map +1 -1
  257. package/dist/validators/marketplace-validator.d.ts.map +1 -1
  258. package/dist/validators/marketplace-validator.js +7 -2
  259. package/dist/validators/marketplace-validator.js.map +1 -1
  260. package/dist/validators/mcp-tool-qualification.d.ts +158 -0
  261. package/dist/validators/mcp-tool-qualification.d.ts.map +1 -0
  262. package/dist/validators/mcp-tool-qualification.js +467 -0
  263. package/dist/validators/mcp-tool-qualification.js.map +1 -0
  264. package/dist/validators/packaged-size-limit.d.ts +296 -0
  265. package/dist/validators/packaged-size-limit.d.ts.map +1 -0
  266. package/dist/validators/packaged-size-limit.js +492 -0
  267. package/dist/validators/packaged-size-limit.js.map +1 -0
  268. package/dist/validators/packaging-validator.d.ts +149 -10
  269. package/dist/validators/packaging-validator.d.ts.map +1 -1
  270. package/dist/validators/packaging-validator.js +371 -55
  271. package/dist/validators/packaging-validator.js.map +1 -1
  272. package/dist/validators/plugin-hosted-shape.d.ts +4 -2
  273. package/dist/validators/plugin-hosted-shape.d.ts.map +1 -1
  274. package/dist/validators/plugin-hosted-shape.js +6 -7
  275. package/dist/validators/plugin-hosted-shape.js.map +1 -1
  276. package/dist/validators/plugin-recommended-fields.d.ts +1 -1
  277. package/dist/validators/plugin-recommended-fields.d.ts.map +1 -1
  278. package/dist/validators/plugin-recommended-fields.js +1 -1
  279. package/dist/validators/plugin-recommended-fields.js.map +1 -1
  280. package/dist/validators/referenced-path-missing.d.ts +211 -0
  281. package/dist/validators/referenced-path-missing.d.ts.map +1 -0
  282. package/dist/validators/referenced-path-missing.js +378 -0
  283. package/dist/validators/referenced-path-missing.js.map +1 -0
  284. package/dist/validators/registry-validator.d.ts.map +1 -1
  285. package/dist/validators/registry-validator.js +1 -2
  286. package/dist/validators/registry-validator.js.map +1 -1
  287. package/dist/validators/rule-engine/index.d.ts +1 -1
  288. package/dist/validators/rule-engine/rule-context.d.ts +20 -3
  289. package/dist/validators/rule-engine/rule-context.d.ts.map +1 -1
  290. package/dist/validators/rule-engine/rule-context.js +3 -1
  291. package/dist/validators/rule-engine/rule-context.js.map +1 -1
  292. package/dist/validators/rule-engine/rule-engine.d.ts +2 -2
  293. package/dist/validators/rule-engine/rule-engine.d.ts.map +1 -1
  294. package/dist/validators/rule-engine/rule-engine.js +52 -13
  295. package/dist/validators/rule-engine/rule-engine.js.map +1 -1
  296. package/dist/validators/skill-validator.d.ts.map +1 -1
  297. package/dist/validators/skill-validator.js +91 -54
  298. package/dist/validators/skill-validator.js.map +1 -1
  299. package/dist/validators/source-only-codes.js +1 -1
  300. package/dist/validators/source-only-codes.js.map +1 -1
  301. package/dist/validators/types.d.ts +21 -1
  302. package/dist/validators/types.d.ts.map +1 -1
  303. package/dist/validators/unified-validator.d.ts.map +1 -1
  304. package/dist/validators/unified-validator.js +4 -5
  305. package/dist/validators/unified-validator.js.map +1 -1
  306. package/dist/validators/validation-rules.d.ts +4 -4
  307. package/dist/validators/validation-rules.d.ts.map +1 -1
  308. package/dist/validators/validation-rules.js +9 -38
  309. package/dist/validators/validation-rules.js.map +1 -1
  310. package/dist/validators/walker-to-issues.d.ts +9 -3
  311. package/dist/validators/walker-to-issues.d.ts.map +1 -1
  312. package/dist/validators/walker-to-issues.js +64 -19
  313. package/dist/validators/walker-to-issues.js.map +1 -1
  314. package/dist/walk-link-graph.d.ts +64 -5
  315. package/dist/walk-link-graph.d.ts.map +1 -1
  316. package/dist/walk-link-graph.js +376 -92
  317. package/dist/walk-link-graph.js.map +1 -1
  318. package/package.json +14 -22
  319. package/dist/skill-test/exit-codes.d.ts +0 -97
  320. package/dist/skill-test/exit-codes.d.ts.map +0 -1
  321. package/dist/skill-test/exit-codes.js +0 -141
  322. package/dist/skill-test/exit-codes.js.map +0 -1
@@ -0,0 +1,3090 @@
1
+ import { toForwardSlashAnyPlatform } from '@vibe-agent-toolkit/utils/path';
2
+ import { isProtectedName, parseStreamJsonTranscript, protectedEnvNames, } from '@vibe-agent-toolkit/utils/skill-test';
3
+ import { z } from 'zod';
4
+ import { sanitizeGraderText } from './grader-text.js';
5
+ /**
6
+ * Baseline-integrity detection for the skill-absent (WITHOUT) arm of a
7
+ * `--baseline` run.
8
+ *
9
+ * WHY THIS EXISTS. `--baseline` withholds the skill's *declaration* from the
10
+ * control arm (`pluginDirs: []` plus `--setting-sources ""`), which removes
11
+ * DISCOVERY but not CAPABILITY: the executor runs with unrestricted Bash under
12
+ * `--permission-mode bypassPermissions`, so any copy of the skill still on the
13
+ * filesystem is reachable. vat no longer ESCORTS the control arm to its own staged
14
+ * copies — the prompt does not name the staged subject, the arm's cwd is a per-arm
15
+ * per-eval workspace outside the harness root, `pluginDirs` is empty, and the env is
16
+ * scrubbed — but "not escorted" is not "cannot reach", and three classes of copy
17
+ * remain reachable by an arm that goes looking:
18
+ *
19
+ * 1. the adopter's own repo / build output,
20
+ * 2. the user's installed plugin cache, and
21
+ * 3. vat's OWN staged tree, which is a sibling of the arm's cwd under the shared
22
+ * OS temp dir. `resolveHarnessRoot` derives `<tmp>/vat-skill-test/<name>-<hash8>`
23
+ * with no random token, so `ls ../..` reaches it and a `*` glob makes the hash
24
+ * irrelevant. Closing this needs a per-run random token on the harness root, and
25
+ * is NOT done — which is why the harness-directory needle below exists.
26
+ *
27
+ * Do not restore a claim here that vat's copies are out of reach. They are one
28
+ * directory climb away, the detector is what covers them, and an overstated comment
29
+ * is how the last two rounds of this fix each shipped believing they were finished.
30
+ *
31
+ * A control arm that reaches either of those answers from the skill's own tool
32
+ * and the reported delta stops being a measure of skill lift — silently, with a
33
+ * well-formed `baseline.json` and exit 0. That silence is the actual defect: a
34
+ * measurement that is quietly wrong is worse than one that is missing, because
35
+ * it gets believed, written down, and acted on.
36
+ *
37
+ * So this module does not try to PREVENT contamination (it cannot, for the two
38
+ * classes above). It DETECTS it and makes it loud.
39
+ */
40
+ /**
41
+ * Fold a path (or a haystack containing paths) into ONE comparable spelling.
42
+ *
43
+ * Three real divergences make a raw `indexOf` of an absolute path useless:
44
+ * - **Windows separators.** `safePath.join` forward-slashes every path VAT
45
+ * derives, while a child's output is backslashed — and inside stream-json
46
+ * those backslashes are ESCAPED, so the raw transcript holds `\\`. Folding
47
+ * backslashes to `/` therefore yields `//`, which still would not match a
48
+ * single-slash needle; the run-collapse is what closes that. ⚠️ THE
49
+ * RUN-COLLAPSE DOES NOT PRESERVE LENGTH, so an index into normalized text
50
+ * does not address the original. The structured scan therefore never mixes
51
+ * the two: it takes excerpts from the RAW command using the raw token's own
52
+ * index (which is also more useful for triage — the arm's own casing is
53
+ * preserved), and matches needles against separately-normalized RESOLVED
54
+ * paths. Only the degraded flat fallback indexes into normalized text, where
55
+ * needle and excerpt come from that same string.
56
+ * - **Case.** Folded UNCONDITIONALLY, not just on win32. Windows is the obvious
57
+ * case-insensitive filesystem, but macOS ships case-insensitive APFS/HFS+ by
58
+ * default, so `cat /PRIVATE/VAR/…/VAT-SKILL-TEST/…/SKILL.md` succeeds there and
59
+ * read as clean while the win32-gated fold was in force. Case-folding both
60
+ * sides cannot create a false positive that the path-boundary match would not
61
+ * already have rejected, so there is no cost to paying it everywhere.
62
+ *
63
+ * 8.3 short names (`VAT-SK~1`) are NOT handled — neither here nor, contrary to what
64
+ * this comment used to claim, by {@link harnessNeedles}, whose segments are all
65
+ * longer than 8 characters and are therefore exactly what Windows shortens.
66
+ */
67
+ function normalizeForMatch(value) {
68
+ return toForwardSlashAnyPlatform(value).replaceAll(/\/{2,}/g, '/').toLowerCase();
69
+ }
70
+ /**
71
+ * The canonical spelling of a path a CALLER declared, for use as a needle.
72
+ *
73
+ * ⚠️ THE RULE THIS ENCODES, because this class has now bitten twice: A SIGNAL MUST
74
+ * BE ARMED BY THE SAME VALUE THE MATCHER USES. `signals` answers "which detectors
75
+ * actually looked?", so a detector reporting itself armed over a needle that cannot
76
+ * match writes "checked and clean" where the truth is "nothing looked" — and the
77
+ * two are byte-identical in `baseline.json`. Where a value can be armed-but-INERT,
78
+ * that is a defect regardless of how it got there. It is not a cosmetic
79
+ * disagreement between two code paths; it is the one claim this module exists to
80
+ * make, made falsely.
81
+ *
82
+ * So every caller-declared path is folded HERE, ONCE, and both the arming test and
83
+ * the matcher read the result — they cannot disagree because there is only one
84
+ * string. {@link normalizeForMatch} folds separators and case;
85
+ * {@link normalizeDotSegments} folds `.` and `..`, which is what the RESOLVED side
86
+ * of every comparison has already had done to it. A needle carrying a `.` segment
87
+ * is inert BY CONSTRUCTION: no resolved path can contain one.
88
+ *
89
+ * 📌 MEASURED: without the dot fold, `harnessRoot: '.'` and `'./'` reported
90
+ * `harness-path` armed over the needles `.` and `./`, which fire on nothing.
91
+ * `harnessRoot: '/'` is NOT in that set — its needle is degenerate but `cat /`
92
+ * does match it, so it stays armed.
93
+ */
94
+ function foldDeclaredPath(value) {
95
+ return normalizeDotSegments(normalizeForMatch(value));
96
+ }
97
+ /**
98
+ * Build a {@link ScanHaystack} from raw text.
99
+ *
100
+ * The separator fold and the run-collapse are done by hand rather than by
101
+ * `normalizeForMatch`'s two `replaceAll`s, because they are the length-CHANGING
102
+ * steps and this is where the correspondence has to be recorded (the collapse
103
+ * deletes code units, so an index into the output does not address the input —
104
+ * the warning {@link normalizeForMatch} has carried since it was written).
105
+ * Lowercasing is applied to the WHOLE folded string afterwards, exactly as
106
+ * `normalizeForMatch` does — not per character. That is what makes the two provably
107
+ * equal rather than approximately so: `toLowerCase` is context-sensitive (Greek
108
+ * final sigma is the real case), so a per-character fold here would quietly disagree
109
+ * with the needles, which are normalized the other way. Every needle-matching test
110
+ * in this suite runs through both functions and would fail on a divergence.
111
+ *
112
+ * `toLowerCase()` changes LENGTH for a small number of code points (U+0130 is the
113
+ * common one). That would desynchronize the map, so the map is dropped rather than
114
+ * silently skewed — an approximate excerpt pointing at the wrong bytes is worse
115
+ * than the folded one, because it looks precise.
116
+ */
117
+ function scanHaystack(raw) {
118
+ const units = [];
119
+ const source = [];
120
+ let previousWasSlash = false;
121
+ for (let i = 0; i < raw.length; i += 1) {
122
+ const ch = raw.charAt(i);
123
+ const folded = ch === '\\' ? '/' : ch;
124
+ const isSlash = folded === '/';
125
+ if (isSlash && previousWasSlash)
126
+ continue;
127
+ previousWasSlash = isSlash;
128
+ units.push(folded);
129
+ source.push(i);
130
+ }
131
+ const folded = units.join('');
132
+ const normalized = folded.toLowerCase();
133
+ return normalized.length === folded.length
134
+ ? { raw, normalized, source }
135
+ : { raw, normalized };
136
+ }
137
+ /** The directory name VAT puts every harness root under. See {@link harnessNeedles}. */
138
+ const VAT_HARNESS_DIR_NAME = 'vat-skill-test';
139
+ // A `MIN_NEEDLE_LENGTH = 8` floor lived here and has been REMOVED. Its stated
140
+ // rationale — that `--out /tmp/out` would otherwise fire on `/tmp/output.csv` — was
141
+ // already false when it was written: the path-boundary match added in the SAME
142
+ // commit rejects that (`t` continues the segment). It was belt over braces, and the
143
+ // braces were harmful. It silently produced ZERO needles for any root under 8 chars,
144
+ // so `--out /tmp/x` disabled the harness-path detector entirely and an absolute
145
+ // reach straight into `<root>/staged/<skill>/SKILL.md` reported clean; a root whose
146
+ // last two segments were short lost the suffix needle, silencing every relative
147
+ // reach. Silent "clean" is the one failure this whole module exists to prevent.
148
+ // Do not reintroduce a length floor without a `degraded` signal on the verdict.
149
+ /**
150
+ * The needles that mean "this transcript reached VAT's staged copies".
151
+ *
152
+ * Three, in longest-first order, and every one of them is matched at a PATH
153
+ * BOUNDARY (see {@link containsPathAtBoundary}) rather than by bare substring:
154
+ *
155
+ * 1. the full normalized root;
156
+ * 2. its last two path segments — the load-bearing one for a RELATIVE reach.
157
+ * The control arm's cwd and the harness root are siblings under the OS temp
158
+ * dir, so `cat ../../vat-skill-test/<key>/staged/…` never contains the
159
+ * absolute root. The suffix also survives an 8.3-short-named or
160
+ * differently-realpathed prefix (macOS `$TMPDIR` is `/var/folders/…` while
161
+ * VAT derives `/private/var/folders/…`);
162
+ * 3. VAT's own harness directory NAME, when the root is actually under one.
163
+ *
164
+ * Needle 3 exists because needles 1 and 2 both require the arm to spell the 8-hex
165
+ * harness key — which it has no way to know and no reason to type. Every natural
166
+ * reach enumerates instead: `cat ../../vat-skill-test/&#42;/staged/SKILL.md`,
167
+ * `find ../../vat-skill-test -name SKILL.md`, or `K=$(ls ../../vat-skill-test)`.
168
+ * One `&#42;` defeated the entire suffix scheme. The boundary match is what keeps
169
+ * needle 3 honest: the control arm's OWN legitimate cwd is
170
+ * `vat-skill-test-ws-<token>/…`, where the next character is `-` rather than `/`,
171
+ * so it is not a hit — which is the constraint any change here must preserve.
172
+ *
173
+ * Needle 3 additionally requires a LEADING `/` (see {@link needsLeadingBoundary}),
174
+ * without which `ls` of the temp dir stamps every clean run contaminated.
175
+ */
176
+ /**
177
+ * Split an ALREADY-NORMALIZED path into its meaningful segments.
178
+ *
179
+ * The `no-hardcoded-path-split` rule exists because splitting a raw path on `/`
180
+ * breaks on Windows — but every caller here passes the output of
181
+ * {@link normalizeForMatch}, which has already folded `\` to `/`. That is the
182
+ * whole point of this module: it compares one canonical spelling, and a
183
+ * `basename()` would reintroduce the platform separator the normalizer removed.
184
+ */
185
+ const normalizedSegments = (normalized) => normalized.split('/').filter((s) => s !== '' && s !== '.');
186
+ export function harnessNeedles(harnessRoot) {
187
+ const normalized = foldDeclaredPath(harnessRoot);
188
+ if (normalized === '')
189
+ return [];
190
+ const segments = normalizedSegments(normalized);
191
+ const loginNames = loginNameSegments(normalized);
192
+ const needles = [pathNeedle(normalized, loginNames, false)];
193
+ if (segments.length >= 2)
194
+ needles.push(pathNeedle(segments.slice(-2).join('/'), loginNames, true));
195
+ const vatDir = normalizeForMatch(VAT_HARNESS_DIR_NAME);
196
+ if (segments.includes(vatDir))
197
+ needles.push(pathNeedle(vatDir, loginNames, false));
198
+ return needles;
199
+ }
200
+ /**
201
+ * Does `value` contain `needle` as a whole PATH PREFIX — i.e. ending at a `/` or at
202
+ * the end of the string, never mid-segment?
203
+ *
204
+ * A bare `includes` over-matches in exactly the layout VAT ships: the default
205
+ * harness root `<tmp>/vat-skill-test/<key>` is a string prefix of the workspaces
206
+ * root `<tmp>/vat-skill-test-ws-<token>`, so `--out <tmp>/vat-skill-test` made every
207
+ * `${fixturesDir}` value look like a harness leak and stripped the control arm's own
208
+ * declared input files. It also made a `--out /tmp/out` needle fire on `/tmp/output.csv`.
209
+ * Both directions of that error are expensive, so the boundary is not optional.
210
+ */
211
+ /**
212
+ * Characters that CONTINUE a path segment. A needle followed by one of these
213
+ * matched mid-name and is not evidence; a needle followed by anything else (`/`,
214
+ * whitespace, a quote, end of string) ended where we said it did.
215
+ *
216
+ * Hyphen is the load-bearing member: VAT's own `vat-skill-test-ws-<token>` — the
217
+ * control arm's LEGITIMATE workspace — shares a prefix with the `vat-skill-test`
218
+ * needle, and it is the `-` that must disqualify it. Whitespace must NOT be in
219
+ * this set: `find ../../vat-skill-test -name SKILL.md` and `ls -R ../../vat-skill-test`
220
+ * are real reaches that end the path at a space.
221
+ */
222
+ const PATH_SEGMENT_CONTINUATION = /[\w-]/;
223
+ /**
224
+ * Does this needle also require a `/` immediately BEFORE it?
225
+ *
226
+ * For a bare directory-NAME needle (one with no separator of its own, i.e.
227
+ * `vat-skill-test`), yes. Without it, `ls` of the OS temp dir stamps
228
+ * `contaminated: true` on a completely clean run: the arm's cwd is
229
+ * `<tmp>/vat-skill-test-ws-<token>/…`, so `ls ../../..` prints `vat-skill-test` on
230
+ * its own line, and a NEWLINE is not in {@link PATH_SEGMENT_CONTINUATION}, so the
231
+ * trailing boundary happily accepts it.
232
+ *
233
+ * ⚠️ SCOPE, since this rule used to be the ONLY thing standing between a listing
234
+ * and a contaminated verdict: it now applies to the DEGRADED flat fallback only
235
+ * (see {@link flatPathHits}). The structured scan never matches a needle against
236
+ * tool OUTPUT at all, so a listing cannot reach a needle in the first place.
237
+ *
238
+ * ⛔ NOT because "every path the structured scan DOES match is resolved absolute",
239
+ * which this used to claim and which is false in two ways: a `~`- or `$VAR`-rooted
240
+ * token is returned AS WRITTEN by {@link resolvePathToken} precisely so it reads as
241
+ * an escape, and after an unevaluable `cd` there is no cwd to resolve against at
242
+ * all. The rule is harmless there anyway — `~/vat-skill-test/x` still carries the
243
+ * slash the bare-name needle wants — but the reason it is harmless is the SHAPE of
244
+ * what survives resolution, not a guarantee of absoluteness that does not hold.
245
+ *
246
+ * ⛔ AND THIS RULE IS WHY THE FLAT SCAN HAD TO GO. A leading `/` was claimed to
247
+ * cost no real detection, on the grounds that every genuine reach carries one —
248
+ * including `cd ../../vat-skill-test`, "which keeps its preceding slash". That is
249
+ * true of a ONE-SHOT `cd` and false of the two-step chain an agent actually types:
250
+ * the Bash tool KEEPS CWD ACROSS CALLS, so `cd ../../..` then `cd vat-skill-test`
251
+ * then `cat staged/…/SKILL.md` never writes a slash before the name, and a control
252
+ * arm that ran the skill's own bundled script read CLEAN with all four signals
253
+ * reported "armed". A leading-slash rule cannot be patched into correctness,
254
+ * because the missing slash is a consequence of a cwd the flat matcher cannot see.
255
+ *
256
+ * Needles 1 and 2 already contain a separator, so this returns false for them and
257
+ * their existing behavior is unchanged.
258
+ */
259
+ function needsLeadingBoundary(needle) {
260
+ return !needle.includes('/');
261
+ }
262
+ // A `prefixNeedle` mode lived here — needles ending in `-` skipping the TRAILING
263
+ // boundary check, so `vat-skill-evals-` could match with the run's token still
264
+ // attached. It is REMOVED: nothing needed it. The reach it was written for is
265
+ // `cat $TMPDIR/vat-skill-evals-*/evals.json`, and `*` is not in
266
+ // PATH_SEGMENT_CONTINUATION, so the ORDINARY trailing check already accepts it;
267
+ // every reach carrying the real token matches the full-path or whole-name needle
268
+ // first. Proven by mutation: disabling the mode left all 53 tests green, which is
269
+ // the definition of a special case nobody is standing on. See the MIN_NEEDLE_LENGTH
270
+ // note above for the same lesson, and do not reintroduce this without a reach that
271
+ // ONLY it can catch.
272
+ function indexOfPathAtBoundary(haystack, needle) {
273
+ if (needle === '')
274
+ return -1;
275
+ const requireLeading = needsLeadingBoundary(needle);
276
+ for (let from = 0;;) {
277
+ const index = haystack.indexOf(needle, from);
278
+ if (index === -1)
279
+ return -1;
280
+ const nextChar = haystack.charAt(index + needle.length);
281
+ const trailingOk = nextChar === '' || !PATH_SEGMENT_CONTINUATION.test(nextChar);
282
+ const leadingOk = !requireLeading || (index > 0 && haystack.charAt(index - 1) === '/');
283
+ if (trailingOk && leadingOk)
284
+ return index;
285
+ from = index + 1;
286
+ }
287
+ }
288
+ function containsPathAtBoundary(haystack, needle) {
289
+ return indexOfPathAtBoundary(haystack, needle) !== -1;
290
+ }
291
+ /**
292
+ * Env keys the control arm must never receive, regardless of value.
293
+ *
294
+ * `CLAUDE_PLUGIN_ROOT` points at the staged plugin root — inside the harness root,
295
+ * holding the SKILL.md and every executable the skill ships. It is meaningless in
296
+ * an arm spawned with `pluginDirs: []`, so withholding it costs the measurement
297
+ * nothing and closes a channel that one `env | grep` turns into the whole treatment.
298
+ */
299
+ const CONTROL_ARM_FORBIDDEN_ENV_KEYS = ['CLAUDE_PLUGIN_ROOT'];
300
+ /**
301
+ * Strip every channel by which the executor ENVIRONMENT would tell the skill-absent
302
+ * arm where the skill is staged.
303
+ *
304
+ * A path reaches a child process through four channels — prompt, argv, cwd, and
305
+ * env. The first three are handled at the call site; this is the fourth, and it is
306
+ * the one that is easy to miss because nothing at the spawn call mentions it: the
307
+ * run assembles ONE env and both arms were given it verbatim.
308
+ *
309
+ * Two rules, deliberately in this order:
310
+ * 1. drop {@link CONTROL_ARM_FORBIDDEN_ENV_KEYS} by name;
311
+ * 2. drop ANY entry whose value contains the harness root — which covers
312
+ * adopter-declared `env:` values that interpolated `${stagedSkillDir}` or
313
+ * `${harnessRoot}`, and, more importantly, covers the next such channel
314
+ * somebody adds without knowing this function exists.
315
+ *
316
+ * Rule 2 is a value scan rather than a longer key list on purpose: a key list only
317
+ * ever knows about the leaks already found. It is accident-catching HYGIENE, not an
318
+ * adversarial control — a determined skill author splits the path across two vars
319
+ * (`A=/tmp/vat-skill-test` + `B=/<key>/…`) and the per-value scan never joins them.
320
+ * The adversarial control is that the path is not REACHABLE, not that it is unnamed.
321
+ *
322
+ * Rule 2 exempts {@link protectedEnvNames}, which is WIDER than the three names the
323
+ * paragraph below uses as its example: it is every process essential, the auth
324
+ * allowlist, every inference credential, the whole `CREDENTIAL_ROUTING_DENY` list,
325
+ * AND this run's `modelVars`. The model vars are the ones that matter most here and
326
+ * were missing until they were threaded in: dropping a model var because its value
327
+ * happens to sit under `--out` runs the control arm ON A DIFFERENT MODEL, which is
328
+ * a far worse confound than the missing PATH this exemption exists to prevent, and
329
+ * one no operator would think to look for.
330
+ *
331
+ * The three named below are the illustration. An `--out` under any of their values (e.g.
332
+ * `--out .` from a repo root, which puts `<repo>/node_modules/.bin` on PATH inside a
333
+ * `bun run`) would otherwise spawn the control arm with no PATH at all. That does not
334
+ * fail; it DEGRADES the control, which scores lower, which reports as skill lift. A
335
+ * scrub that manufactures the very delta the product sells is worse than the leak it
336
+ * closes, so a protected var that names the harness root is RETAINED and reported
337
+ * loudly instead — the operator can see it and move `--out`.
338
+ *
339
+ * `${fixturesDir}` resolves under the WORKSPACES root, not the harness root, so the
340
+ * control arm keeps its declared input files — the arms stay identical in
341
+ * everything except the skill itself.
342
+ *
343
+ * Pure. Returns the scrubbed env and three disjoint name lists for the transparency
344
+ * lines: the rule-1 drops, the rule-2 drops, and the protected names RETAINED
345
+ * despite naming the harness root.
346
+ *
347
+ * The two drop lists are separate because they mean opposite things to an operator.
348
+ * A rule-1 drop is routine — `CLAUDE_PLUGIN_ROOT` is withheld on every plugin-layout
349
+ * baseline run by design, and its value may name nothing in the harness at all (the
350
+ * installed-plugin-cache case). A rule-2 drop says the operator's OWN declared `env:`
351
+ * named the harness root, which is the one worth acting on. Merged into one list under
352
+ * one "naming the harness root" sentence, the routine drop was both mislabelled and
353
+ * loud enough to bury the interesting one.
354
+ */
355
+ export function scrubControlArmEnv(env, harnessRoot,
356
+ /**
357
+ * This run's model env var names. REQUIRED, not defaulted: an omitted list
358
+ * silently narrows the exemption set, and the var it drops decides which model
359
+ * the control arm runs — a defect that reads as skill lift and is invisible in
360
+ * the output. Pass `[]` when the run genuinely has none.
361
+ */
362
+ modelVars,
363
+ /**
364
+ * The platform whose env-name casing rules apply. Defaults to this process's.
365
+ * Threaded in ONLY so the win32 branch of {@link isProtectedName} is testable:
366
+ * without it the case-insensitive lookup this function deliberately uses instead
367
+ * of `protectedNames.has` was unexercised on every platform CI runs the unit
368
+ * suite on, and swapping the two was green.
369
+ */
370
+ platform = process.platform) {
371
+ const needle = normalizeForMatch(harnessRoot);
372
+ const protectedNames = protectedEnvNames(modelVars);
373
+ const scrubbed = {};
374
+ const droppedForbiddenKey = [];
375
+ const droppedNamingRoot = [];
376
+ const retainedLeaks = [];
377
+ for (const [key, value] of Object.entries(env)) {
378
+ const forbiddenKey = CONTROL_ARM_FORBIDDEN_ENV_KEYS.includes(key);
379
+ const leaksPath = typeof value === 'string' && containsPathAtBoundary(normalizeForMatch(value), needle);
380
+ // `isProtectedName`, not `protectedNames.has`: on win32 env names are
381
+ // case-insensitive, so a `Path` that names the harness root would miss the
382
+ // exemption and be dropped — spawning the control arm with no PATH on the one
383
+ // platform where the spelling varies.
384
+ // `!forbiddenKey` is NOT dead, though `CLAUDE_PLUGIN_ROOT` is not in
385
+ // `protectedEnvNames`: `modelVars` is caller-supplied and lands in the protected
386
+ // set verbatim, so a run that declared the forbidden key as a model var would
387
+ // exempt it and hand the control arm the staged plugin root. Rule 1 wins.
388
+ if (leaksPath && !forbiddenKey && isProtectedName(key, protectedNames, platform)) {
389
+ retainedLeaks.push(key);
390
+ scrubbed[key] = value;
391
+ continue;
392
+ }
393
+ // Rule 1 first, and it wins outright: a forbidden key is withheld whatever its
394
+ // value, so reporting it as "its value named the harness root" would be a guess
395
+ // that is wrong whenever the key points somewhere else entirely.
396
+ if (forbiddenKey) {
397
+ droppedForbiddenKey.push(key);
398
+ continue;
399
+ }
400
+ if (leaksPath) {
401
+ droppedNamingRoot.push(key);
402
+ continue;
403
+ }
404
+ scrubbed[key] = value;
405
+ }
406
+ return { env: scrubbed, droppedForbiddenKey, droppedNamingRoot, retainedLeaks };
407
+ }
408
+ /** One piece of evidence that the skill-absent arm reached the skill anyway. */
409
+ /**
410
+ * Every kind of contamination hit, which is also every DETECTOR — the two are
411
+ * 1:1, so one list serves both (see {@link ContaminationSignalSchema}).
412
+ */
413
+ const CONTAMINATION_KINDS = [
414
+ 'harness-path',
415
+ 'declared-executable',
416
+ 'sibling-arm',
417
+ 'vat-private-dir',
418
+ 'skill-content',
419
+ ];
420
+ /** Named so the push sites below do not restate the string literals. */
421
+ const KIND_HARNESS_PATH = 'harness-path';
422
+ const KIND_DECLARED_EXECUTABLE = 'declared-executable';
423
+ const KIND_SIBLING_ARM = 'sibling-arm';
424
+ const KIND_VAT_PRIVATE_DIR = 'vat-private-dir';
425
+ const KIND_SKILL_CONTENT = 'skill-content';
426
+ export const BaselineContaminationHitSchema = z.object({
427
+ /**
428
+ * What matched: a path under vat's harness root, a declared executable's name,
429
+ * the OTHER arm's workspace directory, or one of vat's private tmp dirs.
430
+ *
431
+ * `sibling-arm` is a materially different finding from the first two. Those mean
432
+ * the arm found a copy of the skill; this one means it read the treatment arm's
433
+ * live working directory, so the control may be echoing an answer the treatment
434
+ * produced seconds earlier rather than solving anything.
435
+ *
436
+ * `vat-private-dir` is worse than all of them. It means the arm reached the held
437
+ * eval suite (the `expected_output` ANSWER KEY) or the grader dir (the run's
438
+ * integrity nonce). Reaching a copy of the skill inflates the control arm;
439
+ * reaching the answer key inflates BOTH arms, so it does not even show up as a
440
+ * shrunken delta — see {@link vatPrivateDirNeedles}.
441
+ */
442
+ kind: z.enum(CONTAMINATION_KINDS),
443
+ /**
444
+ * The matched token — a path SUFFIX, a verbatim skill line, or the executable
445
+ * basename.
446
+ *
447
+ * ⚠️ PATH MATCHES ARE TRUNCATED TO THEIR LAST TWO SEGMENTS ON PURPOSE, and that
448
+ * is not cosmetic. Needle sets run longest-first, so the needle that matches a
449
+ * full-path reach is the ENTIRE absolute root — on Windows
450
+ * `C:/Users/<username>/AppData/Local/Temp/vat-skill-test/<key>`, which puts the
451
+ * operator's login name in an artifact adopters attach to bug reports, in the one
452
+ * field that reads like a short opaque token. The last two segments identify the
453
+ * directory just as well (`vat-skill-test/<key>`) and name nobody. See
454
+ * {@link redactPathMatch}; the untruncated path is still visible in `excerpt`,
455
+ * which carries its own warning.
456
+ *
457
+ * A skill-content match is a verbatim line of the skill's own body and is NOT
458
+ * truncated — it has no `/`-separated structure to trim, and the whole point of
459
+ * that signal is that the operator can find the line in SKILL.md by eye.
460
+ */
461
+ match: z.string().min(1),
462
+ /**
463
+ * A short excerpt of the transcript around the match, for triage.
464
+ *
465
+ * RAW TRANSCRIPT — the arm's own bytes, its own casing, its own separators,
466
+ * bounded and whitespace-collapsed. That is a promise this field has to keep to
467
+ * be worth anything: its only use is to be carried back to the transcript and
468
+ * searched for, and it once came from the NORMALIZED haystack instead, so
469
+ * `SKILL.md` was reported as `skill.md` and a JSON-escaped `\n` as `/n` — an
470
+ * operator grepping for the reported excerpt found nothing, in a field that
471
+ * exists solely so they can. The structured scan quotes the raw tool input; the
472
+ * flat and content scans map their match back through {@link ScanHaystack}.
473
+ * (Whitespace collapse is the one transform that survives, and it is stated here:
474
+ * grep for a distinctive WORD from the excerpt, not for the whole line.)
475
+ *
476
+ * NOT redacted. The most likely way a harness path reaches a transcript is an
477
+ * `env` dump, so this window can capture adjacent environment values —
478
+ * `GITHUB_TOKEN=…`, `HOME=/Users/<name>` have both been observed — and
479
+ * `baseline.json` is a file adopters attach to reports. Treat it as sensitive;
480
+ * `grading.json` already quotes transcripts verbatim, so this is the same
481
+ * exposure, not a new one.
482
+ */
483
+ excerpt: z.string(),
484
+ }).strict();
485
+ /** Per-eval contamination finding for the WITHOUT arm. */
486
+ export const BaselineContaminationSchema = z.object({
487
+ evalId: z.string().min(1),
488
+ hits: z.array(BaselineContaminationHitSchema).min(1),
489
+ }).strict();
490
+ /**
491
+ * The run-level `baselineIntegrity` block stamped into `baseline.json`.
492
+ *
493
+ * `contaminated: true` means at least one WITHOUT-arm eval demonstrably reached
494
+ * the skill; the A/B delta for the run is then NOT interpretable as skill lift.
495
+ * The block is written on EVERY baseline run, clean or not, so its absence
496
+ * always means "this baseline.json predates integrity checking" and never
497
+ * "checked and clean".
498
+ */
499
+ /**
500
+ * A detector that can be ARMED for a run.
501
+ *
502
+ * Deliberately the SAME enum as a hit's `kind`, not a parallel list: each
503
+ * detector produces exactly one kind of hit, so two lists would be a drift
504
+ * hazard for no expressive gain. Which detectors were armed is the difference
505
+ * between "checked and clean" and "nothing was looking":
506
+ *
507
+ * - `harness-path` — vat's staged trees ({@link harnessNeedles}). Always armed.
508
+ * - `sibling-arm` — the other arm's live workspace. Armed only when there is one.
509
+ * - `vat-private-dir` — the held answer key and the grader dir
510
+ * ({@link vatPrivateDirNeedles}).
511
+ * - `declared-executable` — the skill's executables by name. Armed ONLY for a
512
+ * skill that ships executables; an instruction-only skill (the common case)
513
+ * has no such name, so this signal is absent.
514
+ * - `skill-content` — verbatim lines of the staged SKILL.md
515
+ * ({@link skillContentNeedles}). The one signal that does not need the arm to
516
+ * name a path or an executable, and therefore the only cover an
517
+ * instruction-only skill has. Unarmed when the body yields no line distinctive
518
+ * enough to accuse anyone with, which is itself worth seeing.
519
+ */
520
+ export const ContaminationSignalSchema = BaselineContaminationHitSchema.shape.kind;
521
+ /** One eval whose two arms were graded against a different number of expectations. */
522
+ export const BaselineArmSkewSchema = z.object({
523
+ evalId: z.string().min(1),
524
+ withTotal: z.number().int().nonnegative(),
525
+ withoutTotal: z.number().int().nonnegative(),
526
+ }).strict();
527
+ /**
528
+ * One CONTROL-arm eval that produced no grade at all, and why.
529
+ *
530
+ * Distinct from {@link BaselineArmSkewSchema}, which describes two arms that both
531
+ * graded and disagreed about how deep. This describes an arm that never got as far
532
+ * as a verdict: its executor failed to spawn, stalled, hit the wall clock, or its
533
+ * grader exited non-zero / wrote no fragment / wrote something unparseable / echoed
534
+ * the wrong nonce / graded a different number of expectations than the eval declares.
535
+ *
536
+ * Every one of those used to propagate out of `runPipeline` and fail the WHOLE run
537
+ * (exit 1) — discarding fully-billed treatment work that was perfectly good, and
538
+ * writing no `grading.json` at all. The treatment arm still hard-fails that way,
539
+ * because without a treatment result there is nothing to salvage; the control arm
540
+ * does not, because the thing it feeds (the delta) has a way to say "I cannot be
541
+ * computed" and the thing the operator paid for (the treatment grading) does not.
542
+ *
543
+ * `detail` is the arm-named error message verbatim — it already carries the eval id
544
+ * and the failing stage, and it is the text an operator would otherwise have seen on
545
+ * stderr before the run died.
546
+ */
547
+ export const BaselineControlArmFailureSchema = z.object({
548
+ evalId: z.string().min(1),
549
+ detail: z.string().min(1),
550
+ }).strict();
551
+ /**
552
+ * Evals whose two arms were graded against DIFFERENT numbers of expectations.
553
+ *
554
+ * `--baseline` sells a DELTA, and a delta between two differently-sized
555
+ * denominators is not a delta. vat computes each arm's `summary` from the
556
+ * fragments it received, so both reports are internally consistent by
557
+ * construction and `reconcileGrading` cannot see this — a grader that emitted 2
558
+ * entries for a 3-expectation eval on the control arm yields
559
+ * `baseline.summary = {passed:2,total:2}`, which reads as **100% without the
560
+ * skill**. That is the most damaging direction the number can be wrong in: it
561
+ * says the skill did nothing.
562
+ *
563
+ * Both directions count, and so does an eval graded on only one arm — the missing
564
+ * side is reported as 0 rather than dropped, since a silently absent eval skews
565
+ * the run total exactly as a short-graded one does.
566
+ *
567
+ * THE ONE-ARM CASE IS NOW THE LOAD-BEARING ONE, and it is why this function did not
568
+ * become redundant when the declared-count check landed. Both arms are pinned to the
569
+ * eval's DECLARED expectation count at the grader boundary
570
+ * (`assertExpectationCountDeclared` in eval-grader.ts), so two arms that both
571
+ * produced a fragment can no longer disagree about `total` — which reads like this
572
+ * check has nothing left to find. It has two live inputs it always had and only now
573
+ * carries alone:
574
+ *
575
+ * - an eval the CONTROL arm never graded at all, because its executor or grader
576
+ * broke (see {@link BaselineControlArmFailureSchema}). That is not a hypothetical:
577
+ * it is the routine outcome the run no longer aborts on, so it arrives here on
578
+ * every such run.
579
+ * - an eval graded on one arm only for any other reason — a fragment lost, an arm
580
+ * mislabelled, a future path that grades the arms from different suites.
581
+ *
582
+ * It is also the SINGLE AUTHORITY `computeBaselineDelta` consults before withholding
583
+ * (see baseline-delta.ts). Deleting it would leave the delta with no way to say
584
+ * "these two totals cannot be subtracted", which is the whole reason `null` exists.
585
+ *
586
+ * The declared-count check is the STRONGER cousin this docblock used to say was "NOT
587
+ * here … it needs the suite threaded into the merge". That premise was wrong: the
588
+ * declared list is already in hand at the grader boundary, one call before the
589
+ * fragment is returned, so no threading was needed and the check now lives there.
590
+ * It catches what parity structurally cannot — both graders drifting the SAME way,
591
+ * which keeps parity satisfied while every reported number is wrong.
592
+ */
593
+ export function armExpectationSkew(withArm, withoutArm) {
594
+ const withoutById = new Map(withoutArm.map((c) => [c.evalId, c.total]));
595
+ const skew = [];
596
+ for (const { evalId, total } of withArm) {
597
+ const withoutTotal = withoutById.get(evalId) ?? 0;
598
+ if (withoutTotal !== total)
599
+ skew.push({ evalId, withTotal: total, withoutTotal });
600
+ }
601
+ // Evals the control arm graded and the treatment did not. Same disease, other
602
+ // direction, and invisible to the loop above.
603
+ const withIds = new Set(withArm.map((c) => c.evalId));
604
+ for (const { evalId, total } of withoutArm) {
605
+ if (!withIds.has(evalId))
606
+ skew.push({ evalId, withTotal: 0, withoutTotal: total });
607
+ }
608
+ return skew;
609
+ }
610
+ /**
611
+ * Why one eval's scan could not run at full strength.
612
+ *
613
+ * The structured scan ({@link detectBaselineContamination}) needs three things the
614
+ * transcript is not contractually obliged to provide: parseable stream-json with no
615
+ * dropped lines, and a cwd it can follow from the arm's own workspace through every
616
+ * `cd`.
617
+ *
618
+ * ⚠️ THE TWO FAILURES ARE NOT THE SAME SHAPE, and treating them alike was a defect
619
+ * in its own right. An UNPARSEABLE transcript leaves nothing to walk, so the scan
620
+ * falls back to the flat text match this module used to be — which both over-reports
621
+ * (a `find` that PRINTS a path reads as a reach) and under-reports (a relative reach
622
+ * after a `cd` loses the leading slash a bare-name needle requires). An unevaluable
623
+ * `cd`, or a dropped line, leaves a HOLE instead: everything resolved before it is
624
+ * still validly resolved and is kept, and only what came after and needed a cwd is
625
+ * lost. Falling back for that case discarded good evidence AND reintroduced the flat
626
+ * scanner's over-reporting — one leading `cd $HOME` was enough to turn a pure
627
+ * directory listing into the "reached the answer key" verdict.
628
+ *
629
+ * A fallback that is not announced is the failure mode this whole module exists
630
+ * to prevent: `contaminated: false` from a degraded scan is written with exactly
631
+ * the same bytes as `contaminated: false` from a scan that actually looked.
632
+ * `signals` already separates "checked and clean" from "nothing was armed"; this
633
+ * separates "checked and clean" from "checked with the blunt instrument".
634
+ */
635
+ export const BASELINE_SCAN_DEGRADATION_REASONS = [
636
+ /** The transcript yielded no stream-json events at all, so there was nothing structured to walk. */
637
+ 'transcript-unparsed',
638
+ /** No `armWorkspaceDir` was threaded through, so no relative path can be anchored. */
639
+ 'cwd-unknown',
640
+ /** A `cd` the walker could not evaluate (`cd "$SOMEVAR"`, `cd -`, bare `cd`) unanchored every later path. */
641
+ 'cwd-untracked',
642
+ /**
643
+ * The transcript carried lines that were not JSON, so the structured walk saw a
644
+ * transcript with holes in it.
645
+ *
646
+ * This is the one degradation that is invisible from the OUTSIDE: a corrupted
647
+ * line is silently dropped by the parser, the surviving lines still decode, and
648
+ * {@link transcriptDecoded} — an any-of test satisfied by the terminal `result`
649
+ * line alone — still returns true. One truncated tool call therefore DELETES a
650
+ * contamination hit and the verdict reads `contaminated: false` from a scan that
651
+ * never saw the evidence.
652
+ */
653
+ 'transcript-malformed',
654
+ /**
655
+ * A reach carried a glob the walk cannot expand, and no needle matched it.
656
+ *
657
+ * Needle 3 (VAT's own directory NAME) exists because one `*` defeated the
658
+ * two-segment suffix scheme — but it is still a LITERAL segment, so a glob
659
+ * standing for the directory name itself walks straight past all three:
660
+ * `cat ../../vat-skill-test/&#42;/staged/&#42;/SKILL.md` fires and
661
+ * `cat ../../vat-&#42;/&#42;/staged/&#42;/SKILL.md` reports `hits: []` with
662
+ * `degraded: none`, which is byte-identical to a clean run. No expansion is
663
+ * attempted — that would need the filesystem, which is long gone by scan time —
664
+ * so the scan says it could not tell.
665
+ */
666
+ 'glob-unexpanded',
667
+ ];
668
+ /*
669
+ * ⛔ A `needle-seen-unresolved` REASON WAS BUILT HERE AND MEASURED OUT. DO NOT
670
+ * REBUILD IT WITHOUT READING THIS.
671
+ *
672
+ * The proposal: run the flat substring scan for the three DIRECTORY needles
673
+ * alongside the structured walk on every scan, and where the flat scan sees a
674
+ * needle the walk produced no hit for, record a DEGRADATION (never a hit, so the
675
+ * "a mention does not convict" ruling is untouched). The motivation is sound — the
676
+ * walk statically interprets arbitrary LLM-written shell, so its blind spots are
677
+ * unbounded, and several confirmed false negatives (shell variable indirection, a
678
+ * heredoc, `awk`'s bare first operand) leave the literal harness path in the
679
+ * transcript reading `hits: [] degraded: none`, byte-identical to a clean run.
680
+ *
681
+ * MEASURED AGAINST THIS SUITE'S OWN INNOCENT FIXTURES, in three progressively
682
+ * narrower forms:
683
+ *
684
+ * - over the whole transcript ......................... 23 clean fixtures degraded
685
+ * - only over Bash commands the walk resolved
686
+ * NOTHING out of (tool output and non-Bash inputs
687
+ * excluded) ......................................... 12 clean fixtures degraded
688
+ * - ...and only on the FULL-PATH needle of each group
689
+ * (dropping the two-segment suffix and bare-name
690
+ * spellings) ........................................ 5 clean fixtures degraded
691
+ *
692
+ * A degradation costs what a false positive costs — it stamps `⚠️ DEGRADED SCAN`
693
+ * on a verdict that was fine — and the previous round already had to narrow a new
694
+ * degradation that fired on 11 of 14 innocent commands.
695
+ *
696
+ * THE FIVE THAT SURVIVE THE NARROWEST FORM ARE WHY IT CANNOT BE NARROWED FURTHER,
697
+ * and they are all one shape: `find . -name "<root>/x"`, `sed -e "s|x|<root>/y|"
698
+ * notes.md`, `echo "…<root>…" >> notes.md`, `curl https://example.com<root>/…`, and
699
+ * a heredoc body. In every one the walk DID tokenize the needle-bearing word and
700
+ * classified it as a pattern, a script, emitted text, a URI or data — decisions
701
+ * this module spent four review rounds establishing. So the flat scan is not seeing
702
+ * something the walk missed; it is seeing the same text and refusing to make the
703
+ * distinction. Shipping the check means overturning five deliberate rulings.
704
+ *
705
+ * The one further narrowing considered — record every operand word the walk
706
+ * EXAMINED, and degrade only where the needle is in none of them — clears four of
707
+ * the five but keeps the heredoc (which contradicts "a heredoc body is DATA"), and
708
+ * costs the `awk` true positive, whose program IS an examined operand. That leaves
709
+ * the detector firing on one of the eight known false negatives, for new machinery
710
+ * inside the walk. Not worth it.
711
+ *
712
+ * Anything that revisits this needs a signal the flat scan does not have, not a
713
+ * narrower flat scan.
714
+ */
715
+ export const BaselineScanDegradationSchema = z.object({
716
+ reason: z.enum(BASELINE_SCAN_DEGRADATION_REASONS),
717
+ /** Human-readable specifics — the offending `cd`, or what the parse produced. */
718
+ detail: z.string(),
719
+ /** Which eval degraded. Absent on the value the detector returns; stamped by the caller. */
720
+ evalId: z.string().min(1).optional(),
721
+ }).strict();
722
+ export const BaselineIntegritySchema = z.object({
723
+ contaminated: z.boolean(),
724
+ /**
725
+ * The evals whose scan did not run at full strength, and why. Empty means every
726
+ * eval got a complete structured scan — the only state in which
727
+ * `contaminated: false` means "looked properly and found nothing".
728
+ *
729
+ * NOT all the same failure: `transcript-unparsed` fell back to the flat text
730
+ * match, while `cwd-untracked` and `transcript-malformed` kept every reach they
731
+ * had resolved and lost only what came after the hole. See
732
+ * {@link BASELINE_SCAN_DEGRADATION_REASONS}.
733
+ */
734
+ degraded: z.array(BaselineScanDegradationSchema),
735
+ /**
736
+ * Whether the two arms were graded against the same expectations, and so
737
+ * whether subtracting one summary from the other means anything.
738
+ *
739
+ * Separate from `contaminated` because they fail differently: contamination
740
+ * says the control HAD the treatment, comparability says the two numbers were
741
+ * never measuring the same thing. A run can be clean and incomparable.
742
+ */
743
+ comparable: z.boolean(),
744
+ /** The evals behind a `comparable: false`; empty when the arms agree. */
745
+ skew: z.array(BaselineArmSkewSchema),
746
+ /**
747
+ * Control-arm evals that produced no grade at all — see
748
+ * {@link BaselineControlArmFailureSchema}.
749
+ *
750
+ * A SEPARATE field from `skew` even though every entry here also produces a skew
751
+ * entry (an eval the control arm never graded has `withoutTotal: 0`), because the
752
+ * two say different things to an operator: skew says "the two graders disagreed
753
+ * about the job", this says "half the experiment did not run, and here is the
754
+ * spawn/grader failure that stopped it". Collapsing them would report a dead
755
+ * control arm as a grading-depth disagreement, which points triage at the grader
756
+ * prompt instead of at the timeout.
757
+ */
758
+ controlArmFailures: z.array(BaselineControlArmFailureSchema),
759
+ /** Human-readable verdict, safe to print verbatim. */
760
+ summary: z.string().min(1),
761
+ /**
762
+ * Which detectors were ARMED for this run.
763
+ *
764
+ * Without it, `contaminated: false` is written identically whether four signals
765
+ * were active or one, so a clean verdict cannot be told apart from a blind one
766
+ * — and the difference is large: the executable-name signal is the only one
767
+ * that sees an ambient copy of the skill in the adopter's own repo, and it does
768
+ * not exist for a skill that ships no executables.
769
+ *
770
+ * An empty array is the loudest case: nothing was looking, and `contaminated`
771
+ * says nothing at all.
772
+ */
773
+ signals: z.array(ContaminationSignalSchema),
774
+ findings: z.array(BaselineContaminationSchema),
775
+ }).strict();
776
+ /** Characters of transcript to keep either side of a match in an excerpt. */
777
+ const EXCERPT_RADIUS = 60;
778
+ /**
779
+ * Longest matched token an excerpt will quote in full.
780
+ *
781
+ * ⛔ THIS IS A BOUND, NOT A TUNING KNOB, AND IT USED TO BE ABSENT. `excerptAround`'s
782
+ * window is `±EXCERPT_RADIUS` around a token whose LENGTH the caller supplies, so the
783
+ * bound this function advertises is only real if that length is bounded too. It was
784
+ * not: {@link structuredToolReaches} computed `length` as `text.indexOf(leaf) === -1 ?
785
+ * text.length : leaf.length`, and `text` is `JSON.stringify(input)` — in which any leaf
786
+ * carrying a newline, a `"` or a `\` is present only in ESCAPED form and so is never
787
+ * found. One `Write` with an 8 KB `content` therefore produced an 8,097-character
788
+ * excerpt carrying a planted `AWS_SECRET_ACCESS_KEY=…` straight into `baseline.json`,
789
+ * which is a file adopters attach to bug reports. The `indexOf` is fixed at that call
790
+ * site; this clamp is the second line, so no future caller can reopen it by passing a
791
+ * length it did not measure.
792
+ */
793
+ const MAX_EXCERPT_TOKEN = 200;
794
+ /**
795
+ * A short excerpt around the first occurrence of `token`, collapsed to one line.
796
+ * Bounded so a huge tool result cannot bloat `baseline.json` — see
797
+ * {@link MAX_EXCERPT_TOKEN}, which is what makes that claim true rather than
798
+ * merely intended.
799
+ *
800
+ * ⚠️ KNOWN OPEN — nothing here is masked. The ±60 chars are raw transcript, and a
801
+ * hit inside an `env` dump has been observed carrying `AWS_SECRET_ACCESS_KEY=…`,
802
+ * `GITHUB_TOKEN=ghp_…` and `HOME=/Users/<name>` into `baseline.json` — the same
803
+ * values `formatForwardedEnvLine` bothers to mask on stderr. Fix: mask `KEY=value`
804
+ * pairs here. (The OTHER half of that note — that `match` leaked the absolute root
805
+ * — is CLOSED: see {@link redactPathMatch}. Do not restore the leak by reporting a
806
+ * raw needle.)
807
+ */
808
+ function excerptAround(haystack, index, tokenLength) {
809
+ const bounded = Math.min(Math.max(tokenLength, 0), MAX_EXCERPT_TOKEN);
810
+ const start = Math.max(0, index - EXCERPT_RADIUS);
811
+ const end = Math.min(haystack.length, index + bounded + EXCERPT_RADIUS);
812
+ const slice = haystack.slice(start, end).replaceAll(/\s+/g, ' ').trim();
813
+ return `${start > 0 ? '…' : ''}${slice}${end < haystack.length ? '…' : ''}`;
814
+ }
815
+ /**
816
+ * The same excerpt, but taken from the RAW text a {@link ScanHaystack} was built
817
+ * from — so what is reported is what the arm actually typed.
818
+ *
819
+ * The span is mapped end-INCLUSIVE (`source[last] + 1`) rather than by looking up
820
+ * `source[index + length]`, which would be out of range for a needle that runs to
821
+ * the end of the haystack and would silently start the slice at 0.
822
+ */
823
+ function excerptIn(haystack, index, length) {
824
+ const { source } = haystack;
825
+ if (source === undefined)
826
+ return excerptAround(haystack.normalized, index, length);
827
+ const start = source[index] ?? 0;
828
+ const end = (source[index + length - 1] ?? haystack.raw.length - 1) + 1;
829
+ return excerptAround(haystack.raw, start, end - start);
830
+ }
831
+ /** Most trailing segments of a path needle worth reporting in `match`. */
832
+ const MATCH_SEGMENT_LIMIT = 2;
833
+ /** What replaces a segment that is somebody's login name. */
834
+ const USER_SEGMENT_MASK = '<user>';
835
+ /**
836
+ * Directory names whose CHILD is a login name on some platform VAT runs on:
837
+ * `/Users/<name>` (macOS, and `C:/Users/<name>` on Windows), `/home/<name>`
838
+ * (Linux), and the pre-Vista Windows spelling.
839
+ */
840
+ const HOME_PARENT_SEGMENTS = new Set(['users', 'home', 'documents and settings']);
841
+ /**
842
+ * The segments of `fullPath` that are somebody's login name.
843
+ *
844
+ * ⛔ THE TWO-SEGMENT RULE ALONE DID NOT DO WHAT ITS DOCBLOCK CLAIMED. "The last two
845
+ * segments … name nobody" is true of the DEFAULT root (`<tmp>/vat-skill-test/<key>`)
846
+ * and false of the ordinary `--out ~/something` shape, which is a two-segment path
847
+ * whose FIRST segment is the login name: `/Users/jeffdutton/vat-out` reported as
848
+ * `…/jeffdutton/vat-out`, and `C:/Users/jeffdutton/out` as `…/jeffdutton/out`. The
849
+ * truncation is a privacy rule, so it has to survive the case where the name is
850
+ * inside the window it keeps — which is what this closes.
851
+ */
852
+ function loginNameSegments(fullPath) {
853
+ const segments = normalizedSegments(fullPath);
854
+ const names = new Set();
855
+ for (const [index, segment] of segments.entries()) {
856
+ const parent = index === 0 ? undefined : segments[index - 1];
857
+ if (parent !== undefined && HOME_PARENT_SEGMENTS.has(parent))
858
+ names.add(segment);
859
+ }
860
+ return names;
861
+ }
862
+ /**
863
+ * A needle, plus the string safe to write into an artifact when it matches.
864
+ *
865
+ * Two transforms, and both are load-bearing:
866
+ *
867
+ * 1. **Login names are masked.** See {@link loginNameSegments}.
868
+ * 2. **Anything shorter than what it came from is marked `…/`.** Truncation is
869
+ * invisible otherwise, and a reader who cannot tell `vat-skill-test/<key>` was
870
+ * cut out of a longer path reads it as the whole path. `truncated` is passed
871
+ * by the needle BUILDER, because a two-segment suffix needle is
872
+ * indistinguishable from a two-segment root once it is just a string — which
873
+ * is the bug this parameter exists to make impossible to reintroduce.
874
+ *
875
+ * A whole NAME (`vat-skill-test`, `vat-skill-evals-`, a sibling-arm token) is not a
876
+ * truncation of anything and is reported verbatim; an `…/` on it would advertise a
877
+ * cut that never happened.
878
+ */
879
+ function pathNeedle(needle, loginNames, truncated) {
880
+ const absolute = needle.startsWith('/');
881
+ const segments = normalizedSegments(needle)
882
+ .map((segment) => (loginNames.has(segment) ? USER_SEGMENT_MASK : segment));
883
+ if (segments.length > MATCH_SEGMENT_LIMIT) {
884
+ return { needle, match: `…/${segments.slice(-MATCH_SEGMENT_LIMIT).join('/')}` };
885
+ }
886
+ const body = segments.join('/');
887
+ return { needle, match: `${truncated ? '…/' : ''}${absolute ? '/' : ''}${body}` };
888
+ }
889
+ /**
890
+ * The needles that mean "this transcript named the OTHER arm's workspace".
891
+ *
892
+ * Two: the full path, and the bare per-arm token. The token is 16 hex characters
893
+ * minted per run, so a boundary-anchored match on it is essentially free of false
894
+ * positives — the arm's own token is different, and nothing else on the machine
895
+ * spells it.
896
+ *
897
+ * The LEADING `/` requirement (via {@link needsLeadingBoundary}) is what keeps
898
+ * `ls ..` honest: listing the parent prints the sibling's directory name as a bare
899
+ * basename on its own line, which is "I looked around", not "I read the other arm".
900
+ * Every genuine reach — `cat ../<token>/e1/out.md`, an absolute path, `find ..
901
+ * -name '*.md'` printing `../<token>/…` — carries the slash.
902
+ */
903
+ export function siblingArmNeedles(siblingArmDir) {
904
+ const full = foldDeclaredPath(siblingArmDir);
905
+ if (full === '')
906
+ return [];
907
+ const loginNames = loginNameSegments(full);
908
+ const token = normalizedSegments(full).at(-1);
909
+ // Longest first, so one reach is reported at its most specific spelling.
910
+ if (token === undefined || token === full)
911
+ return [pathNeedle(full, loginNames, false)];
912
+ return [pathNeedle(full, loginNames, false), pathNeedle(token, loginNames, false)];
913
+ }
914
+ /**
915
+ * The needles that mean "this transcript reached a dir only VAT is supposed to
916
+ * see" — the held eval suite (`<tmp>/vat-skill-evals-<token>`, which holds the
917
+ * `expected_output` ANSWER KEY for a fetched-artifact subject) and the grader dir
918
+ * (`<tmp>/vat-skill-grade-<token>`, which holds the run's integrity nonce).
919
+ *
920
+ * These are a FOURTH reachable class the harness-path detector cannot see: they
921
+ * are siblings of the arm's cwd under the OS temp dir, contain no harness path,
922
+ * and are two hops (`ls ../..`) or one `$TMPDIR` expansion away. Worse than
923
+ * reaching the skill — reaching the answer key inflates BOTH arms, so it cannot
924
+ * show up as a shrunken delta at all.
925
+ *
926
+ * Three needles, longest-first: the full path, the whole dir NAME, and the
927
+ * dir-name prefix with the per-run token stripped (`vat-skill-evals-`).
928
+ *
929
+ * The prefix is what catches the reach an arm can actually type. The token is 16
930
+ * random hex it has no way to know, so it globs — `cat $TMPDIR/vat-skill-evals-&#42;/
931
+ * evals.json` — and that string contains neither the full path nor the name. (One
932
+ * `&#42;` defeating a needle that embeds a random key is the same trap that once
933
+ * made the whole harness-suffix scheme inert.)
934
+ *
935
+ * The prefix needle carries no `/`, so {@link needsLeadingBoundary} requires one
936
+ * before it — without that, `ls $TMPDIR` printing `vat-skill-evals-abc` on its own
937
+ * line stamps a clean run contaminated. That rule is also why this must NEVER be
938
+ * built for the workspaces root: `vat-skill-test-ws-` is the arm's own legitimate
939
+ * cwd and its absolute path is in the arm's own prompt, so the needle would fire
940
+ * on every single clean run.
941
+ */
942
+ export function vatPrivateDirNeedles(dir) {
943
+ const full = foldDeclaredPath(dir);
944
+ if (full === '')
945
+ return [];
946
+ const segments = normalizedSegments(full);
947
+ const loginNames = loginNameSegments(full);
948
+ const name = segments.at(-1);
949
+ if (name === undefined || name === full)
950
+ return [pathNeedle(full, loginNames, false)];
951
+ const needles = [pathNeedle(full, loginNames, false), pathNeedle(name, loginNames, false)];
952
+ // Everything up to and including the final `-`, i.e. the part that is NOT the
953
+ // per-run token. Absent when the dir name carries no `-` at all.
954
+ const lastHyphen = name.lastIndexOf('-');
955
+ if (lastHyphen > 0)
956
+ needles.push(pathNeedle(name.slice(0, lastHyphen + 1), loginNames, false));
957
+ return needles;
958
+ }
959
+ /** How many verbatim lines to lift from the skill body as content needles. */
960
+ const SKILL_CONTENT_NEEDLE_COUNT = 3;
961
+ /**
962
+ * Shortest line worth using as a content needle.
963
+ *
964
+ * A needle this long is verbatim prose an arm does not reproduce by coincidence,
965
+ * which is what lets this signal skip the path-boundary machinery the other
966
+ * needles need: there is no shorter spelling of a 48-character sentence to guard
967
+ * against, and no `ls` output that prints one by accident.
968
+ */
969
+ const MIN_SKILL_CONTENT_NEEDLE_LENGTH = 48;
970
+ /** Line prefixes that make a line structural rather than distinctive prose. */
971
+ const STRUCTURAL_LINE_PREFIXES = ['#', '|', '>', '---', '```'];
972
+ /**
973
+ * Verbatim lines from the skill body that mean "this transcript SAW the skill".
974
+ *
975
+ * THE GAP THIS FILLS. Every other signal in this module needs the arm to name a
976
+ * PATH or an executable. An instruction-only skill — the common case — ships no
977
+ * executable, and the two ambient classes vat cannot remove (the adopter's own
978
+ * repo/build output, the installed plugin cache) produce no harness path. So
979
+ * `grep -rl "<phrase>" .` → `Read` → answer was completely invisible, and
980
+ * `contaminated: false` meant "saw no evidence" while reading as "verified none".
981
+ * Content is the only thing that survives all three of those steps: a `grep` hit
982
+ * line, a `Read` echo, and an answer that quotes the skill with no path attached.
983
+ *
984
+ * Selection is deterministic and needs no corpus: LONGEST first, because length is
985
+ * rarity for free. A candidate must be plain body prose —
986
+ *
987
+ * - frontmatter is skipped (the `name:` is the skill's own identifier and shows
988
+ * up in ordinary config and prose; it is not distinctive enough to accuse
989
+ * anyone with);
990
+ * - fenced code is skipped, since a code block is often copied from a shared
991
+ * upstream and is the same in every skill that wraps the same tool;
992
+ * - headings, table rows and block quotes are skipped as structural.
993
+ *
994
+ * 📌 A rule that lines carrying `"` or `\` are unusable has been REMOVED. It was
995
+ * true while the haystack was the RAW stream-json, where such characters are
996
+ * escaped and {@link normalizeForMatch} then rewrites the escape, so the needle
997
+ * could never match its own text. The content signal now reads
998
+ * {@link contentHaystackFor} — the DECODED assistant text, tool inputs and tool
999
+ * results — where there is no escaping to defeat, so those lines are usable
1000
+ * needles again, and dropping them was silently costing quote-heavy skills their
1001
+ * only signal. Re-add the rule only if the content haystack goes back to raw JSON.
1002
+ *
1003
+ * `excludedText` is every channel through which VAT ITSELF hands the arm text:
1004
+ * the run's eval prompts, its `expected_output`, its expectations, AND the
1005
+ * contents of the input `files` fixtures VAT stages into the arm's workspace and
1006
+ * tells it to work on. This is not optional care in any of the four. An adopter
1007
+ * who quotes a sentence of their SKILL.md in a prompt — or ships a fixture that
1008
+ * does — would otherwise stamp EVERY run contaminated, including the arm that
1009
+ * merely read the input it was given, and the attached triage instruction
1010
+ * ("uninstall the ambient copy of the plugin") would send the operator hunting
1011
+ * something that does not exist. The skill's own words reaching the arm through
1012
+ * vat are not the arm reaching the skill.
1013
+ */
1014
+ export function skillContentNeedles(skillMarkdown, excludedText = '') {
1015
+ const excluded = normalizeForMatch(excludedText);
1016
+ const candidates = [];
1017
+ for (const line of skillBodyLines(skillMarkdown)) {
1018
+ if (!isNeedleCandidate(line))
1019
+ continue;
1020
+ const needle = normalizeForMatch(line);
1021
+ if (excluded.includes(needle))
1022
+ continue;
1023
+ if (!candidates.includes(needle))
1024
+ candidates.push(needle);
1025
+ }
1026
+ // Longest first; ties keep document order, so the list is stable across runs of
1027
+ // the same skill and a reported `match` can be found in SKILL.md by eye.
1028
+ return candidates
1029
+ .map((needle, order) => ({ needle, order }))
1030
+ .sort((a, b) => b.needle.length - a.needle.length || a.order - b.order)
1031
+ .slice(0, SKILL_CONTENT_NEEDLE_COUNT)
1032
+ .map((c) => c.needle);
1033
+ }
1034
+ /** Trimmed body lines: frontmatter and fenced code stripped, everything else kept. */
1035
+ function skillBodyLines(skillMarkdown) {
1036
+ const lines = skillMarkdown.split('\n').map((line) => line.trim());
1037
+ const body = [];
1038
+ let inFrontmatter = lines[0] === '---';
1039
+ let inCode = false;
1040
+ for (const [index, line] of lines.entries()) {
1041
+ if (index === 0 && inFrontmatter)
1042
+ continue;
1043
+ if (inFrontmatter) {
1044
+ if (line === '---')
1045
+ inFrontmatter = false;
1046
+ continue;
1047
+ }
1048
+ if (line.startsWith('```')) {
1049
+ inCode = !inCode;
1050
+ continue;
1051
+ }
1052
+ if (!inCode)
1053
+ body.push(line);
1054
+ }
1055
+ return body;
1056
+ }
1057
+ /** Is this body line distinctive enough that quoting it means seeing the skill? */
1058
+ function isNeedleCandidate(line) {
1059
+ if (line.length < MIN_SKILL_CONTENT_NEEDLE_LENGTH)
1060
+ return false;
1061
+ return !STRUCTURAL_LINE_PREFIXES.some((prefix) => line.startsWith(prefix));
1062
+ }
1063
+ /**
1064
+ * Shortest declared executable path worth matching. A one- or two-character
1065
+ * spelling (`x`, `go`) occurs constantly in ordinary prose and JSON and would fire
1066
+ * on every run; below this length the check is pure noise, so it is skipped and
1067
+ * detection falls back to the harness-path signal.
1068
+ */
1069
+ const MIN_EXECUTABLE_PATH_LENGTH = 3;
1070
+ /**
1071
+ * The needle for one declared executable, or `undefined` when the declaration
1072
+ * cannot produce one.
1073
+ *
1074
+ * ⛔ THE ONE PLACE THE FLOOR AND THE FOLD ARE APPLIED. Every consumer —
1075
+ * {@link activeContaminationSignals}, {@link structuredExecutableHits},
1076
+ * {@link flatPathHits}, and the walk's `declaredBasenames` — goes through here, so
1077
+ * "armed" and "can match" are the SAME TEST rather than two tests that have to
1078
+ * agree. See {@link foldDeclaredPath} for why that is not a style preference.
1079
+ *
1080
+ * The floor is applied to the FOLDED value, not the raw one, and that is the half
1081
+ * that makes `signals` honest: `'///'` and `'./ab'` are respectively 3 and 4
1082
+ * characters of raw string and each armed the detector, while folding to `''` and
1083
+ * `'ab'` — nothing a needle can be built from.
1084
+ *
1085
+ * A leading `/` is STRIPPED because `executablePaths` is skill-RELATIVE by
1086
+ * contract: `/scripts/summary.py` is a legal spelling of `scripts/summary.py` in
1087
+ * `vibe-agent-toolkit.config.yaml`, whose schema is `path: z.string().min(1)` with
1088
+ * no shape constraint at all. Left on, it produced the needle `/scripts/summary.py`
1089
+ * and the matcher then asked whether a resolved path ends with
1090
+ * `//scripts/summary.py`, which no normalized path ever does.
1091
+ */
1092
+ function declaredExecutableNeedle(declared) {
1093
+ const folded = foldDeclaredPath(declared);
1094
+ const relative = folded.startsWith('/') ? folded.slice(1) : folded;
1095
+ return relative.length < MIN_EXECUTABLE_PATH_LENGTH ? undefined : relative;
1096
+ }
1097
+ /**
1098
+ * Does this resolved reach point AT the skill's declared executable — i.e. does it
1099
+ * end with the declared relative path, at a path boundary?
1100
+ *
1101
+ * ⛔ THE STEM TEST THIS REPLACES WAS A FALSE-POSITIVE ENGINE. It compared the
1102
+ * extension-stripped basename of any escaping reach against the extension-stripped
1103
+ * basename of the declaration, so a skill shipping `scripts/summary.py` convicted a
1104
+ * control arm for `sort data.csv > /tmp/summary.txt` followed by `cat
1105
+ * /tmp/summary.txt`; also confirmed firing were `python3 /tmp/summary.py`, `bash
1106
+ * /tmp/run.sh`, `python3 ~/analyze.py`, `diff /tmp/summary.txt /tmp/other.txt` and —
1107
+ * for a skill shipping `scripts/hosts.sh` — `cat /etc/hosts`. Every one of those is
1108
+ * a clean arm doing exactly what a control arm is supposed to do, told to discard
1109
+ * its run and to uninstall an ambient copy that does not exist. A check that
1110
+ * routinely destroys good runs trains people to ignore the one warning that matters,
1111
+ * so a false positive here is NOT the conservative direction.
1112
+ *
1113
+ * The relative PATH is what separates them, and it costs no detection: an ambient
1114
+ * copy — the adopter's build output, the installed plugin cache — is a COPY, so it
1115
+ * preserves the skill's internal layout and reproduces `scripts/summary.py` under
1116
+ * whatever root it sits at. `/tmp/summary.txt` and `/etc/hosts` do not.
1117
+ *
1118
+ * The residual, stated rather than hidden: a skill that declares an executable at
1119
+ * its own root (`summary.py`, no directory) is matched on that one segment, so an
1120
+ * unrelated `/tmp/summary.py` still fires. Nothing distinguishes them — the
1121
+ * declaration carries no more information than the name.
1122
+ */
1123
+ function reachIsDeclaredExecutable(resolved, declaredPath) {
1124
+ return resolved.endsWith(`/${declaredPath}`);
1125
+ }
1126
+ /**
1127
+ * Match an executable name only where it is pointed at BY A PATH, never as a bare
1128
+ * word and never as a bare filename.
1129
+ *
1130
+ * `deriveDeclaredExecutableNames` strips the extension, so `scripts/summary.py`
1131
+ * becomes the needle `summary` — a word that appears in ordinary assistant prose
1132
+ * on almost every run. A bare `indexOf` therefore reports `contaminated: true` on
1133
+ * clean runs, and the operator instruction attached to that verdict is "discard
1134
+ * the delta". A check that routinely destroys good runs trains people to ignore
1135
+ * the one warning that matters, so a false positive here is NOT the safe
1136
+ * direction — it is a different way to lose the measurement.
1137
+ *
1138
+ * This used to accept a second form, `name` followed by an extension anywhere
1139
+ * (`summary.py`, `report.md`), and that form was the defect: a review found 8/8
1140
+ * realistic CLEAN pairs firing, and 5 of them were exactly it — `python3
1141
+ * analyze.py`, `Wrote report.md`, `saved to summary.txt`, `built index.json`,
1142
+ * `created run.sh`. A control arm denied the skill writes its own script and gives
1143
+ * it the obvious name; a filename with no directory in front of it says nothing
1144
+ * about WHOSE file it is, so it can never be evidence. Only the path can be, and
1145
+ * a path is what {@link reachEscapesOwnWorkspace} then judges.
1146
+ *
1147
+ * The cost is the case this was already blind to and stays blind to: an executable
1148
+ * invoked bare on PATH (`csvsum data.csv`), or invoked by basename after a `cd`
1149
+ * into an ambient copy. The `cd` itself carries the path, which is what the other
1150
+ * detectors read.
1151
+ *
1152
+ * 📌 MEASURED, so nobody re-derives it: restoring the extension branch leaves the
1153
+ * whole suite green, because {@link reachEscapesOwnWorkspace} rejects a token with
1154
+ * no path root anyway. The branch is DEAD given that predicate, not merely
1155
+ * redundant — which is why it is deleted rather than kept as belt-and-braces.
1156
+ * Re-adding either form needs a reach that only it catches.
1157
+ *
1158
+ * ⚠️ DEGRADED-MODE ONLY. This regex, {@link pathTokenEndingAt} and
1159
+ * {@link reachEscapesOwnWorkspace} are now reached solely by {@link flatPathHits}.
1160
+ * The structured scan gets the same answer from a resolved absolute path and a
1161
+ * prefix test ({@link pathEscapesWorkspace}), with no shape-guessing at all.
1162
+ */
1163
+ function executableInvocationPattern(name) {
1164
+ // Every regex metacharacter in `name` is escaped first, so the constructed
1165
+ // source is a literal match for an adopter-declared basename and cannot inject
1166
+ // pattern syntax. `name` also has no user-controlled quantifier, so this is not
1167
+ // a ReDoS surface.
1168
+ const escaped = name.replaceAll(/[.*+?^${}()|[\]\\]/g, String.raw `\$&`);
1169
+ // Global because the caller walks EVERY occurrence — see `firstEscapingInvocation`.
1170
+ // A fresh instance is built per name per call, so the stateful `lastIndex` of a
1171
+ // `g` regex is never shared.
1172
+ // eslint-disable-next-line security/detect-non-literal-regexp -- source is built from the escaped literal above
1173
+ return new RegExp(String.raw `[/\\]${escaped}(?![\w-])`, 'g');
1174
+ }
1175
+ /**
1176
+ * Characters that continue a path token leftwards from a match.
1177
+ *
1178
+ * ⚠️ DEGRADED-MODE ONLY, and known-broken even there: SPACE is absent, so
1179
+ * `python3 "/Users/dev/My Projects/skill/scripts/csvsum.py"` truncates at the last
1180
+ * segment, the token loses its root, {@link reachEscapesOwnWorkspace} returns
1181
+ * false, and the reach reads CLEAN. The same defeats
1182
+ * `~/Library/Application Support/claude/plugins/…`, the standard macOS spelling of
1183
+ * the installed-plugin cache. It is not fixed here because it cannot be: a
1184
+ * character class scraping free text has no way to know where a quoted token ends.
1185
+ * The structured scan reads shell QUOTING instead ({@link readShellWord}), which
1186
+ * is where that class of path is now handled correctly.
1187
+ */
1188
+ const PATH_TOKEN_CHARS = /[\w./~$:-]/;
1189
+ /**
1190
+ * Expand left from `index` to the start of the path token the match sits in, and
1191
+ * return the token from there through the end of the match.
1192
+ *
1193
+ * The haystack is already normalized, so separators are `/` and the text is
1194
+ * lowercased — this walks characters, not path components, because the token is
1195
+ * embedded in free-form transcript text with no delimiters to parse against.
1196
+ */
1197
+ function pathTokenEndingAt(haystack, index, length) {
1198
+ let start = index;
1199
+ while (start > 0 && PATH_TOKEN_CHARS.test(haystack.charAt(start - 1)))
1200
+ start -= 1;
1201
+ return haystack.slice(start, index + length);
1202
+ }
1203
+ /**
1204
+ * Does this path reach OUTSIDE the control arm's own working tree?
1205
+ *
1206
+ * The whole question the executable-name signal has to answer is whose copy the
1207
+ * arm ran, and the answer is in the path root, not the name. The arm's cwd IS its
1208
+ * per-eval workspace, so a plain relative path is by definition its own scratch
1209
+ * file and is not evidence; a reach into an ambient copy — the adopter's repo, the
1210
+ * installed plugin cache — is absolute, `~`- or `$VAR`-rooted, or climbs out.
1211
+ *
1212
+ * Threading `armWorkspaceDir` in matters for exactly one form, and it is a real
1213
+ * one: the executor prompt states the arm's working directory ABSOLUTELY, so the
1214
+ * arm echoes and reuses that absolute path constantly. Without this suppression
1215
+ * every such self-reference would read as an escape.
1216
+ *
1217
+ * Worked cases, all against a normalized token:
1218
+ * `<armWorkspaceDir>/analyze.py` → false, the arm's own file, named absolutely
1219
+ * `scripts/analyze.py`, `./run.sh` → false, relative to the arm's own cwd
1220
+ * `docs.example.com/report` → false, a bare head is not a path root
1221
+ * `https://docs.example.com/report` → false, a URI is not a filesystem path
1222
+ * `/users/dev/repo/dist/…/analyze` → true, absolute and outside
1223
+ * `~/.claude/plugins/…/analyze.py` → true, home-rooted
1224
+ * `$tmpdir/…/analyze`, `c:/…/analyze`→ true, variable- and drive-rooted
1225
+ * `../../repo/dist/…/analyze` → true, climbs out
1226
+ *
1227
+ * ⛔ THE "RESIDUAL, ACCEPTED" NOTE THAT USED TO END THIS BLOCK WAS THE DEFECT.
1228
+ * It said an arm that `cd`s elsewhere and then uses relative paths reads as clean
1229
+ * here, and that this was fine because "the `cd` carries the absolute path, and
1230
+ * that is the other detectors' job". It does not: `cd vat-skill-test` after
1231
+ * `cd ../../..` carries no absolute path and no leading slash, so no detector saw
1232
+ * it and the arm ran the skill's own script on a `contaminated: false` verdict.
1233
+ * A blind spot handed to a neighbour who is also blind is not a residual.
1234
+ * The structured scan resolves the cwd instead; this predicate is now reached
1235
+ * only from the degraded flat fallback, where the same hole is still open and is
1236
+ * declared as degradation rather than assumed away.
1237
+ */
1238
+ /**
1239
+ * The FIRST invocation of `name` whose path escapes the arm's own workspace, as
1240
+ * `{ index, length }` into the normalized haystack — or `undefined` when every
1241
+ * occurrence is the arm's own file. Degraded-mode only; the structured scan reads
1242
+ * the same evidence off {@link PathReach}.
1243
+ *
1244
+ * Scanning every occurrence rather than testing the first is the difference
1245
+ * between a working detector and a decorative one: a control arm that writes
1246
+ * `scripts/analyze.py` and later reads an ambient copy mentions the benign form
1247
+ * first almost every time.
1248
+ */
1249
+ function firstEscapingInvocation(haystack, normalizedName, armWorkspaceDir) {
1250
+ for (const match of haystack.matchAll(executableInvocationPattern(normalizedName))) {
1251
+ const token = pathTokenEndingAt(haystack, match.index, match[0].length);
1252
+ if (reachEscapesOwnWorkspace(token, armWorkspaceDir)) {
1253
+ return { index: match.index, length: match[0].length };
1254
+ }
1255
+ }
1256
+ return undefined;
1257
+ }
1258
+ function reachEscapesOwnWorkspace(token, armWorkspaceDir) {
1259
+ if (armWorkspaceDir !== '' && token.startsWith(armWorkspaceDir))
1260
+ return false;
1261
+ if (token.startsWith('/'))
1262
+ return true;
1263
+ if (token.includes('../'))
1264
+ return true;
1265
+ const separator = token.indexOf('/');
1266
+ // No separator at all means no path root, so nothing says whose file it is —
1267
+ // the plain `report.md` an arm writes and reports. The pattern above cannot
1268
+ // currently produce such a token (it starts AT a separator), so this is stated
1269
+ // rather than relied on: it is what makes the pattern's narrowness a statement
1270
+ // of intent instead of the only thing holding the check together.
1271
+ if (separator === -1)
1272
+ return false;
1273
+ const head = token.slice(0, separator);
1274
+ // A URI is not a filesystem path, so it can never be evidence that anything
1275
+ // RAN. It reaches here because `https:/…` (the run-collapse eats the double
1276
+ // slash) has the same shape as a Windows drive root — and the two are told
1277
+ // apart by length, because a drive is exactly one letter.
1278
+ if (head.endsWith(':'))
1279
+ return head.length === 2;
1280
+ return !/^[\w.-]*$/.test(head);
1281
+ }
1282
+ /**
1283
+ * The first needle in `needles` that matches at a path boundary, as a hit of
1284
+ * `kind` — or `undefined` when none does. Needle sets are ordered longest-first,
1285
+ * so "first" is "most specific spelling that matched", and one reach is reported
1286
+ * once rather than once per spelling of itself.
1287
+ */
1288
+ function firstNeedleHit(haystack, needles, kind) {
1289
+ for (const { needle, match } of needles) {
1290
+ const index = indexOfPathAtBoundary(haystack.normalized, needle);
1291
+ if (index === -1)
1292
+ continue;
1293
+ return { kind, match, excerpt: excerptIn(haystack, index, needle.length) };
1294
+ }
1295
+ return undefined;
1296
+ }
1297
+ /**
1298
+ * The first RESOLVED PATH matching any of `needles`, as a hit of `kind`.
1299
+ *
1300
+ * Needles run in their declared longest-first order in the OUTER loop, so one
1301
+ * reach is still reported at its most specific spelling; reaches run in
1302
+ * transcript order inside it. `claimed` records the resolved path so the
1303
+ * executable signal below does not report the same reach a second time.
1304
+ */
1305
+ function firstReachHit(reaches, needles, kind, claimed) {
1306
+ for (const { needle, match } of needles) {
1307
+ for (const reach of reaches) {
1308
+ if (!containsPathAtBoundary(reach.resolved, needle))
1309
+ continue;
1310
+ claimed.add(reach.resolved);
1311
+ return { kind, match, excerpt: excerptAround(reach.text, reach.index, reach.length) };
1312
+ }
1313
+ }
1314
+ return undefined;
1315
+ }
1316
+ /**
1317
+ * The first content needle present in the transcript, as a hit — or `undefined`.
1318
+ *
1319
+ * A plain substring test, deliberately: these needles are whole verbatim lines,
1320
+ * not paths, so there is no boundary to enforce and no shorter spelling of one to
1321
+ * be defeated by. First match wins, as in every other group — one reach, one hit.
1322
+ */
1323
+ function firstContentHit(haystack, needles) {
1324
+ for (const needle of needles) {
1325
+ const index = haystack.normalized.indexOf(needle);
1326
+ if (index !== -1) {
1327
+ return { kind: KIND_SKILL_CONTENT, match: needle, excerpt: excerptIn(haystack, index, needle.length) };
1328
+ }
1329
+ }
1330
+ return undefined;
1331
+ }
1332
+ /**
1333
+ * The haystack the CONTENT signal reads: assistant prose, every tool input, and
1334
+ * every tool RESULT — the decoded values, not the raw JSON line.
1335
+ *
1336
+ * Decoded matters twice over. Against the raw stream-json, a needle carrying a
1337
+ * `"` or a `\` could never match its own text (the transcript holds the ESCAPED
1338
+ * form), which is why {@link isNeedleCandidate} used to drop such lines on the
1339
+ * floor. Against the decoded values there is no escaping to defeat, so those
1340
+ * lines are usable needles again.
1341
+ *
1342
+ * Deliberately WIDER than "tool output and assistant text": an arm that writes a
1343
+ * sentence of the skill into a file (`echo "<skill prose>" > notes.md`) has
1344
+ * demonstrably seen it, and that evidence lives in a tool INPUT. Unlike a path,
1345
+ * a verbatim line of skill prose cannot arrive by orientation — there is no `ls`
1346
+ * that prints one — so widening the content haystack cannot reproduce defect (b).
1347
+ */
1348
+ function contentHaystackFor(parsed, transcript) {
1349
+ const parts = [parsed.text];
1350
+ for (const use of parsed.toolUses)
1351
+ parts.push(use.command ?? renderToolInput(use.input));
1352
+ for (const result of parsed.toolResults)
1353
+ parts.push(result.content);
1354
+ const joined = parts.join('\n');
1355
+ // A transcript we could not decode at all still gets scanned, in its raw form:
1356
+ // a content needle found in escaped JSON is still a content needle found.
1357
+ //
1358
+ // The DECODED join is what the excerpt quotes from when there is one, which is
1359
+ // the right raw text here: it is the arm's own prose and its own commands, with
1360
+ // the stream-json escaping already undone. Quoting the JSON line instead would
1361
+ // hand the operator a string that is not in their transcript either.
1362
+ return scanHaystack(joined.trim() === '' ? transcript : joined);
1363
+ }
1364
+ function renderToolInput(input) {
1365
+ if (typeof input === 'string')
1366
+ return input;
1367
+ if (input === undefined || input === null)
1368
+ return '';
1369
+ try {
1370
+ return JSON.stringify(input) ?? '';
1371
+ }
1372
+ catch (error) {
1373
+ // `JSON.stringify` throws exactly one class — TypeError, for a cycle or a
1374
+ // BigInt — and an input that cannot be rendered has no text to scan. Neither
1375
+ // can arrive from a `JSON.parse`d transcript; the narrowing is here so that a
1376
+ // throw from anything else (a `toJSON` on a foreign object) stays loud.
1377
+ if (error instanceof TypeError)
1378
+ return '';
1379
+ throw error;
1380
+ }
1381
+ }
1382
+ /**
1383
+ * Did the transcript decode into anything at all?
1384
+ *
1385
+ * `parseStreamJsonTranscript` is deliberately tolerant — an unparseable line is
1386
+ * skipped, not thrown on — so "it returned an object" is not evidence it worked.
1387
+ * A non-empty transcript that produced no text, no tool calls, no tool results
1388
+ * and no terminal `result` event was not stream-json, and the structured scan has
1389
+ * nothing to walk.
1390
+ */
1391
+ function transcriptDecoded(parsed) {
1392
+ return (parsed.text !== '' ||
1393
+ parsed.toolUses.length > 0 ||
1394
+ parsed.toolResults.length > 0 ||
1395
+ parsed.result !== undefined);
1396
+ }
1397
+ const TOOL_FIELDS = new Map([
1398
+ ['read', { intent: 'retrieval', paths: ['file_path', 'path', 'notebook_path'], prose: [] }],
1399
+ ['notebookread', { intent: 'retrieval', paths: ['notebook_path', 'path'], prose: [] }],
1400
+ ['grep', { intent: 'retrieval', paths: ['path', 'paths', 'glob'], prose: ['pattern', 'output_mode', 'type'] }],
1401
+ ['webfetch', { intent: 'retrieval', paths: ['url'], prose: ['prompt'] }],
1402
+ ['glob', { intent: 'enumeration', paths: ['pattern', 'path'], prose: [] }],
1403
+ ['ls', { intent: 'enumeration', paths: ['path', 'paths'], prose: ['ignore'] }],
1404
+ ['listdir', { intent: 'enumeration', paths: ['path', 'paths'], prose: [] }],
1405
+ ['write', { intent: 'write', paths: ['file_path', 'path'], prose: ['content'] }],
1406
+ ['edit', { intent: 'write', paths: ['file_path', 'path'], prose: ['old_string', 'new_string', 'content'] }],
1407
+ ['multiedit', { intent: 'write', paths: ['file_path', 'path'], prose: ['old_string', 'new_string', 'edits'] }],
1408
+ ['notebookedit', { intent: 'write', paths: ['notebook_path', 'path'], prose: ['new_source', 'old_source'] }],
1409
+ ['task', { intent: 'other', paths: [], prose: ['prompt', 'description', 'subagent_type'] }],
1410
+ ['todowrite', { intent: 'other', paths: [], prose: ['todos', 'content', 'activeform', 'status'] }],
1411
+ ['websearch', { intent: 'other', paths: [], prose: ['query'] }],
1412
+ ['bash', { intent: 'other', paths: [], prose: ['command', 'description'] }],
1413
+ ]);
1414
+ /**
1415
+ * Field names that name a path in every tool anyone has shipped.
1416
+ *
1417
+ * This is the forward-compatibility half of {@link TOOL_FIELDS}: it is what still
1418
+ * fires on `SomeFutureReader({ target_document: … })`, a tool this code has never
1419
+ * heard of under a field name it has never heard of.
1420
+ */
1421
+ const PATH_FIELD_NAME = /path|file|dir|url|uri|cwd|source|dest|target|glob|location|notebook|folder/;
1422
+ /**
1423
+ * Is this leaf a path OPERAND, judged without knowing the tool?
1424
+ *
1425
+ * Two ways to qualify, and the second is what keeps prose out. A field NAME that
1426
+ * says "path" is taken at its word. Otherwise the value must be a LONE TOKEN —
1427
+ * no whitespace anywhere in it — because every confirmed false positive in this
1428
+ * class was a path embedded in a sentence, and a sentence has spaces. The cost is
1429
+ * stated rather than hidden: a quoted path containing a space, under an
1430
+ * unrecognised field name of an unrecognised tool, is not seen. Put the field on
1431
+ * that tool's `paths` list when such a tool appears; the allowlist bypasses this.
1432
+ */
1433
+ function heuristicPathField(key, value) {
1434
+ return PATH_FIELD_NAME.test(key) || !/\s/.test(value);
1435
+ }
1436
+ /**
1437
+ * Walk the transcript's tool calls in order, resolving every path they name
1438
+ * against the cwd in force at that point.
1439
+ *
1440
+ * TWO THINGS THIS GETS RIGHT THAT THE PREVIOUS VERSION DID NOT, both of which were
1441
+ * silent:
1442
+ *
1443
+ * **A Task subagent has its own working directory.** `parsed.toolUses` carries
1444
+ * `parentToolUseId` — the parser has always populated it and nothing read it — so a
1445
+ * subagent's `cd /elsewhere` re-anchored every later relative path of the MAIN
1446
+ * agent, and a subagent's `cd $VAR` degraded the main scan. Each parent id gets its
1447
+ * own cwd and its own degradation.
1448
+ *
1449
+ * **Degradation runs FORWARD, not backward.** The old docblock claimed "`untracked`
1450
+ * is set the moment a `cd` cannot be evaluated", and that was a description of the
1451
+ * flag, not of the coverage: the caller then threw away EVERY structured reach,
1452
+ * including the ones resolved BEFORE the bad `cd`, and reran the flat scanner over
1453
+ * the whole transcript. One leading `cd $HOME` therefore made the canonical
1454
+ * four-step reach chain read `hits: []`, and one leading `cd $HOME` plus a `find`
1455
+ * that merely LISTED the temp dir produced `vat-private-dir` — the "reached the
1456
+ * answer key" verdict — because that is what the flat scanner does with a listing.
1457
+ * Reaches resolved before the uncertainty are still validly resolved. After it, the
1458
+ * cwd becomes `undefined` and only tokens that do not need one (absolute,
1459
+ * drive-rooted, `~`- or `$VAR`-rooted, `file://`) are still resolved.
1460
+ */
1461
+ function walkToolReaches(parsed, startCwd, ctx) {
1462
+ const reaches = [];
1463
+ const cwds = new Map();
1464
+ const holes = noCwdHoles();
1465
+ for (const use of parsed.toolUses) {
1466
+ const context = use.parentToolUseId;
1467
+ const cwd = cwds.has(context) ? cwds.get(context) : startCwd;
1468
+ if (use.command === undefined) {
1469
+ reaches.push(...structuredToolReaches(use.name, use.input, cwd));
1470
+ continue;
1471
+ }
1472
+ const walked = walkBashCommand(use.command, cwd, ctx);
1473
+ reaches.push(...walked.reaches);
1474
+ cwds.set(context, walked.cwd);
1475
+ // ⛔ THE WIDEST HOLE, ACROSS CALLS TOO — not the first one the transcript
1476
+ // happened to contain. See {@link CwdHoles}.
1477
+ holes.full ??= walked.holes.full;
1478
+ holes.scoped ??= walked.holes.scoped;
1479
+ }
1480
+ const untracked = worstCwdHole(holes);
1481
+ return { reaches, ...(untracked === undefined ? {} : { untracked }) };
1482
+ }
1483
+ /** Paths named as OPERANDS by a NON-Bash tool call — see {@link TOOL_FIELDS}. */
1484
+ function structuredToolReaches(name, input, cwd) {
1485
+ const spec = TOOL_FIELDS.get(name.toLowerCase());
1486
+ const text = renderToolInput(input);
1487
+ const reaches = [];
1488
+ for (const { key, value } of stringLeaves(input, '')) {
1489
+ if (!isToolPathOperand(spec, key, value))
1490
+ continue;
1491
+ const resolved = resolvePathToken(value, cwd);
1492
+ if (resolved === undefined)
1493
+ continue;
1494
+ // ⛔ `length` IS THE EXCERPT BOUND. It used to be `indexOf(leaf) === -1 ?
1495
+ // text.length : leaf.length`, and `text` is `JSON.stringify(input)` — in which a
1496
+ // leaf carrying a newline, a `"` or a `\` appears only escaped and so is never
1497
+ // found. That turned one `Write` into an 8,097-character excerpt carrying a
1498
+ // planted secret. The token's own length is the only bound that is always right.
1499
+ const index = text.indexOf(value);
1500
+ reaches.push({
1501
+ resolved,
1502
+ intent: spec?.intent ?? 'other',
1503
+ text,
1504
+ index: index === -1 ? 0 : index,
1505
+ length: value.length,
1506
+ });
1507
+ }
1508
+ return reaches;
1509
+ }
1510
+ function isToolPathOperand(spec, key, value) {
1511
+ const field = key.toLowerCase();
1512
+ if (spec?.prose.includes(field) === true)
1513
+ return false;
1514
+ if (spec?.paths.includes(field) === true)
1515
+ return isPathCandidate(value);
1516
+ return isPathCandidate(value) && heuristicPathField(field, value);
1517
+ }
1518
+ /**
1519
+ * Every string in a tool input, however deeply nested, tagged with the nearest
1520
+ * object key. An array's elements inherit the key of the array itself, so
1521
+ * `{ paths: [a, b] }` and `{ todos: [{ content: … }] }` both classify correctly.
1522
+ * Bounded by the input itself.
1523
+ */
1524
+ function stringLeaves(value, key) {
1525
+ if (typeof value === 'string')
1526
+ return [{ key, value }];
1527
+ if (Array.isArray(value))
1528
+ return value.flatMap((v) => stringLeaves(v, key));
1529
+ if (value !== null && typeof value === 'object') {
1530
+ return Object.entries(value).flatMap(([k, v]) => stringLeaves(v, k));
1531
+ }
1532
+ return [];
1533
+ }
1534
+ /* ────────────────────────── shell reading ────────────────────────── */
1535
+ /**
1536
+ * Shell control operators, LONGEST FIRST.
1537
+ *
1538
+ * ⚠️ ONE PAIR OF THIS ORDERING IS LOAD-BEARING AND THE REST IS READABILITY, which is
1539
+ * worth writing down because the old comment ("the first six end a command") was
1540
+ * self-contradicting — `>>` does not end a command and `\n` does, and neither was in
1541
+ * the six. `||` MUST precede `|`: {@link walkBashCommand} degrades a `cd` on the
1542
+ * right of a `||` and does not degrade one after a pipe, so reading `||` as two `|`
1543
+ * silently reopens the `cd one || cd two` false positive. Reordering `&&` before
1544
+ * `&`, or `>>` before `>`, was MEASURED to change no verdict — both spellings split
1545
+ * the same segments and mark the same redirect target — so do not add a test that
1546
+ * pretends otherwise; keep the order because a `&`-then-`&` token stream is a lie
1547
+ * about what the arm typed.
1548
+ *
1549
+ * `<`, `>` and `>>` end a WORD only: their operand belongs to the command that owns
1550
+ * them (`sort < ../../skill/notes.md` reaches through a redirect, not through a new
1551
+ * command). `(` and `)` end a command AND open/close a subshell — see
1552
+ * {@link walkBashCommand} for why the difference from a `{ … }` brace group is not
1553
+ * cosmetic.
1554
+ */
1555
+ const SHELL_OPERATORS = ['&&', '||', '>>', ';', '|', '&', '<', '>', '(', ')', '\n'];
1556
+ const SEGMENT_OPERATORS = new Set(['&&', '||', ';', '|', '&', '(', ')', '\n']);
1557
+ const INPUT_REDIRECT = '<';
1558
+ const OUTPUT_REDIRECTS = new Set(['>', '>>']);
1559
+ const SUBSHELL_OPEN = '(';
1560
+ const SUBSHELL_CLOSE = ')';
1561
+ const OR_ELSE = '||';
1562
+ /** The character comments, heredoc bodies and line continuations are blanked to. */
1563
+ const BLANK = ' ';
1564
+ function blankRange(chars, from, to) {
1565
+ for (let i = Math.max(from, 0); i < to && i < chars.length; i += 1)
1566
+ chars[i] = BLANK;
1567
+ }
1568
+ function endOfLine(chars, from) {
1569
+ const at = chars.indexOf('\n', from);
1570
+ return at === -1 ? chars.length : at;
1571
+ }
1572
+ /**
1573
+ * Blank `#` comments and join `\`-newline continuations, quote-aware.
1574
+ *
1575
+ * BLANKED RATHER THAN DELETED, and that is the whole trick: every
1576
+ * {@link ShellToken} carries an index into the ORIGINAL command so an excerpt can
1577
+ * quote what the arm actually typed, and deleting characters would skew every one
1578
+ * of them. Replacing with spaces is length-preserving, so the tokenizer sees a
1579
+ * command with the noise removed while the indices still address the real text.
1580
+ *
1581
+ * Both forms were parsed as ordinary command lines before this existed:
1582
+ * `ls -la # nothing under /tmp/vat-skill-test` convicted on the COMMENT, and
1583
+ * `ls -la \` + newline + `<path>` made the path the segment HEAD, which
1584
+ * {@link commandIntent} then classified as retrieval ("executing a file by path")
1585
+ * and reported as a `declared-executable` run.
1586
+ */
1587
+ function blankCommentsAndContinuations(chars) {
1588
+ let quote;
1589
+ let atWordStart = true;
1590
+ let resumeAt = 0;
1591
+ for (const [i, ch] of chars.entries()) {
1592
+ if (i < resumeAt)
1593
+ continue;
1594
+ if (quote !== undefined) {
1595
+ quote = ch === quote ? undefined : quote;
1596
+ continue;
1597
+ }
1598
+ resumeAt = blankNoiseAt(chars, i, ch, atWordStart);
1599
+ if (ch === "'" || ch === '"')
1600
+ quote = ch;
1601
+ atWordStart = WORD_BOUNDARY_CHARS.has(ch);
1602
+ }
1603
+ }
1604
+ /**
1605
+ * Blank the comment or continuation starting at `i`, and return where scanning
1606
+ * resumes. A no-op (returns `i`) for every other character.
1607
+ */
1608
+ function blankNoiseAt(chars, i, ch, atWordStart) {
1609
+ if (ch === '\\') {
1610
+ if (chars[i + 1] === '\n')
1611
+ blankRange(chars, i, i + 2);
1612
+ return i + 2;
1613
+ }
1614
+ if (ch !== '#' || !atWordStart)
1615
+ return i;
1616
+ const stop = endOfLine(chars, i + 1);
1617
+ blankRange(chars, i, stop);
1618
+ return stop;
1619
+ }
1620
+ /** Characters after which the next character begins a fresh shell word. */
1621
+ const WORD_BOUNDARY_CHARS = new Set([' ', '\t', '\n', ';', '&', '|', '(', ')', '']);
1622
+ /**
1623
+ * Blank every heredoc body and its `<<WORD` marker.
1624
+ *
1625
+ * A heredoc body is DATA, and it was being tokenized as command lines. A body
1626
+ * containing `cd /tmp/vat-skill-test/<key>` therefore both emitted a false
1627
+ * `harness-path` hit and poisoned the tracked cwd for the whole rest of the
1628
+ * transcript — the second of which is the worse half, because it is silent.
1629
+ */
1630
+ const HEREDOC_MARKER = /<<-?\s*(['"]?)([A-Za-z_][\w-]*)\1/g;
1631
+ function blankHeredocBodies(chars) {
1632
+ const text = chars.join('');
1633
+ for (const match of text.matchAll(HEREDOC_MARKER)) {
1634
+ const delimiter = match[2];
1635
+ if (delimiter === undefined)
1636
+ continue;
1637
+ blankRange(chars, match.index, match.index + match[0].length);
1638
+ const bodyStart = text.indexOf('\n', match.index + match[0].length);
1639
+ if (bodyStart === -1)
1640
+ continue;
1641
+ blankRange(chars, bodyStart, heredocBodyEnd(text, bodyStart + 1, delimiter));
1642
+ }
1643
+ }
1644
+ function heredocBodyEnd(text, from, delimiter) {
1645
+ let cursor = from;
1646
+ while (cursor <= text.length) {
1647
+ const lineEnd = text.indexOf('\n', cursor);
1648
+ const stop = lineEnd === -1 ? text.length : lineEnd;
1649
+ if (text.slice(cursor, stop).trim() === delimiter)
1650
+ return stop;
1651
+ if (lineEnd === -1)
1652
+ return text.length;
1653
+ cursor = lineEnd + 1;
1654
+ }
1655
+ return text.length;
1656
+ }
1657
+ /**
1658
+ * The command with comments, heredoc bodies and continuations blanked out —
1659
+ * SAME LENGTH as the original, so every token index still addresses it.
1660
+ */
1661
+ function sanitizeCommandText(command) {
1662
+ // Code UNITS, not code points: every token index is a `String.prototype` index,
1663
+ // so a spread (which splits by code point) would desynchronize on an astral char.
1664
+ const chars = Array.from({ length: command.length }, (_, i) => command.charAt(i));
1665
+ blankCommentsAndContinuations(chars);
1666
+ blankHeredocBodies(chars);
1667
+ return chars.join('');
1668
+ }
1669
+ /**
1670
+ * Characters a backslash may escape.
1671
+ *
1672
+ * Deliberately a SET rather than "the next character, whatever it is": a Windows
1673
+ * path (`C:\repo\dist\summary.mjs`) is a perfectly ordinary token in a transcript,
1674
+ * and an unconditional escape rule eats its separators and leaves
1675
+ * `c:repodistsummary.mjs` — which matches no needle and reports clean. The
1676
+ * members here are the ones a shell actually needs escaped, and `\ ` is the one
1677
+ * that matters: `/Users/dev/My\ Projects/…` is a single path token.
1678
+ */
1679
+ const SHELL_ESCAPABLE = new Set([' ', '\t', '"', "'", '\\', '$', '&', '|', ';', '(', ')', '<', '>', '*', '?', '`']);
1680
+ function matchOperator(command, at) {
1681
+ return SHELL_OPERATORS.find((op) => command.startsWith(op, at));
1682
+ }
1683
+ /**
1684
+ * Read one shell WORD, honouring `'…'`, `"…"` and backslash escapes.
1685
+ *
1686
+ * Quote-awareness is not cosmetic. `PATH_TOKEN_CHARS` — the flat scan's way of
1687
+ * finding a path — excludes space, so it truncated `python3 "/Users/dev/My
1688
+ * Projects/skill/scripts/csvsum.py"` at the last segment, left the token with no
1689
+ * root, and reported CLEAN. The same held for
1690
+ * `~/Library/Application Support/claude/plugins/…`, which is the standard macOS
1691
+ * spelling of the installed-plugin cache this detector mainly exists to catch.
1692
+ */
1693
+ /**
1694
+ * The end of a `$( … )` or `` ` … ` `` substitution starting at `at`, or -1.
1695
+ *
1696
+ * Substitutions are swallowed WHOLE rather than tokenized, because `(` and `)` are
1697
+ * now segment operators: without this, `cat $(pwd)/notes.md` tokenizes as `$`, `(`,
1698
+ * `pwd`, `)`, `/notes.md` — and that last fragment resolves to the absolute path
1699
+ * `/notes.md`, a reach into the filesystem ROOT that the arm never made.
1700
+ */
1701
+ /**
1702
+ * Every `$( … )` / `` ` … ` `` body in a command, with its offset.
1703
+ *
1704
+ * Swallowing a substitution whole keeps its EXPANSION out of the path model, which
1705
+ * is right — nothing here can know what `$(pwd)` is. It also hid the commands
1706
+ * INSIDE it, which is not: `echo done $(cat <ambient-copy>/summary.py)` really does
1707
+ * read that file. The bodies are walked separately, in subshell semantics (a `cd`
1708
+ * inside one cannot move the caller), and their reaches are rebased onto the outer
1709
+ * command so the excerpt still quotes what the arm typed.
1710
+ */
1711
+ function substitutionBodies(command) {
1712
+ const bodies = [];
1713
+ let i = 0;
1714
+ while (i < command.length) {
1715
+ const end = substitutionEnd(command, i);
1716
+ if (end === -1) {
1717
+ i += 1;
1718
+ continue;
1719
+ }
1720
+ const open = command.charAt(i) === '`' ? 1 : 2;
1721
+ bodies.push({ text: command.slice(i + open, end - 1), offset: i + open });
1722
+ i = end;
1723
+ }
1724
+ return bodies;
1725
+ }
1726
+ function substitutionEnd(command, at) {
1727
+ if (command.startsWith('$(', at)) {
1728
+ const close = command.indexOf(')', at + 2);
1729
+ return close === -1 ? command.length : close + 1;
1730
+ }
1731
+ if (command.charAt(at) === '`') {
1732
+ const close = command.indexOf('`', at + 1);
1733
+ return close === -1 ? command.length : close + 1;
1734
+ }
1735
+ return -1;
1736
+ }
1737
+ function readShellWord(command, start) {
1738
+ let text = '';
1739
+ let i = start;
1740
+ while (i < command.length) {
1741
+ const ch = command.charAt(i);
1742
+ if (ch === ' ' || ch === '\t' || ch === '\r')
1743
+ break;
1744
+ const substitution = substitutionEnd(command, i);
1745
+ if (substitution !== -1) {
1746
+ text += command.slice(i, substitution);
1747
+ i = substitution;
1748
+ continue;
1749
+ }
1750
+ if (matchOperator(command, i) !== undefined)
1751
+ break;
1752
+ if (ch === "'" || ch === '"') {
1753
+ const close = command.indexOf(ch, i + 1);
1754
+ if (close === -1) {
1755
+ text += command.slice(i + 1);
1756
+ i = command.length;
1757
+ break;
1758
+ }
1759
+ text += command.slice(i + 1, close);
1760
+ i = close + 1;
1761
+ continue;
1762
+ }
1763
+ if (ch === '\\' && SHELL_ESCAPABLE.has(command.charAt(i + 1))) {
1764
+ text += command.charAt(i + 1);
1765
+ i += 2;
1766
+ continue;
1767
+ }
1768
+ text += ch;
1769
+ i += 1;
1770
+ }
1771
+ return { text, index: start, length: i - start, operator: false };
1772
+ }
1773
+ function tokenizeShell(rawCommand) {
1774
+ const command = sanitizeCommandText(rawCommand);
1775
+ const tokens = [];
1776
+ let i = 0;
1777
+ while (i < command.length) {
1778
+ const ch = command.charAt(i);
1779
+ if (ch === ' ' || ch === '\t' || ch === '\r') {
1780
+ i += 1;
1781
+ continue;
1782
+ }
1783
+ const operator = matchOperator(command, i);
1784
+ if (operator !== undefined) {
1785
+ tokens.push({ text: operator, index: i, length: operator.length, operator: true });
1786
+ i += operator.length;
1787
+ continue;
1788
+ }
1789
+ const word = readShellWord(command, i);
1790
+ tokens.push(word);
1791
+ // A zero-length word would spin forever; it cannot happen (the operator and
1792
+ // whitespace branches above consume every character that would produce one),
1793
+ // so this is a guard on the invariant rather than a case.
1794
+ i = word.length === 0 ? i + 1 : word.index + word.length;
1795
+ }
1796
+ return tokens;
1797
+ }
1798
+ /**
1799
+ * Split a tokenized command at the operators that END a command.
1800
+ *
1801
+ * EMPTY SEGMENTS ARE KEPT, unlike before. `( cd x )` ends with a `)` and nothing
1802
+ * after it, and dropping that empty tail would drop the only record that the
1803
+ * subshell CLOSED — leaving the `cd` applied to the caller's cwd, which is the one
1804
+ * thing a subshell guarantees does not happen.
1805
+ */
1806
+ function shellSegments(tokens) {
1807
+ const segments = [{ words: [], separator: '' }];
1808
+ for (const token of tokens) {
1809
+ if (token.operator && SEGMENT_OPERATORS.has(token.text)) {
1810
+ segments.push({ words: [], separator: token.text });
1811
+ continue;
1812
+ }
1813
+ segments.at(-1)?.words.push(token);
1814
+ }
1815
+ return segments;
1816
+ }
1817
+ /* ────────────────────────── intent ────────────────────────── */
1818
+ /**
1819
+ * Commands that CONSUME a file's content. An interpreter is here because running
1820
+ * a script is the strongest possible form of reading it.
1821
+ */
1822
+ const RETRIEVAL_COMMANDS = new Set([
1823
+ 'cat', 'head', 'tail', 'less', 'more', 'bat', 'nl', 'xxd', 'od', 'strings', 'tac', 'rev',
1824
+ 'source', '.',
1825
+ 'grep', 'rg', 'egrep', 'fgrep', 'ack', 'ag',
1826
+ 'awk', 'sed', 'jq', 'yq', 'cut', 'tr', 'sort', 'uniq', 'wc',
1827
+ 'python', 'python3', 'node', 'bun', 'deno', 'bash', 'sh', 'zsh', 'ruby', 'perl', 'php', 'osascript',
1828
+ 'tsx', 'ts-node', 'pwsh', 'powershell', 'rscript',
1829
+ 'cp', 'mv', 'rsync', 'install', 'tar', 'unzip', 'gunzip', 'zcat',
1830
+ 'diff', 'cmp', 'md5', 'md5sum', 'sha1sum', 'sha256sum', 'shasum',
1831
+ 'open', 'code', 'vim', 'vi', 'nano', 'emacs',
1832
+ ]);
1833
+ /**
1834
+ * Commands that only produce NAMES. Their input paths still count as a reach —
1835
+ * `find ../../vat-skill-test` chose vat's staged tree as its search root, which
1836
+ * the arm could only do by going looking — but their OUTPUT is never scanned, and
1837
+ * they can never be evidence that a declared executable RAN.
1838
+ */
1839
+ const ENUMERATION_COMMANDS = new Set([
1840
+ 'ls', 'dir', 'find', 'tree', 'fd', 'du', 'df', 'stat', 'file',
1841
+ 'basename', 'dirname', 'realpath', 'readlink', 'pwd', 'which', 'type', 'test', '[',
1842
+ ]);
1843
+ /**
1844
+ * Words that prefix a command without being it.
1845
+ *
1846
+ * ⚠️ THE LAUNCHER FAMILY IS NOT OPTIONAL. The original eight covered the 1990s
1847
+ * spellings and missed every modern one, so `uv run <ambient-copy>`,
1848
+ * `timeout 30 python3 <ambient-copy>` and `env FOO=1 python3 <ambient-copy>` were
1849
+ * all exec-blind: the head resolved to `uv` / `timeout` / `env`, none of which is a
1850
+ * retrieval command, so the reach could never support the executable signal.
1851
+ */
1852
+ const COMMAND_PREFIXES = new Set([
1853
+ 'sudo', 'doas', 'time', 'nohup', 'exec', 'command', 'builtin', 'nice', 'xargs',
1854
+ 'env', 'timeout', 'uv', 'uvx', 'npx', 'bunx', 'pnpm', 'yarn', 'poetry', 'pipx',
1855
+ 'watch', 'script', 'stdbuf', 'setsid', 'ionice', 'chrt',
1856
+ ]);
1857
+ /**
1858
+ * Words a launcher takes BEFORE the command it launches.
1859
+ *
1860
+ * `timeout 30 python3 x` and `nice -n 5 cat x` both put a non-command word between
1861
+ * the prefix and the command, so skipping the prefix alone left `30` / `-n` as the
1862
+ * head. Only skipped while still inside a prefix run, so a bare `run` or a leading
1863
+ * `-x` is never mistaken for a launcher argument.
1864
+ */
1865
+ const PREFIX_ARGUMENTS = new Set(['run', 'exec', 'x', '--']);
1866
+ const DURATION_ARGUMENT = /^\d[\d.]*[smhd]?$/;
1867
+ /**
1868
+ * Shell keywords and grouping words that are not the command.
1869
+ *
1870
+ * ⛔ WITHOUT THESE THE `cd` IS INVISIBLE, silently. {@link segmentHead} skipped only
1871
+ * `VAR=` assignments and {@link COMMAND_PREFIXES}, so in `then cd vat-skill-test`
1872
+ * the head was `then` and the `cd` became an ARGUMENT — which
1873
+ * {@link isPathCandidate} discards for having no separator. The confirmed chain
1874
+ * `cd ../../..` / `if [ -d vat-skill-test ]; then cd vat-skill-test; fi` /
1875
+ * `cd <key>` / `cat staged/s/SKILL.md` produced `hits: []` and did NOT degrade,
1876
+ * which is the worst of the three possible answers.
1877
+ *
1878
+ * A `cd` under `then`/`else`/`do` is CONDITIONAL and is applied anyway. That is a
1879
+ * judgement, not an oversight: the branch the arm goes on to use is the branch it
1880
+ * took, and the alternative (degrade) would report the reach as unknowable on the
1881
+ * exact transcripts where it is plainly visible. `||` is different and DOES degrade
1882
+ * — see {@link walkBashCommand} — because there the two branches are mutually
1883
+ * exclusive and nothing later distinguishes them.
1884
+ */
1885
+ const SHELL_KEYWORDS = new Set([
1886
+ 'if', 'then', 'else', 'elif', 'fi', 'do', 'done', 'while', 'until', 'for', 'in',
1887
+ 'case', 'esac', 'select', 'function', '{', '}', '!',
1888
+ ]);
1889
+ /**
1890
+ * The last `/`-separated segment of a token.
1891
+ *
1892
+ * 📌 A BACKSLASH ARM WAS TRIED AND MEASURED OUT. It existed for "a Windows path can
1893
+ * appear in a transcript captured anywhere", which is true, but neither caller can
1894
+ * observe it: {@link segmentHead} is only ever handed text that has been through
1895
+ * the tokenizer and the normalizer (where `\` is already `/`), and a
1896
+ * Windows-spelled command HEAD reaches `retrieval` through
1897
+ * {@link commandIntent}'s head-is-a-path branch whether or not its basename is
1898
+ * recognised. Removing the arm left the whole suite green in both directions, so it
1899
+ * is deleted rather than kept as belt-and-braces. Callers must pass `/`-normalized
1900
+ * text; re-add it only with a reach that only it catches.
1901
+ */
1902
+ function basenameOf(token) {
1903
+ const at = token.lastIndexOf('/');
1904
+ return at === -1 ? token : token.slice(at + 1);
1905
+ }
1906
+ /** Is this token still part of the launcher prefix run rather than the command? */
1907
+ function isPrefixArgument(text) {
1908
+ return text.startsWith('-') || DURATION_ARGUMENT.test(text) || PREFIX_ARGUMENTS.has(text.toLowerCase());
1909
+ }
1910
+ /**
1911
+ * The command a segment actually runs, skipping `VAR=value` assignments, shell
1912
+ * keywords, and launcher wrappers with their own arguments. Returns the head token
1913
+ * too, because `./scripts/run.sh` is both the command AND a path reach into
1914
+ * whatever it points at.
1915
+ */
1916
+ function segmentHead(words) {
1917
+ let inPrefix = false;
1918
+ for (const [index, token] of words.entries()) {
1919
+ const base = basenameOf(token.text).toLowerCase();
1920
+ if (/^[A-Za-z_]\w*=/.test(token.text) || SHELL_KEYWORDS.has(base))
1921
+ continue;
1922
+ if (COMMAND_PREFIXES.has(base)) {
1923
+ inPrefix = true;
1924
+ continue;
1925
+ }
1926
+ if (inPrefix && isPrefixArgument(token.text))
1927
+ continue;
1928
+ return { head: base, headToken: token, args: words.slice(index + 1) };
1929
+ }
1930
+ return { head: '', args: [] };
1931
+ }
1932
+ function commandIntent(head, headToken, words) {
1933
+ // Enumeration is tested BEFORE the head-is-a-path branch below, and that order is
1934
+ // the set's only consumer: `/bin/ls -l <ambient-copy>/summary.mjs` has a
1935
+ // path-shaped head, so without this it would read as "executed a file by path"
1936
+ // and report the arm as having RUN the skill's tool for listing a file.
1937
+ if (ENUMERATION_COMMANDS.has(head))
1938
+ return 'enumeration';
1939
+ if (RETRIEVAL_COMMANDS.has(head))
1940
+ return 'retrieval';
1941
+ // `< file` feeds a file's CONTENT to whatever runs, whether or not we recognise
1942
+ // the command — so the redirect decides the intent when the command name did not.
1943
+ if (words.some((t) => t.operator && t.text === INPUT_REDIRECT))
1944
+ return 'retrieval';
1945
+ // `./tool` or `/opt/bin/tool`: executing a file by path is retrieval regardless
1946
+ // of what it is called, and this is the branch that covers a skill's own bundled
1947
+ // script invoked directly rather than through an interpreter.
1948
+ if (headToken !== undefined && isPathCandidate(headToken.text))
1949
+ return 'retrieval';
1950
+ return 'other';
1951
+ }
1952
+ /* ────────────────────────── operand versus prose ────────────────────────── */
1953
+ /** Commands whose every operand is TEXT TO EMIT, never a file to open. */
1954
+ const PROSE_ALL_OPERANDS = new Set(['echo', 'printf', 'print']);
1955
+ /**
1956
+ * Commands whose FIRST operand is a pattern or a script rather than a path.
1957
+ *
1958
+ * `grep -rn "/tmp/vat-skill-test" .` names vat's harness root and opens nothing —
1959
+ * the string is what it searches FOR. `sed -i "s|foo|<harness>/x|" ./notes.md` is
1960
+ * the same shape with a substitution script. Both stamped clean arms contaminated.
1961
+ */
1962
+ const PROSE_FIRST_OPERAND = new Set(['grep', 'rg', 'egrep', 'fgrep', 'ack', 'ag', 'sed', 'awk', 'jq', 'yq', 'expr']);
1963
+ /**
1964
+ * Flags that CARRY the pattern/script, so the first operand is a path after all.
1965
+ *
1966
+ * ⛔ `-c` IS NOT ONE OF THEM, and listing it here was half of a silent blind spot.
1967
+ * On the {@link PROSE_FIRST_OPERAND} family `-c` means COUNT (`grep -c pat file`),
1968
+ * so treating it as script-carrying stopped `pat` being recognised as the pattern —
1969
+ * and `grep -c "/tmp/vat-skill-test" .` then resolved vat's own harness root as a
1970
+ * reach. On an interpreter it means the opposite of a pattern (see
1971
+ * {@link PATTERN_FLAGS}). It belongs on neither list.
1972
+ */
1973
+ const SCRIPT_CARRYING_FLAGS = new Set(['-e', '-f', '--expression', '--file', '--regexp']);
1974
+ /**
1975
+ * Flags whose VALUE is a pattern: `find -name SKILL.md`, `grep --include=*.md`.
1976
+ *
1977
+ * Split into the ones that mean "pattern" WHEREVER they appear and the ones that
1978
+ * mean it only on the grep/sed/awk family — see {@link patternFlagValues}.
1979
+ */
1980
+ const PATTERN_FLAGS = new Set([
1981
+ '-name', '-iname', '-path', '-ipath', '-regex', '-iregex', '-wholename',
1982
+ '--include', '--exclude', '--glob',
1983
+ ]);
1984
+ /**
1985
+ * Pattern flags that belong to ONE COMMAND FAMILY and mean something else elsewhere.
1986
+ *
1987
+ * ⛔ `-e` USED TO BE UNCONDITIONAL, and that blinded every interpreter
1988
+ * one-liner: `python3 -c "print(open('<harness>/staged/s/SKILL.md').read())"`
1989
+ * produced `hits: []` — and `python`/`node`/`perl`/`ruby` are not in
1990
+ * a nested shell, so NO degradation was recorded either and the verdict read
1991
+ * as a full-strength clean scan. The same command with `-Q` in place of `-c` fired
1992
+ * `harness-path`, which is how narrow the hole was. An interpreter's `-c`/`-e`
1993
+ * carries a SCRIPT, and a script contains real operands; only on the pattern family
1994
+ * is the token after it a thing to search FOR.
1995
+ *
1996
+ * ⛔ `-c` IS NOT A MEMBER — see {@link SCRIPT_CARRYING_FLAGS}, which says the same
1997
+ * thing from the other side. Scoping it to the family did not make it safe: on the
1998
+ * family `-c` means COUNT and takes NO VALUE, so marking the token after it as a
1999
+ * pattern swallowed the pattern AND left the first-operand rule to claim the next
2000
+ * unclaimed operand — THE FILE. `grep -c foo <harness>/staged/s/SKILL.md`,
2001
+ * `rg -c` and `jq -c` all produced `hits: []` with no degradation, which is the
2002
+ * same bytes as a clean scan; `grep -n` on the identical path fired. Left off both
2003
+ * lists, `grep -c pat file` reads as pattern-then-file, which is what it is.
2004
+ */
2005
+ const FAMILY_PATTERN_FLAGS = new Set(['-e', '--expression', '--regexp']);
2006
+ /**
2007
+ * ⚠️ THE CHARACTER CLASS EXCLUDES `-` ON PURPOSE. Widening it to `[A-Za-z-]` makes
2008
+ * the regex backtrack and classify the bare `-` and `--` as flags, which silently
2009
+ * duplicates two rules that ought to be stated where they mean something: `cd -`
2010
+ * goes wherever it was before ({@link cdDestination} rejects it by name) and `--`
2011
+ * ends the options ({@link END_OF_OPTIONS}). Measured: with the wider class, both of
2012
+ * those explicit rules could be deleted and the suite stayed green — belt and
2013
+ * braces, where the braces were an accident of regex backtracking.
2014
+ */
2015
+ function isFlag(token) {
2016
+ return !token.operator && /^-{1,2}[A-Za-z]/.test(token.text);
2017
+ }
2018
+ /**
2019
+ * The tokens in this segment that are PROSE — a pattern, a script, or text to emit
2020
+ * — and so can never be evidence that anything was reached.
2021
+ *
2022
+ * This is the Bash half of the operand-versus-prose line the module docblock
2023
+ * describes. Every member was a confirmed false positive on a clean control arm.
2024
+ */
2025
+ function proseOperands(head, args) {
2026
+ const prose = new Set();
2027
+ if (PROSE_ALL_OPERANDS.has(head)) {
2028
+ for (const token of args)
2029
+ if (!token.operator && !isFlag(token))
2030
+ prose.add(token);
2031
+ }
2032
+ for (const value of patternFlagValues(head, args))
2033
+ prose.add(value);
2034
+ // `grep -e '<pattern>' file` and `sed -e '<script>' file` put the pattern on the
2035
+ // FLAG, so the first operand is the file after all and must not be skipped.
2036
+ const carried = args.some((t) => !t.operator && SCRIPT_CARRYING_FLAGS.has(t.text.toLowerCase()));
2037
+ if (!carried && PROSE_FIRST_OPERAND.has(head)) {
2038
+ const first = args.find((t) => !t.operator && !isFlag(t) && !prose.has(t));
2039
+ if (first !== undefined)
2040
+ prose.add(first);
2041
+ }
2042
+ return prose;
2043
+ }
2044
+ /**
2045
+ * The value token of every `-name <pattern>`-shaped flag in this segment.
2046
+ *
2047
+ * {@link FAMILY_PATTERN_FLAGS} count only when the HEAD is a pattern command —
2048
+ * otherwise `python3 -c <script>` reads its script as a pattern and the reaches
2049
+ * inside it are never resolved.
2050
+ */
2051
+ function patternFlagValues(head, args) {
2052
+ const values = [];
2053
+ const familyFlags = PROSE_FIRST_OPERAND.has(head);
2054
+ for (const [index, token] of args.entries()) {
2055
+ if (token.operator || !isFlag(token))
2056
+ continue;
2057
+ const flag = token.text.toLowerCase();
2058
+ if (!PATTERN_FLAGS.has(flag) && !(familyFlags && FAMILY_PATTERN_FLAGS.has(flag)))
2059
+ continue;
2060
+ const value = args[index + 1];
2061
+ if (value !== undefined && !value.operator)
2062
+ values.push(value);
2063
+ }
2064
+ return values;
2065
+ }
2066
+ /** Commands whose LAST operand is where bytes are written, not read. */
2067
+ const DESTINATION_LAST_OPERAND = new Set(['cp', 'mv', 'install', 'rsync', 'ln']);
2068
+ /** Commands whose every operand is a write destination. */
2069
+ const DESTINATION_ALL_OPERANDS = new Set(['tee']);
2070
+ /**
2071
+ * The tokens in this segment that are WRITE DESTINATIONS.
2072
+ *
2073
+ * A redirect target (`> /tmp/summary.txt`) and a copy destination
2074
+ * (`cp notes.md /tmp/summary.md`) both used to inherit the segment's single intent,
2075
+ * so `sort`/`cat`/`wc` lent them `retrieval` and the arm was reported as having RUN
2076
+ * a declared executable for writing a file called `summary`. They remain REACHES —
2077
+ * writing into vat's staged tree is worth a `harness-path` hit — but they cannot
2078
+ * support the executable signal, which asks only whether something ran.
2079
+ */
2080
+ function writeTargets(head, words, args) {
2081
+ const targets = new Set();
2082
+ for (const [index, token] of words.entries()) {
2083
+ if (!token.operator || !OUTPUT_REDIRECTS.has(token.text))
2084
+ continue;
2085
+ const next = words[index + 1];
2086
+ if (next !== undefined && !next.operator)
2087
+ targets.add(next);
2088
+ }
2089
+ const operands = args.filter((t) => !t.operator && !isFlag(t));
2090
+ if (DESTINATION_ALL_OPERANDS.has(head))
2091
+ for (const t of operands)
2092
+ targets.add(t);
2093
+ const last = operands.at(-1);
2094
+ if (DESTINATION_LAST_OPERAND.has(head) && last !== undefined && operands.length > 1)
2095
+ targets.add(last);
2096
+ return targets;
2097
+ }
2098
+ /* ────────────────────────── path resolution ────────────────────────── */
2099
+ /**
2100
+ * Is this token worth resolving as a path at all?
2101
+ *
2102
+ * A BARE WORD IS NOT A PATH, and that rule is load-bearing in both directions.
2103
+ * Without it `grep -rn vat-skill-test src` — vat dogfooded on its own checkout,
2104
+ * where ~10 tracked files carry the literal — resolves `vat-skill-test` against
2105
+ * the cwd and stamps the run contaminated.
2106
+ *
2107
+ * ⛔ THE COST IS NOT "a reach into the arm's OWN cwd by bare filename", which is
2108
+ * what this used to claim and which would indeed be harmless. `cd vat-skill-test`
2109
+ * after `cd ../../..` is a bare word too, and it is the single most important step
2110
+ * in the whole reach chain — which is why {@link cdDestination} resolves a `cd`
2111
+ * operand unconditionally and never asks this question. The real cost is a bare
2112
+ * name passed to some OTHER command after the arm has already `cd`ed into vat's
2113
+ * tree, and that reach is covered by the `cd` that put it there.
2114
+ *
2115
+ * 📌 THREE CLAUSES WERE MEASURED OUT: `~`-prefixed, `$VAR`-prefixed and a bare
2116
+ * drive (`C:`). Each was reachable only by a token with NO separator in it —
2117
+ * `~/x`, `$TMPDIR/x` and `C:/x` all qualify through the separator test — so the
2118
+ * clauses could only ever admit `~`, `$HOME` and `C:` standing alone, none of which
2119
+ * resolves to anything a needle can match. The `--flag` guard went with them: once
2120
+ * candidacy is "contains a separator", a flag without one is already rejected and a
2121
+ * flag with one (`--out=/tmp/x`) is a path the guard deliberately let through.
2122
+ */
2123
+ function isPathCandidate(word) {
2124
+ return word.includes('/') || word.includes('\\');
2125
+ }
2126
+ /** `scheme://` on the RAW token — {@link normalizeForMatch} collapses the `//` away. */
2127
+ const URI_SCHEME = /^[A-Za-z][\w+.-]*:\/\//;
2128
+ /** `file://` with an optional host, which is the only URI scheme that IS a path. */
2129
+ const FILE_URI = /^file:\/\/[^/]*/i;
2130
+ /**
2131
+ * Fold `.` and `..` out of an already-normalized path.
2132
+ *
2133
+ * `..` beyond the root is clamped rather than escaping, which is what a real
2134
+ * filesystem does, and what makes `cd ../../../../..` from a temp workspace land
2135
+ * at `/` instead of producing a token no needle can match.
2136
+ *
2137
+ * Splitting on `/` is safe here for the same reason it is in
2138
+ * {@link normalizedSegments}: the input has already been through
2139
+ * {@link normalizeForMatch}, which folded every `\` away. A `basename()` would
2140
+ * reintroduce the platform separator the normalizer just removed.
2141
+ */
2142
+ function normalizeDotSegments(normalized) {
2143
+ const absolute = normalized.startsWith('/');
2144
+ // A `~`- or `$VAR`-rooted path has an UNEXPANDABLE root segment, and `..` must not
2145
+ // pop it: popping leaves `''`, and an empty cwd resolves every later relative token
2146
+ // against the filesystem root — which is how `cd ~ && cd ..` came to report the
2147
+ // arm's own script as an ambient copy. A real `cd ~/a && cd ../../..` stops at the
2148
+ // home directory for the same reason a real `/` climb stops at `/`.
2149
+ const unexpandableRoot = normalized.startsWith('~') || normalized.startsWith('$');
2150
+ const out = [];
2151
+ for (const segment of normalized.split('/')) {
2152
+ if (segment === '' || segment === '.')
2153
+ continue;
2154
+ // 📌 BOTH GUARDS THIS `pop` USED TO CARRY ARE PROVABLY REDUNDANT, verified over
2155
+ // 39,060 generated paths. `out.length > 0` guarded a `pop` on an empty array,
2156
+ // which is already a no-op; `out.at(-1) !== '..'` guarded against popping a
2157
+ // `'..'`, which is never pushed — this branch is the only place `'..'` is seen
2158
+ // and it never pushes. The root clamp the docblock above promises is what
2159
+ // `[].pop()` does for free.
2160
+ if (segment === '..') {
2161
+ if (!(unexpandableRoot && out.length <= 1))
2162
+ out.pop();
2163
+ }
2164
+ else
2165
+ out.push(segment);
2166
+ }
2167
+ return (absolute ? '/' : '') + out.join('/');
2168
+ }
2169
+ /**
2170
+ * Resolve one token to a comparable absolute path, or `undefined` when it is not
2171
+ * a filesystem path at all.
2172
+ *
2173
+ * A `~`- or `$VAR`-rooted token is returned AS WRITTEN. We cannot expand it, and
2174
+ * we must not: pretending `~/x` is relative would resolve it inside the arm's own
2175
+ * workspace and suppress exactly the installed-plugin-cache reach this detector
2176
+ * exists for. Left unexpanded it is not under the arm's workspace, so it reads as
2177
+ * an escape — the conservative direction FOR A TOKEN. ⚠️ It is the INVERTED
2178
+ * direction for a CWD, and this docblock did not use to distinguish the two: a
2179
+ * tracked `cd ~` produced the cwd `"~"`, `cd ~ && cd ..` produced `""`, and an
2180
+ * empty cwd then resolved every later relative token against `/` — which reported
2181
+ * `declared-executable` on an arm running its own script in its own workspace.
2182
+ * {@link cdDestination} refuses a `~` target for that reason.
2183
+ *
2184
+ * `file://` IS a filesystem path and is unwrapped. Every other scheme is not one
2185
+ * and returns `undefined`. Treating them alike made `curl
2186
+ * file:///…/vat-skill-test/<key>/staged/s/SKILL.md` invisible to EVERY signal —
2187
+ * `hits: []` and not degraded, which is the same silence as a clean run.
2188
+ *
2189
+ * `cwd` is `undefined` after a `cd` the walk could not evaluate. A relative token
2190
+ * then resolves to nothing rather than to a guess; an absolute one is unaffected,
2191
+ * which is what makes degradation forward-only.
2192
+ */
2193
+ function resolvePathToken(token, cwd) {
2194
+ const unwrapped = FILE_URI.test(token) ? token.replace(FILE_URI, '') : token;
2195
+ if (URI_SCHEME.test(unwrapped))
2196
+ return undefined;
2197
+ const normalized = normalizeForMatch(unwrapped);
2198
+ if (normalized === '')
2199
+ return undefined;
2200
+ if (normalized.startsWith('~') || normalized.startsWith('$'))
2201
+ return normalized;
2202
+ if (normalized.startsWith('/') || /^[a-z]:\//.test(normalized))
2203
+ return normalizeDotSegments(normalized);
2204
+ return cwd === undefined ? undefined : normalizeDotSegments(`${cwd}/${normalized}`);
2205
+ }
2206
+ const END_OF_OPTIONS = '--';
2207
+ function cdDestination(args) {
2208
+ const operands = [];
2209
+ let literal = false;
2210
+ for (const token of args) {
2211
+ if (token.operator)
2212
+ continue;
2213
+ if (!literal && token.text === END_OF_OPTIONS) {
2214
+ literal = true;
2215
+ continue;
2216
+ }
2217
+ if (!literal && isFlag(token))
2218
+ continue;
2219
+ operands.push(token);
2220
+ }
2221
+ const spelled = `cd ${args.filter((t) => !t.operator).map((t) => t.text).join(' ')}`.trim();
2222
+ if (operands.length !== 1)
2223
+ return { kind: 'bad', spelled };
2224
+ const target = operands[0];
2225
+ if (target === undefined)
2226
+ return { kind: 'bad', spelled };
2227
+ const { text } = target;
2228
+ // ⛔ ONLY A ROOTLESS `~` IS REFUSED, not every `~`-prefixed target. The reason the
2229
+ // docblock gives — cwd `"~"`, then `cd ..`, then `""`, then every relative token
2230
+ // resolving against `/` — is about the BARE form and about `~user`, which names a
2231
+ // home directory we cannot locate. `cd ~/sub` has neither problem:
2232
+ // {@link resolvePathToken} returns `~/sub` usably and {@link normalizeDotSegments}
2233
+ // now clamps `..` at the `~`. Refusing it wholesale cost the CANONICAL
2234
+ // installed-plugin-cache reach: `cd ~/.claude/plugins/<skill>/scripts` then
2235
+ // `python3 ./csvsum.py` read clean, while the one-shot spelling of the same reach
2236
+ // fired.
2237
+ const rootlessHome = text.startsWith('~') && !text.startsWith('~/');
2238
+ if (text === '' || text === '-' || rootlessHome || text.includes('$') || text.includes('`')) {
2239
+ return { kind: 'bad', spelled };
2240
+ }
2241
+ return { kind: 'ok', token: target };
2242
+ }
2243
+ /**
2244
+ * Commands that move the working directory WITHOUT being `cd`, and that the walk
2245
+ * has no way to follow.
2246
+ *
2247
+ * ⛔ THESE WERE STRICTLY WORSE THAN THE FORMS THAT DEGRADE: they left a stale cwd
2248
+ * in force with no degradation recorded at all, so every later relative path was
2249
+ * resolved against a directory the arm had already left, and the verdict said the
2250
+ * scan ran at full strength. `pushd`/`popd` persist across Bash tool calls exactly
2251
+ * as `cd` does.
2252
+ *
2253
+ * ⛔ `dirs` IS NOT ONE OF THEM and used to be listed here. It PRINTS the directory
2254
+ * stack and changes nothing whatsoever, so one `dirs` anywhere in a transcript
2255
+ * unanchored every relative path after it: the canonical `cd ../../..` → `cd
2256
+ * vat-skill-test` → `cat <key>/staged/s/SKILL.md` chain reported `hits: []` behind
2257
+ * it, where the identical chain without it fires `harness-path`.
2258
+ */
2259
+ const DIRECTORY_STACK_COMMANDS = new Set(['pushd', 'popd']);
2260
+ /**
2261
+ * ⚠️ CASE-SENSITIVE, and that is the point: `-C` changes directory while `-c`
2262
+ * carries a script. Folding them together made `git -C ../.. status` read as a
2263
+ * nested shell and fall through both branches, which is how `-C` shipped untracked.
2264
+ */
2265
+ const CHDIR_FLAGS = new Set(['-C', '--directory', '--chdir', '-execdir']);
2266
+ /** Commands for which `-C` means "change directory" rather than, say, grep's context. */
2267
+ const CHDIR_FLAG_COMMANDS = new Set(['git', 'make', 'tar', 'env', 'docker', 'podman', 'cmake', 'ninja', 'find']);
2268
+ function isChdirFlag(text) {
2269
+ return CHDIR_FLAGS.has(text) || text.startsWith('--chdir=') || text.startsWith('--directory=');
2270
+ }
2271
+ /**
2272
+ * The `-C`/`-execdir` this segment carries, or `undefined`.
2273
+ *
2274
+ * ⚠️ SCOPED TO ONE COMMAND, which is what the docblock on {@link CHDIR_FLAGS} has
2275
+ * always said and what the code then ignored by setting the walk's cwd to
2276
+ * `undefined` PERMANENTLY. `git -C ../repo status` moves nothing the shell can see:
2277
+ * a leading one made the canonical `cd ../../..` → `cd vat-skill-test` → `cat
2278
+ * <key>/staged/s/SKILL.md` chain report `hits: []`, where the identical chain
2279
+ * without it fires `harness-path`. Only that segment's own operands are unanchored.
2280
+ *
2281
+ * ⛔ A NESTED SHELL IS NOT LISTED HERE ANY MORE. `sh -c "…"` cannot change its
2282
+ * CALLER's directory — the module already reasons this way for `( … )` subshells —
2283
+ * and treating it as an opaque move blinded the walk from that point on. The
2284
+ * segment now falls through to {@link segmentReaches}, where the `-c` operand is an
2285
+ * ordinary token: a script that spells a harness path is a reach the walk can see,
2286
+ * and a `cd` inside it correctly moves nothing.
2287
+ *
2288
+ * Scanned over the WHOLE segment rather than over `args`, because the owner of the
2289
+ * flag is not always the head: `env` is a launcher prefix, so `env -C ../.. ls`
2290
+ * resolves its head to `ls` and an args-only scan never sees who `-C` belongs to.
2291
+ */
2292
+ function scopedCwdChange(words) {
2293
+ let owner;
2294
+ for (const { operator, text } of words) {
2295
+ if (operator)
2296
+ continue;
2297
+ const base = basenameOf(text).toLowerCase();
2298
+ if (CHDIR_FLAG_COMMANDS.has(base))
2299
+ owner = base;
2300
+ else if (owner !== undefined && isChdirFlag(text))
2301
+ return `${owner} ${text}`;
2302
+ }
2303
+ return undefined;
2304
+ }
2305
+ /**
2306
+ * Walk ONE Bash command: collect the paths its segments name and carry the cwd
2307
+ * forward across them.
2308
+ *
2309
+ * Carrying cwd ACROSS calls (the caller's job) and across `&&` within one call
2310
+ * (this function's) is the entire fix for defect (a). The Bash tool keeps its
2311
+ * working directory between invocations, so a `cd` in call 3 governs call 9.
2312
+ *
2313
+ * THREE CONTROL-FLOW FACTS THE WALK USED TO IGNORE, all of them silently:
2314
+ *
2315
+ * - **`( … )` does not leak its cwd; `{ …; }` does.** Probed on the shipped code:
2316
+ * neither was tracked — `(` became the segment HEAD, so the `cd` inside it was a
2317
+ * mere argument. The subshell answer was therefore accidentally right and the
2318
+ * brace-group answer was wrong, and no test could tell. The cwd is now stacked on
2319
+ * `(` and restored on `)`, while `{`/`}` are ordinary keywords the head skips, so
2320
+ * a brace group's `cd` persists exactly as the shell says it does.
2321
+ * - **`||` runs ONE of its two sides.** `cd one || cd two` was walked as though both
2322
+ * ran, resolving to `<cwd>/one/two`. `cd .. || cd ..` then made `ls
2323
+ * <sibling-token>/notes.md` report `sibling-arm` — the strongest verdict this tool
2324
+ * issues — on a run whose real cwd never moved twice. A `cd` to the RIGHT of a
2325
+ * `||` is now unevaluable. A `cd` to its LEFT still applies (`cd x || exit 1` is
2326
+ * the idiom), and `&&` is unaffected: its right side runs only when the left
2327
+ * succeeded, which is the case the walk is already modelling.
2328
+ * - **A stale cwd needs saying.** See {@link scopedCwdChange}.
2329
+ */
2330
+ function walkBashCommand(command, startCwd, ctx,
2331
+ /** Recursion guard for {@link substitutionBodies}; a body's own bodies are not walked. */
2332
+ depth = 0) {
2333
+ const reaches = depth === 0 ? substitutionReaches(command, startCwd, ctx) : [];
2334
+ const enclosing = [];
2335
+ const state = { cwd: startCwd, conditional: false };
2336
+ const holes = noCwdHoles();
2337
+ for (const { words, separator } of shellSegments(tokenizeShell(command))) {
2338
+ applySeparator(separator, enclosing, state);
2339
+ const { head, headToken, args } = segmentHead(words);
2340
+ const change = cwdChangeFor(head, args, words);
2341
+ // A `-C`/`-execdir` moves the cwd for THIS COMMAND ONLY, so its own RELATIVE
2342
+ // operands are unanchored and the walk's cwd is not — see
2343
+ // {@link scopedCwdChange}.
2344
+ //
2345
+ // ⛔ THE SEGMENT IS STILL WALKED. It used to `continue` past
2346
+ // {@link segmentReaches} entirely, which dropped its ABSOLUTE operands too —
2347
+ // and an absolute operand is anchored whatever `-C` does, which is what
2348
+ // {@link scopedCwdChange}'s own docblock says. Measured: `env -C /tmp cat
2349
+ // <harness>/staged/s/SKILL.md`, and the `git -C`/`tar -C` spellings of it, all
2350
+ // reported `hits: []`, while the bare `cat` of the same path fired. Walking it
2351
+ // with NO cwd is exactly the stated rule: {@link resolvePathToken} drops a
2352
+ // relative token without one and keeps an absolute one.
2353
+ let segmentCwd = state.cwd;
2354
+ if (change?.kind === 'scoped') {
2355
+ holes.scoped ??= untrackedScope(change.spelled);
2356
+ segmentCwd = undefined;
2357
+ }
2358
+ else if (change !== undefined) {
2359
+ const moved = applyCd(change, state.conditional, state.cwd, command);
2360
+ if (moved.reach !== undefined)
2361
+ reaches.push(moved.reach);
2362
+ state.cwd = moved.cwd;
2363
+ holes.full ??= moved.untracked;
2364
+ continue;
2365
+ }
2366
+ const intent = commandIntent(head, headToken, words);
2367
+ // `exactOptionalPropertyTypes` distinguishes an absent key from an explicit
2368
+ // `undefined`, and `segmentHead` returns `headToken` as the latter — so spread it
2369
+ // conditionally rather than widening the callee to accept `| undefined`, which
2370
+ // would let a genuinely missing head token look like a supplied one.
2371
+ reaches.push(...segmentReaches({ head, args, words, ...(headToken === undefined ? {} : { headToken }) }, intent, segmentCwd, command, ctx));
2372
+ }
2373
+ return { reaches, cwd: state.cwd, holes };
2374
+ }
2375
+ /**
2376
+ * The reaches made INSIDE this command's substitutions, rebased onto it.
2377
+ *
2378
+ * Subshell semantics: whatever a body does to the cwd is discarded, and a body that
2379
+ * cannot be evaluated degrades nothing — the caller's cwd is untouched either way.
2380
+ */
2381
+ function substitutionReaches(command, cwd, ctx) {
2382
+ const reaches = [];
2383
+ for (const { text, offset } of substitutionBodies(command)) {
2384
+ for (const reach of walkBashCommand(text, cwd, ctx, 1).reaches) {
2385
+ reaches.push({ ...reach, text: command, index: reach.index + offset });
2386
+ }
2387
+ }
2388
+ return reaches;
2389
+ }
2390
+ /**
2391
+ * Separators that END the AND-OR list a `||` made conditional.
2392
+ *
2393
+ * ⛔ `state.conditional` USED TO BE A LATCH: `||` set it and nothing ever cleared
2394
+ * it, so every later segment of the same Bash call was treated as an unevaluable
2395
+ * branch — `mkdir -p out || true ; cd out` reported `cwd-untracked` and unanchored
2396
+ * every relative path in the rest of the transcript. Agents write `|| true`
2397
+ * constantly, which made this the likeliest of the blinding paths to fire in
2398
+ * practice. `;`, a newline, a background `&` and either parenthesis start a fresh
2399
+ * list; `&&` and `|` continue the one the `||` is part of, so a `cd` after them is
2400
+ * still on a branch that may not have run.
2401
+ */
2402
+ const LIST_TERMINATORS = new Set([';', '\n', '&', SUBSHELL_OPEN, SUBSHELL_CLOSE]);
2403
+ function applySeparator(separator, enclosing, state) {
2404
+ if (separator === SUBSHELL_OPEN)
2405
+ enclosing.push(state.cwd);
2406
+ else if (separator === SUBSHELL_CLOSE && enclosing.length > 0)
2407
+ state.cwd = enclosing.pop();
2408
+ if (separator === OR_ELSE)
2409
+ state.conditional = true;
2410
+ else if (LIST_TERMINATORS.has(separator))
2411
+ state.conditional = false;
2412
+ }
2413
+ /** The cwd change this segment makes, or `undefined` when it makes none. */
2414
+ function cwdChangeFor(head, args, words) {
2415
+ if (head === 'cd')
2416
+ return cdDestination(args);
2417
+ if (DIRECTORY_STACK_COMMANDS.has(head))
2418
+ return { kind: 'bad', spelled: head };
2419
+ const scoped = scopedCwdChange(words);
2420
+ return scoped === undefined ? undefined : { kind: 'scoped', spelled: scoped };
2421
+ }
2422
+ /**
2423
+ * Apply one `cd` (or one unevaluable cwd change): where the walk goes next, the
2424
+ * reach the `cd` itself makes, and the degradation it owes.
2425
+ *
2426
+ * A `cd` argument is ALWAYS resolved, even when it is a BARE NAME that
2427
+ * {@link isPathCandidate} would reject everywhere else — `cd vat-skill-test`
2428
+ * after `cd ../../..` is precisely the step the flat scan could not see, and
2429
+ * skipping it would leave the cwd stale and the rest of the walk anchored wrong.
2430
+ * The rejection rule exists to stop a `grep` PATTERN being read as a path; a `cd`
2431
+ * operand is never a pattern.
2432
+ *
2433
+ * The target is reported as a reach because navigating INTO vat's staged tree is
2434
+ * not orientation, whatever the arm does once it is there.
2435
+ */
2436
+ function applyCd(destination, conditional, cwd, command) {
2437
+ if (destination.kind === 'bad')
2438
+ return { cwd: undefined, untracked: untrackedCd(destination.spelled) };
2439
+ const { token } = destination;
2440
+ const resolved = resolvePathToken(token.text, cwd);
2441
+ const reach = resolved === undefined
2442
+ ? undefined
2443
+ : { resolved, intent: 'other', text: command, index: token.index, length: token.length };
2444
+ if (conditional) {
2445
+ // ⛔ THE PREFIX IS DERIVED FROM `conditional`, NOT FROM THE COMMAND TEXT. It used
2446
+ // to be `command.includes('||')`, so a `cd` on its own LINE of a multi-line
2447
+ // command that happened to contain a `||` somewhere else was reported to the
2448
+ // operator as `could not evaluate "|| cd ../../.."` — a `||` the arm never typed
2449
+ // in front of that `cd`. Inside this branch the `cd` IS on the right of a `||` by
2450
+ // construction, so the prefix is a fact rather than a guess.
2451
+ return {
2452
+ cwd: undefined,
2453
+ ...(reach === undefined ? {} : { reach }),
2454
+ untracked: untrackedCd(`${OR_ELSE} cd ${token.text}`),
2455
+ };
2456
+ }
2457
+ return { cwd: resolved, ...(reach === undefined ? {} : { reach }) };
2458
+ }
2459
+ function untrackedCd(spelled) {
2460
+ return cwdDegradation(
2461
+ // SANITIZED AT CONSTRUCTION, not at the stderr write. `spelled` is a raw shell
2462
+ // token lifted out of the CONTROL ARM's transcript, and the harness interpolates
2463
+ // this detail straight into `process.stderr.write`: a `cd "$D<ESC>[2K<CR><ESC>
2464
+ // [32mvat: control arm verified clean<ESC>[0m"` erased the "contamination scan
2465
+ // DEGRADED" line vat had just written and re-rendered it in green as vat's own
2466
+ // voice — terminal forgery on the exact warning that says the scan went blind. A
2467
+ // newline payload got a line of its own. It cannot be fixed at the write, because
2468
+ // vat attaches `degraded` to the eval fragment AFTER `parseEvalFragment` has
2469
+ // sanitized it, which would leave the raw bytes in `baseline.json`; one call here
2470
+ // covers both surfaces. `sanitizeGraderText` and not the line-preserving variant:
2471
+ // this is interpolated mid-sentence, so a surviving newline IS the attack.
2472
+ `could not evaluate "${spelled}" — every later relative path is unanchored`);
2473
+ }
2474
+ /**
2475
+ * The degradation a ONE-COMMAND cwd change owes.
2476
+ *
2477
+ * The same KIND of hole as {@link untrackedCd} — a path the walk could not anchor —
2478
+ * so it carries the same reason; the DETAIL is what says the hole is one command
2479
+ * wide rather than the whole rest of the transcript.
2480
+ */
2481
+ function untrackedScope(spelled) {
2482
+ return cwdDegradation(`could not evaluate "${spelled}" — the paths in that ONE command are unanchored, later ones are not`);
2483
+ }
2484
+ function noCwdHoles() {
2485
+ return { full: undefined, scoped: undefined };
2486
+ }
2487
+ /** The hole to REPORT: the WIDEST one found, never merely the first one met. */
2488
+ function worstCwdHole(holes) {
2489
+ return holes.full ?? holes.scoped;
2490
+ }
2491
+ /** One `cwd-untracked` degradation, sanitized at construction for both surfaces. */
2492
+ function cwdDegradation(detail) {
2493
+ return { reason: 'cwd-untracked', detail: sanitizeGraderText(detail) };
2494
+ }
2495
+ /**
2496
+ * Is this separator-less operand still worth resolving?
2497
+ *
2498
+ * {@link isPathCandidate} rejects a bare word, and its docblock justifies the cost
2499
+ * by saying a bare name used after a `cd` "is covered by the `cd` that put it
2500
+ * there". That is TRUE of vat's own directories, which have needles, and FALSE of
2501
+ * the two ambient classes vat cannot remove — the adopter's repo and the installed
2502
+ * plugin cache — which have none, and for which `declared-executable` is the only
2503
+ * signal there is. Confirmed: `cd /users/dev/myrepo/dist/plugins/myskill/scripts`
2504
+ * then `python3 csvsum.py data.csv` produced NOTHING, while the identical transcript
2505
+ * spelled `./csvsum.py` fired.
2506
+ *
2507
+ * Narrow on purpose — only a RETRIEVAL head, and only an operand whose basename is
2508
+ * one the skill actually declares. Widening it to "any bare word" reopens the false
2509
+ * positive the bare-word rule exists for: `grep -rn vat-skill-test src`, vat
2510
+ * dogfooded on its own checkout, resolves the PATTERN against the cwd.
2511
+ */
2512
+ function isBareDeclaredExecutable(word, intent, ctx) {
2513
+ return intent === 'retrieval' && ctx.declaredBasenames.has(word.toLowerCase());
2514
+ }
2515
+ /** Every path one segment's words name, resolved against the cwd in force there. */
2516
+ function segmentReaches(segment, intent, cwd, command, ctx) {
2517
+ const { head, headToken, args, words } = segment;
2518
+ const prose = proseOperands(head, args);
2519
+ const targets = writeTargets(head, words, args);
2520
+ const reaches = [];
2521
+ const candidates = headToken === undefined ? args : [headToken, ...args];
2522
+ for (const word of candidates) {
2523
+ if (word.operator)
2524
+ continue;
2525
+ // ⛔ `targets` IS CONSULTED BEFORE `prose`, and the order is the whole fix.
2526
+ // {@link proseOperands} marks every non-flag operand of `echo`/`printf` as text
2527
+ // to emit — the token after `>` included — so `echo boom > <harness>/staged/s/
2528
+ // SKILL.md`, the control arm OVERWRITING the treatment arm's staged skill,
2529
+ // produced `hits: []` while `cat x > <same path>` fired correctly. A redirect
2530
+ // DESTINATION is an operand whoever wrote the bytes, which is what
2531
+ // {@link writeTargets}' own docblock has always claimed.
2532
+ if (prose.has(word) && !targets.has(word))
2533
+ continue;
2534
+ if (!isPathCandidate(word.text) && !isBareDeclaredExecutable(word.text, intent, ctx))
2535
+ continue;
2536
+ const resolved = resolvePathToken(word.text, cwd);
2537
+ if (resolved === undefined)
2538
+ continue;
2539
+ reaches.push({
2540
+ resolved,
2541
+ intent: targets.has(word) ? 'write' : intent,
2542
+ text: command,
2543
+ index: word.index,
2544
+ length: word.length,
2545
+ });
2546
+ }
2547
+ return reaches;
2548
+ }
2549
+ /* ────────────────────────── assembling the verdict ────────────────────────── */
2550
+ /**
2551
+ * Does this resolved path lie OUTSIDE the arm's own workspace?
2552
+ *
2553
+ * ⚠️ NOT "a prefix test and nothing else", which is what this used to claim: the
2554
+ * `/` BOUNDARY is the test. A bare `startsWith` reports the arm's own tree as
2555
+ * containing `<workspace>-other/scripts/summary.mjs`, a sibling directory that
2556
+ * merely shares its name as a prefix — the same over-match
2557
+ * {@link containsPathAtBoundary} exists for one level up.
2558
+ *
2559
+ * 📌 An `armWorkspaceDir === ''` guard returning `true` was MEASURED OUT as dead:
2560
+ * {@link scanDegradation} reports `cwd-unknown` and the structured path never runs
2561
+ * when the workspace is empty, so nothing can reach it. Forcing this predicate to a
2562
+ * constant `true` still kills four tests, so the predicate itself is live.
2563
+ */
2564
+ function pathEscapesWorkspace(resolved, armWorkspaceDir) {
2565
+ return resolved !== armWorkspaceDir && !resolved.startsWith(`${armWorkspaceDir}/`);
2566
+ }
2567
+ function structuredExecutableHits(input, reaches, armWorkspaceDir, claimed) {
2568
+ const hits = [];
2569
+ for (const declaredPath of input.executablePaths ?? []) {
2570
+ const needle = declaredExecutableNeedle(declaredPath);
2571
+ if (needle === undefined)
2572
+ continue;
2573
+ // ⛔ EVERY REACH, NOT JUST THE FIRST — which the comment here has always said
2574
+ // and the code stopped doing. `reaches.find(…)` returned the FIRST escaping
2575
+ // name-matching reach and the `claimed` test then DISCARDED it, never looking at
2576
+ // the ones behind it: `cat <harness>/staged/s/scripts/summary.py` followed by
2577
+ // `python3 /users/dev/repo/dist/plugins/s/scripts/summary.py data.csv` reported
2578
+ // `harness-path` alone, while the same two commands in the opposite order
2579
+ // reported both. That is the READ-versus-RAN distinction — the first thing an
2580
+ // operator triaging a contaminated run wants — lost to statement order.
2581
+ const reach = reaches.find((r) => {
2582
+ if (r.intent !== 'retrieval' || claimed.has(r.resolved))
2583
+ return false;
2584
+ return reachIsDeclaredExecutable(r.resolved, needle) && pathEscapesWorkspace(r.resolved, armWorkspaceDir);
2585
+ });
2586
+ // A reach already reported as a harness / sibling / private-dir hit is ONE
2587
+ // reach; reporting it again as an executable would double-count it. Deduping
2588
+ // on the RESOLVED PATH rather than on excerpt text is what makes this
2589
+ // case-proof — the old excerpt-substring test compared a raw declared name
2590
+ // against an unconditionally lowercased excerpt, so any name with a capital
2591
+ // (`Summarize`) escaped the dedupe and was reported twice.
2592
+ if (reach === undefined)
2593
+ continue;
2594
+ claimed.add(reach.resolved);
2595
+ hits.push({
2596
+ kind: KIND_DECLARED_EXECUTABLE,
2597
+ match: declaredPath,
2598
+ excerpt: excerptAround(reach.text, reach.index, reach.length),
2599
+ });
2600
+ }
2601
+ return hits;
2602
+ }
2603
+ /**
2604
+ * Glob metacharacters a shell expands and this walk cannot.
2605
+ *
2606
+ * `~` is deliberately absent — {@link resolvePathToken} keeps a `~`-rooted token as
2607
+ * written on purpose, and that is a KNOWN root, not an unknown segment.
2608
+ *
2609
+ * ⛔ `{`/`}` USED TO BE ABSENT TOO, and brace expansion is a shell expansion exactly
2610
+ * like `*`. Measured: `cat ../../../{vat-skill-test,zz}/k/staged/s/SKILL.md` and
2611
+ * `cat ../../../vat-skill-{test,x}/k/staged/s/SKILL.md` both reported `hits: []`
2612
+ * with no degradation — the same bytes as a clean scan — while
2613
+ * `cat ../../../vat-*&#47;k/staged/s/SKILL.md`, the identical reach, degraded. A brace
2614
+ * around a directory name defeats all three literal needles for the same reason a
2615
+ * `*` does.
2616
+ */
2617
+ const GLOB_METACHARACTERS = /[*?[{}]/;
2618
+ /**
2619
+ * The LITERAL segments of every directory needle, as one set.
2620
+ *
2621
+ * Derived from the needles the scan already builds rather than from a second
2622
+ * hand-maintained list. A second list that has to agree with the first is the
2623
+ * failure this module keeps having, and it would go stale the first time a needle
2624
+ * builder changed shape.
2625
+ */
2626
+ function needleSegmentSet(needles) {
2627
+ const segments = new Set();
2628
+ for (const { needle } of needles)
2629
+ for (const segment of normalizedSegments(needle))
2630
+ segments.add(segment);
2631
+ return segments;
2632
+ }
2633
+ /** The index of the LAST glob metacharacter in `segment`, or `-1`. */
2634
+ function lastMetacharacterIndex(segment) {
2635
+ for (let index = segment.length - 1; index >= 0; index -= 1) {
2636
+ if (GLOB_METACHARACTERS.test(segment.charAt(index)))
2637
+ return index;
2638
+ }
2639
+ return -1;
2640
+ }
2641
+ /**
2642
+ * Could this globbed path segment expand to something a needle segment names?
2643
+ *
2644
+ * ⚠️ DELIBERATELY CONSERVATIVE, AND CONSERVATIVE MEANS DEGRADING. Only the LITERAL
2645
+ * RUN BEFORE the first metacharacter and the one AFTER the last are consulted; a
2646
+ * literal caught BETWEEN two metacharacters is ignored, because for a brace
2647
+ * (`{a,b}`) the text inside is a set of alternatives rather than something that
2648
+ * must appear, and a rule that cannot tell the two apart must not convict on the
2649
+ * middle. That leaves `vat-&#42;` consistent with `vat-skill-test`, `vat-&#42;-test`
2650
+ * consistent with it from both ends, `&#42;` consistent with everything — and
2651
+ * `build-&#42;` consistent with nothing this scan is looking for.
2652
+ *
2653
+ * This is NOT a glob matcher and must not grow into one. It answers one question:
2654
+ * is there any way this segment could have been standing where a needle segment
2655
+ * was? When in doubt the answer is yes.
2656
+ */
2657
+ function globSegmentCouldHideANeedle(segment, needleSegments) {
2658
+ const first = segment.search(GLOB_METACHARACTERS);
2659
+ if (first === -1)
2660
+ return false;
2661
+ const prefix = segment.slice(0, first);
2662
+ const suffix = segment.slice(lastMetacharacterIndex(segment) + 1);
2663
+ for (const needle of needleSegments) {
2664
+ if (prefix.length + suffix.length > needle.length)
2665
+ continue;
2666
+ if (needle.startsWith(prefix) && needle.endsWith(suffix))
2667
+ return true;
2668
+ }
2669
+ return false;
2670
+ }
2671
+ /**
2672
+ * Does this reach's glob stand where a NEEDLE SEGMENT could have been hidden?
2673
+ *
2674
+ * ⛔ "ESCAPES THE WORKSPACE" IS NOT A NARROWING ON ITS OWN, and treating it as one
2675
+ * made this degradation fire on ordinary work. The arm's workspace is a
2676
+ * SUBDIRECTORY of the OS temp dir, so every glob over the arm's own `/tmp` scratch
2677
+ * escapes it, and the enumeration narrowing does not help because `cat`, `wc`,
2678
+ * `diff`, `tar` and `md5sum` are all retrieval. Measured: `cat /tmp/report-*.json`,
2679
+ * `cat ~/Downloads/*.csv`, `wc -l /tmp/out*.txt`, `rm -f /tmp/*.tmp` and seven more
2680
+ * ordinary commands each stamped `⚠️ DEGRADED SCAN` on a clean verdict — including
2681
+ * this module's own flagship innocent fixture, the arm writing a scratch file and
2682
+ * reading it back, spelled `cat /tmp/out-*.txt`.
2683
+ *
2684
+ * TWO QUESTIONS, and the second is what makes the claim actionable:
2685
+ *
2686
+ * - **Is the glob in a DIRECTORY segment?** The blindness this exists for is a
2687
+ * metacharacter standing where a LITERAL NEEDLE SEGMENT would be: all three
2688
+ * harness needles are literal path segments, so
2689
+ * `cat ../../vat-&#42;/&#42;/staged/&#42;/SKILL.md` defeats every one of them. A glob in
2690
+ * the BASENAME hides no directory and can defeat no segment needle.
2691
+ * - **Could that segment have BEEN a needle segment?** See
2692
+ * {@link globSegmentCouldHideANeedle}. Without this the degradation says only
2693
+ * "a glob appeared somewhere outside the workspace", which is true of
2694
+ * `cp /tmp/build-&#42;/out.md .` and tells an operator nothing they can act on. A
2695
+ * warning that fires where nothing could have been hidden is noise, and noise
2696
+ * on this verdict is what teaches people to ignore the one that matters.
2697
+ *
2698
+ * ⚠️ AN EMPTY NEEDLE SET DEGRADES NOTHING — with no directory needle armed there is
2699
+ * nothing for a glob to hide, and a warning there would mean "nothing looked".
2700
+ *
2701
+ * 📌 AN EXPLICIT `needleSegments.size === 0` EARLY RETURN WAS MEASURED OUT AS DEAD:
2702
+ * {@link globSegmentCouldHideANeedle} loops over the set, so an empty one already
2703
+ * answers `false`, and deleting the guard left all tests green. The behaviour is
2704
+ * pinned by a test rather than by a branch a mutation cannot kill.
2705
+ */
2706
+ function globCouldHideANeedle(resolved, needleSegments) {
2707
+ // `slice(0, -1)` drops the BASENAME: a glob there stands for a filename, and no
2708
+ // directory needle segment can be hiding behind a filename.
2709
+ return normalizedSegments(resolved)
2710
+ .slice(0, -1)
2711
+ .some((segment) => globSegmentCouldHideANeedle(segment, needleSegments));
2712
+ }
2713
+ /**
2714
+ * The degradation owed by a reach whose glob nobody could expand and no needle
2715
+ * matched.
2716
+ *
2717
+ * ⛔ THE THREE HARNESS NEEDLES ARE ALL LITERAL SEGMENTS, needle 3 included, so a
2718
+ * glob standing for the harness directory NAME defeats every one of them and the
2719
+ * scan reports `hits: []` with `degraded: none` — the same bytes as a clean run.
2720
+ * Confirmed: `cat ../../vat-skill-test/*&#47;staged/*&#47;SKILL.md` fires, while
2721
+ * `cat ../../vat-*&#47;*&#47;staged/*&#47;SKILL.md` and
2722
+ * `cat ../../vat-skill-t*&#47;*&#47;staged/*&#47;SKILL.md` do not.
2723
+ *
2724
+ * THREE NARROWINGS, all measured against real clean commands rather than assumed,
2725
+ * because a degradation on every clean run costs exactly what the executable false
2726
+ * positive cost:
2727
+ *
2728
+ * - the reach must ESCAPE the arm's own workspace. A glob inside its own tree
2729
+ * (`cat ./fixtures/*.md`) is the ordinary shape of ordinary work.
2730
+ * - the reach must not be an ENUMERATION. `ls -d /tmp/&#42;` and a `Glob` tool call
2731
+ * are orientation — the module already refuses to convict on either — and a glob
2732
+ * is what enumeration is FOR, so degrading there would fire constantly while
2733
+ * saying nothing.
2734
+ * - the metacharacter must stand where a NEEDLE SEGMENT could have been — see
2735
+ * {@link globCouldHideANeedle}.
2736
+ */
2737
+ function globDegradation(reaches, armWorkspaceDir, claimed, needleSegments) {
2738
+ const blind = reaches.find((r) => r.intent !== 'enumeration'
2739
+ && globCouldHideANeedle(r.resolved, needleSegments)
2740
+ && !claimed.has(r.resolved)
2741
+ && pathEscapesWorkspace(r.resolved, armWorkspaceDir));
2742
+ if (blind === undefined)
2743
+ return undefined;
2744
+ return {
2745
+ reason: 'glob-unexpanded',
2746
+ // Sanitized for the same reason as {@link untrackedCd}: the excerpt is the arm's
2747
+ // own text, and this detail is interpolated into a terminal line.
2748
+ detail: sanitizeGraderText(`a reach outside the arm's workspace carried a glob nothing can expand (${excerptAround(blind.text, blind.index, blind.length)}) — no needle matched it, and no needle could have`),
2749
+ };
2750
+ }
2751
+ function directoryNeedles(input) {
2752
+ return {
2753
+ harness: harnessNeedles(input.harnessRoot),
2754
+ sibling: siblingArmNeedles(input.siblingArmDir ?? ''),
2755
+ privateDirs: (input.vatPrivateDirs ?? []).map((dir) => vatPrivateDirNeedles(dir ?? '')),
2756
+ };
2757
+ }
2758
+ /** Every directory needle group, paired with the hit kind it produces. */
2759
+ function directoryNeedleGroups(needles) {
2760
+ return [
2761
+ [KIND_HARNESS_PATH, needles.harness],
2762
+ [KIND_SIBLING_ARM, needles.sibling],
2763
+ ...needles.privateDirs.map((group) => [KIND_VAT_PRIVATE_DIR, group]),
2764
+ ];
2765
+ }
2766
+ /** Path/dir hits from the structured walk, in the stable per-group order. */
2767
+ function structuredPathHits(input, reaches, armWorkspaceDir, { harness, sibling, privateDirs }) {
2768
+ const dirHits = [];
2769
+ const claimed = new Set();
2770
+ const push = (hit) => {
2771
+ if (hit !== undefined)
2772
+ dirHits.push(hit);
2773
+ };
2774
+ push(firstReachHit(reaches, harness, KIND_HARNESS_PATH, claimed));
2775
+ push(firstReachHit(reaches, sibling, KIND_SIBLING_ARM, claimed));
2776
+ // Per dir, not first-match-wins across all of them: reaching the answer key and
2777
+ // reaching the grader dir are two different capabilities, and an operator
2778
+ // triaging a contaminated run needs to see both.
2779
+ for (const needles of privateDirs) {
2780
+ push(firstReachHit(reaches, needles, KIND_VAT_PRIVATE_DIR, claimed));
2781
+ }
2782
+ const executableHits = structuredExecutableHits(input, reaches, armWorkspaceDir, claimed);
2783
+ const blindGlob = globDegradation(reaches, armWorkspaceDir, claimed, needleSegmentSet([harness, sibling, ...privateDirs].flat()));
2784
+ return { dirHits, executableHits, ...(blindGlob === undefined ? {} : { blindGlob }) };
2785
+ }
2786
+ /**
2787
+ * The DEGRADED scan: the flat text match this module used to be.
2788
+ *
2789
+ * Kept verbatim in behaviour, not because it is right — the module docblock above
2790
+ * lists both directions in which it is wrong — but because "no scan at all" is
2791
+ * worse than "a scan that over- and under-reports and SAYS SO". Every caller of
2792
+ * this path attaches a {@link BaselineScanDegradation}.
2793
+ */
2794
+ function flatPathHits(input, needles) {
2795
+ const haystack = scanHaystack(input.transcript);
2796
+ const dirHits = [];
2797
+ const dirNeedles = [];
2798
+ for (const [kind, group] of directoryNeedleGroups(needles)) {
2799
+ const hit = firstNeedleHit(haystack, group, kind);
2800
+ if (hit === undefined)
2801
+ continue;
2802
+ dirHits.push(hit);
2803
+ // The needle as MATCHED, not as reported: `hit.match` has been redacted and is
2804
+ // no longer a substring of anything (see {@link pathNeedle}).
2805
+ dirNeedles.push(...group.map((n) => n.needle));
2806
+ }
2807
+ const armDir = normalizeForMatch(input.armWorkspaceDir ?? '');
2808
+ const executableHits = [];
2809
+ for (const declaredPath of input.executablePaths ?? []) {
2810
+ const needle = declaredExecutableNeedle(declaredPath);
2811
+ if (needle === undefined)
2812
+ continue;
2813
+ const match = firstEscapingInvocation(haystack.normalized, needle, armDir);
2814
+ if (match === undefined)
2815
+ continue;
2816
+ if (reachedViaReportedDir(haystack.normalized, match, dirNeedles))
2817
+ continue;
2818
+ executableHits.push({
2819
+ kind: KIND_DECLARED_EXECUTABLE,
2820
+ match: declaredPath,
2821
+ excerpt: excerptIn(haystack, match.index, match.length),
2822
+ });
2823
+ }
2824
+ return { dirHits, executableHits };
2825
+ }
2826
+ /**
2827
+ * Was this executable invocation reached THROUGH a directory already reported?
2828
+ *
2829
+ * The dedupe this replaces asked a much weaker question: "does any dir hit's
2830
+ * EXCERPT contain the executable's name?" — where the excerpt is ±60 characters of
2831
+ * surrounding transcript, and the guard was armed by `hits.length > 0` over the
2832
+ * sibling-arm and private-dir hits as well as the harness one. A SKILL.md line
2833
+ * naming the script it ships sits well inside 60 characters of any path to that
2834
+ * SKILL.md, so the normal case suppressed the second signal — and that second
2835
+ * signal is the whole difference between "the arm READ an ambient copy" and "the
2836
+ * arm RAN it", which is the first thing an operator triaging a contaminated run
2837
+ * wants to know.
2838
+ *
2839
+ * The right question is about ONE REACH, not about proximity: the executable's own
2840
+ * path token either contains a reported directory or it does not. `cat
2841
+ * <root>/staged/s/scripts/csvsum.py` is one reach reported twice and is still
2842
+ * deduped; a `csvsum` run from an ambient copy sixty characters away from an
2843
+ * unrelated `find ../..` is two reaches and is now reported as two.
2844
+ *
2845
+ * Degraded-mode only. The structured scan dedupes on the RESOLVED PATH (`claimed`),
2846
+ * which is the same question asked where the answer is exact.
2847
+ */
2848
+ function reachedViaReportedDir(haystack, match, dirNeedles) {
2849
+ if (dirNeedles.length === 0)
2850
+ return false;
2851
+ const token = pathTokenEndingAt(haystack, match.index, match.length);
2852
+ return dirNeedles.some((needle) => containsPathAtBoundary(token, needle));
2853
+ }
2854
+ /**
2855
+ * Which scan can run for this transcript, and why not the good one.
2856
+ *
2857
+ * Order matters: an unparseable transcript is reported as such even when
2858
+ * `armWorkspaceDir` is also missing, because it is the more fundamental failure
2859
+ * and the one an operator can act on (the executor did not capture stream-json).
2860
+ */
2861
+ function scanDegradation(input, parsed) {
2862
+ // ⛔ THE `trim() !== ''` GUARD THAT USED TO ARM THIS WAS THE HOLE. An EMPTY control
2863
+ // transcript skipped the branch and fell through to a walk over zero tool uses:
2864
+ // `hits: []`, `degraded: undefined` — and it then COUNTS as an observed eval, so
2865
+ // the run summary says "no skill-absent eval was observed reaching the skill" about
2866
+ // an eval where nothing was observed at all. Nothing to scan is the most degraded a
2867
+ // scan can be, not the least.
2868
+ if (!transcriptDecoded(parsed)) {
2869
+ const empty = input.transcript.trim() === '';
2870
+ return {
2871
+ reason: 'transcript-unparsed',
2872
+ // Sanitized for the same reason as {@link untrackedCd}, even though this
2873
+ // detail is vat-authored and carries nothing from the arm: the property that
2874
+ // no degradation detail can forge a terminal line has to hold by
2875
+ // CONSTRUCTION, not by which of the three sites somebody audited.
2876
+ detail: sanitizeGraderText(empty
2877
+ ? 'the control arm produced an empty transcript, so nothing was scanned for this eval'
2878
+ : `${input.transcript.length} chars of transcript yielded no stream-json events`),
2879
+ };
2880
+ }
2881
+ if (normalizeForMatch(input.armWorkspaceDir ?? '') === '') {
2882
+ return {
2883
+ reason: 'cwd-unknown',
2884
+ detail: sanitizeGraderText('no armWorkspaceDir was supplied, so no relative path in the transcript can be anchored'),
2885
+ };
2886
+ }
2887
+ return undefined;
2888
+ }
2889
+ /**
2890
+ * How many transcript lines failed to parse.
2891
+ *
2892
+ * The parser exposes `malformedLineCount` precisely so a consumer can see the holes
2893
+ * it used to fill silently: a bare `continue` on a `JSON.parse` failure meant one
2894
+ * corrupted line could delete a contamination hit while the scan still reported full
2895
+ * strength, because `transcriptDecoded` is an any-of test that the terminal `result`
2896
+ * line alone satisfies.
2897
+ *
2898
+ * There is deliberately no local fallback count. A second implementation here could
2899
+ * disagree with the parser about what "malformed" means, and the whole point of the
2900
+ * field is that ONE place decides. (A build-order bridge lived here while the parser
2901
+ * change and this consumer landed together — `@vibe-agent-toolkit/utils` resolves to
2902
+ * its built `dist` under vitest, so the field was absent at test runtime until the
2903
+ * next build, and an absent count reads as zero. It has been rebuilt; the bridge is
2904
+ * gone. If this ever reads `undefined` again, that is a BUILD problem, not a reason
2905
+ * to re-add a local counter.)
2906
+ */
2907
+ function malformedTranscriptLines(parsed) {
2908
+ return parsed.malformedLineCount;
2909
+ }
2910
+ function malformedDegradation(count) {
2911
+ return {
2912
+ reason: 'transcript-malformed',
2913
+ detail: sanitizeGraderText(`${count} transcript line(s) failed to parse and were dropped, so the structured scan ` +
2914
+ 'saw a transcript with holes in it'),
2915
+ };
2916
+ }
2917
+ /**
2918
+ * Scan the skill-absent arm's transcript for proof it reached the skill.
2919
+ *
2920
+ * Pure + unit-testable. See the module-level block above {@link ToolIntent} for
2921
+ * the architecture and for the two demonstrated defects that forced it.
2922
+ */
2923
+ export function detectBaselineContamination(input) {
2924
+ const parsed = parseStreamJsonTranscript(input.transcript);
2925
+ // Content is scanned identically in both modes: a verbatim line of skill prose
2926
+ // is evidence wherever it appears, and unlike a path it cannot arrive by
2927
+ // orientation. So degradation never costs this signal anything.
2928
+ const contentHit = firstContentHit(contentHaystackFor(parsed, input.transcript), input.skillContentNeedles ?? []);
2929
+ const blind = scanDegradation(input, parsed);
2930
+ const armDir = normalizeForMatch(input.armWorkspaceDir ?? '');
2931
+ const startCwd = normalizeForMatch(input.armCwd ?? '') || armDir;
2932
+ const ctx = {
2933
+ declaredBasenames: new Set((input.executablePaths ?? [])
2934
+ .map((declared) => declaredExecutableNeedle(declared))
2935
+ .filter((needle) => needle !== undefined)
2936
+ .map((needle) => basenameOf(needle))),
2937
+ };
2938
+ const walked = blind === undefined ? walkToolReaches(parsed, startCwd, ctx) : undefined;
2939
+ // ⛔ THE FLAT FALLBACK IS FOR A TRANSCRIPT WE CANNOT WALK, NOT FOR A `cd` WE
2940
+ // CANNOT EVALUATE. It used to be for both, and that cost more than it saved in
2941
+ // both directions: one leading `cd $HOME` made the canonical four-step reach chain
2942
+ // read `hits: []`, and one leading `cd $HOME` plus a `find` that merely LISTED the
2943
+ // temp dir produced `harness-path` AND `vat-private-dir` — the flat scanner's
2944
+ // over-reporting reproduced verbatim, on the verdict reserved for "reached the
2945
+ // answer key". Reaches resolved BEFORE the bad `cd` are still validly resolved,
2946
+ // and {@link walkToolReaches} degrades forward from the point of uncertainty.
2947
+ // A dropped transcript line is a HOLE in the scan and nothing said so: the parser
2948
+ // skipped it in silence, and `transcriptDecoded` — an any-of test the terminal
2949
+ // `result` line satisfies on its own — still reported the transcript as decoded,
2950
+ // so one corrupted tool call DELETED a contamination hit under a confident verdict.
2951
+ const malformed = malformedTranscriptLines(parsed);
2952
+ const needles = directoryNeedles(input);
2953
+ const { dirHits, executableHits, blindGlob } = walked === undefined
2954
+ ? { ...flatPathHits(input, needles), blindGlob: undefined }
2955
+ : structuredPathHits(input, walked.reaches, armDir, needles);
2956
+ // Ordered by how much of the scan each one costs: no structured scan at all, then
2957
+ // a cwd hole, then a transcript hole, then a single reach nothing could expand.
2958
+ const degraded = blind
2959
+ ?? walked?.untracked
2960
+ ?? (malformed > 0 ? malformedDegradation(malformed) : undefined)
2961
+ ?? blindGlob;
2962
+ return {
2963
+ hits: [...dirHits, ...(contentHit === undefined ? [] : [contentHit]), ...executableHits],
2964
+ ...(degraded === undefined ? {} : { degraded }),
2965
+ };
2966
+ }
2967
+ /**
2968
+ * Which contamination detectors are ARMED for a run, derived from the SAME input
2969
+ * object handed to {@link detectBaselineContamination} so the two cannot disagree
2970
+ * about what was checked. A signal counts as armed only when it has a non-empty
2971
+ * needle set — a dir that was never threaded through produces no needles and must
2972
+ * not be reported as "checked".
2973
+ */
2974
+ export function activeContaminationSignals(input) {
2975
+ const signals = [];
2976
+ if (harnessNeedles(input.harnessRoot).length > 0)
2977
+ signals.push(KIND_HARNESS_PATH);
2978
+ if (siblingArmNeedles(input.siblingArmDir ?? '').length > 0)
2979
+ signals.push(KIND_SIBLING_ARM);
2980
+ if ((input.vatPrivateDirs ?? []).some((dir) => vatPrivateDirNeedles(dir ?? '').length > 0)) {
2981
+ signals.push(KIND_VAT_PRIVATE_DIR);
2982
+ }
2983
+ if ((input.skillContentNeedles ?? []).length > 0)
2984
+ signals.push(KIND_SKILL_CONTENT);
2985
+ // The NEEDLE, not the raw string's length: see {@link declaredExecutableNeedle}.
2986
+ // This is the arming test and the matching test at once, which is the only shape
2987
+ // in which they cannot disagree.
2988
+ if ((input.executablePaths ?? []).some((declared) => declaredExecutableNeedle(declared) !== undefined)) {
2989
+ signals.push(KIND_DECLARED_EXECUTABLE);
2990
+ }
2991
+ return signals;
2992
+ }
2993
+ /** Render the armed-signal list for the human-readable summary line. */
2994
+ function describeSignals(signals) {
2995
+ return signals.length === 0
2996
+ ? 'NO detector was armed for this run, so this verdict is not evidence of anything'
2997
+ : `checked by: ${[...signals].join(', ')}`;
2998
+ }
2999
+ /**
3000
+ * Assemble the run-level integrity block from every WITHOUT-arm eval's findings.
3001
+ * `findings` is empty on a clean run — the block is still emitted, with
3002
+ * `contaminated: false`, so "checked and clean" is distinguishable from
3003
+ * "never checked".
3004
+ *
3005
+ * `comparable` is derived from `skew` ALONE, deliberately, even though a
3006
+ * `controlArmFailures` entry also means the arms cannot be subtracted. `skew` is
3007
+ * the single authority `computeBaselineDelta` consults, and a second input to
3008
+ * `comparable` here would let this block say "not comparable" while the delta block
3009
+ * beside it printed a number — the exact two-blocks-disagreeing failure this module
3010
+ * keeps warning about. It costs nothing: a control-arm failure removes that eval's
3011
+ * control grade entirely, and {@link armExpectationSkew} reports a one-armed eval as
3012
+ * skew by construction, so the two always agree. See the run-harness test that pins
3013
+ * that derivation end to end rather than trusting this sentence.
3014
+ */
3015
+ export function summarizeBaselineIntegrity(input) {
3016
+ const { findings, signals, skew, degraded, controlArmFailures, observedEvals } = input;
3017
+ const base = {
3018
+ signals: [...signals],
3019
+ skew: [...skew],
3020
+ comparable: skew.length === 0,
3021
+ degraded: [...degraded],
3022
+ controlArmFailures: [...controlArmFailures],
3023
+ };
3024
+ const degradedIds = degraded.map((d) => `${d.evalId ?? '?'}: ${d.reason}`).join('; ');
3025
+ const degradedNote = degraded.length === 0
3026
+ ? ''
3027
+ : ` ⚠️ DEGRADED SCAN: ${degraded.length} eval(s) did not get a full structured scan ` +
3028
+ `[${degradedIds}]. An unparseable transcript falls back to flat text matching, which both ` +
3029
+ 'over- and under-reports; an unevaluable `cd` or a dropped transcript line instead leaves a ' +
3030
+ 'HOLE — everything resolved up to that point stands, and nothing after it that needed a cwd ' +
3031
+ 'was resolved at all. Either way a clean verdict here is not the same claim as a clean ' +
3032
+ 'structured scan.';
3033
+ const skewNote = skew.length === 0
3034
+ ? ''
3035
+ : ` ARMS NOT COMPARABLE: ${skew.length} eval(s) [${skew.map((s) => s.evalId).join(', ')}] were graded ` +
3036
+ 'against a different number of expectations on each arm, so the two summaries have different ' +
3037
+ 'denominators and subtracting them is meaningless — a short-graded control reads as a skill that did nothing.';
3038
+ // Printed BEFORE the skew note it causes: a dead control arm is also a skew
3039
+ // entry, and an operator who reads only the skew sentence goes and audits the
3040
+ // grader prompt for a run whose grader never got to speak.
3041
+ const controlCauses = controlArmFailures.map((f) => `${f.evalId}: ${f.detail}`).join('; ');
3042
+ const controlNote = controlArmFailures.length === 0
3043
+ ? ''
3044
+ : ` CONTROL ARM DID NOT RUN: ${controlArmFailures.length} eval(s) produced no skill-absent grade — ` +
3045
+ `${controlCauses}. ` +
3046
+ 'The treatment arm was graded and its artifacts are complete; only the comparison is missing. ' +
3047
+ 'Re-run --baseline to recover the delta — the treatment result you already paid for stands.';
3048
+ // ⛔ NOTHING WAS OBSERVED, so nothing may be reported as observed. The absence
3049
+ // LEADS: on the run this was found on, the two false sentences came first and
3050
+ // `CONTROL ARM DID NOT RUN` came third, so the reader met the reassurance before
3051
+ // the retraction. "Interpretable as instruction lift" is dropped outright rather
3052
+ // than qualified — there is no delta to interpret when no control arm ran.
3053
+ if (observedEvals === 0) {
3054
+ return {
3055
+ ...base,
3056
+ contaminated: false,
3057
+ summary: 'NOT CHECKED: no skill-absent eval was scanned, so `contaminated: false` records that nothing ' +
3058
+ `was looked at — not that nothing was found (${describeSignals(signals)}).` +
3059
+ controlNote +
3060
+ skewNote +
3061
+ degradedNote,
3062
+ findings: [],
3063
+ };
3064
+ }
3065
+ if (findings.length === 0) {
3066
+ return {
3067
+ ...base,
3068
+ contaminated: false,
3069
+ summary: 'No skill-absent eval was observed reaching the skill. The A/B delta is interpretable as instruction lift ' +
3070
+ `(note: both arms still share a filesystem — this is not a capability control; ${describeSignals(signals)}).` +
3071
+ controlNote +
3072
+ skewNote +
3073
+ degradedNote,
3074
+ findings: [],
3075
+ };
3076
+ }
3077
+ const ids = findings.map(f => f.evalId).join(', ');
3078
+ return {
3079
+ ...base,
3080
+ contaminated: true,
3081
+ summary: `BASELINE CONTAMINATED: the skill-absent arm reached the skill in ${findings.length} eval(s) [${ids}]. ` +
3082
+ 'The reported delta is NOT a measure of skill lift — the control arm had the treatment. ' +
3083
+ 'Most likely an ambient copy of the skill in this repo\'s build output or the installed plugin cache.' +
3084
+ controlNote +
3085
+ skewNote +
3086
+ degradedNote,
3087
+ findings: [...findings],
3088
+ };
3089
+ }
3090
+ //# sourceMappingURL=baseline-integrity.js.map