@shield-agent/kya 0.32.9 → 0.33.2

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 (279) hide show
  1. package/.cursor-plugin/plugin.json +1 -1
  2. package/.mcp.json +1 -1
  3. package/LIMITATIONS.md +2 -2
  4. package/README.md +178 -12
  5. package/amp/settings.example.json +1 -1
  6. package/claude/claude_desktop_config.example.json +1 -1
  7. package/cline/cline_mcp_settings.example.json +1 -1
  8. package/copilot/mcp-config.example.json +1 -1
  9. package/data/iocs.json +17 -0
  10. package/dist/cli.d.ts +1 -1
  11. package/dist/cli.d.ts.map +1 -1
  12. package/dist/cli.js +149 -16
  13. package/dist/cli.js.map +1 -1
  14. package/dist/commands/connect.d.ts +1 -1
  15. package/dist/commands/connect.d.ts.map +1 -1
  16. package/dist/commands/connect.js +11 -3
  17. package/dist/commands/connect.js.map +1 -1
  18. package/dist/commands/dash.d.ts.map +1 -1
  19. package/dist/commands/dash.js +25 -1
  20. package/dist/commands/dash.js.map +1 -1
  21. package/dist/commands/doctor.d.ts +8 -0
  22. package/dist/commands/doctor.d.ts.map +1 -0
  23. package/dist/commands/doctor.js +26 -0
  24. package/dist/commands/doctor.js.map +1 -0
  25. package/dist/commands/eval-tool.d.ts +3 -0
  26. package/dist/commands/eval-tool.d.ts.map +1 -1
  27. package/dist/commands/eval-tool.js +2 -1
  28. package/dist/commands/eval-tool.js.map +1 -1
  29. package/dist/commands/export.d.ts +33 -0
  30. package/dist/commands/export.d.ts.map +1 -0
  31. package/dist/commands/export.js +121 -0
  32. package/dist/commands/export.js.map +1 -0
  33. package/dist/commands/hook.d.ts.map +1 -1
  34. package/dist/commands/hook.js +120 -26
  35. package/dist/commands/hook.js.map +1 -1
  36. package/dist/commands/hosted-hook.d.ts +8 -1
  37. package/dist/commands/hosted-hook.d.ts.map +1 -1
  38. package/dist/commands/hosted-hook.js +15 -5
  39. package/dist/commands/hosted-hook.js.map +1 -1
  40. package/dist/commands/map.d.ts +21 -0
  41. package/dist/commands/map.d.ts.map +1 -0
  42. package/dist/commands/map.js +76 -0
  43. package/dist/commands/map.js.map +1 -0
  44. package/dist/commands/notify-flush.d.ts.map +1 -1
  45. package/dist/commands/notify-flush.js +7 -1
  46. package/dist/commands/notify-flush.js.map +1 -1
  47. package/dist/commands/posture.d.ts +21 -0
  48. package/dist/commands/posture.d.ts.map +1 -0
  49. package/dist/commands/posture.js +177 -0
  50. package/dist/commands/posture.js.map +1 -0
  51. package/dist/commands/receipt-serve.d.ts +7 -0
  52. package/dist/commands/receipt-serve.d.ts.map +1 -1
  53. package/dist/commands/receipt-serve.js +50 -0
  54. package/dist/commands/receipt-serve.js.map +1 -1
  55. package/dist/commands/start.d.ts.map +1 -1
  56. package/dist/commands/start.js +5 -1
  57. package/dist/commands/start.js.map +1 -1
  58. package/dist/commands/team.d.ts.map +1 -1
  59. package/dist/commands/team.js +30 -11
  60. package/dist/commands/team.js.map +1 -1
  61. package/dist/commands/wire-hooks.d.ts +5 -1
  62. package/dist/commands/wire-hooks.d.ts.map +1 -1
  63. package/dist/commands/wire-hooks.js +23 -8
  64. package/dist/commands/wire-hooks.js.map +1 -1
  65. package/dist/commands/wrap.d.ts +38 -1
  66. package/dist/commands/wrap.d.ts.map +1 -1
  67. package/dist/commands/wrap.js +82 -6
  68. package/dist/commands/wrap.js.map +1 -1
  69. package/dist/daily-slot.d.ts +2 -0
  70. package/dist/daily-slot.d.ts.map +1 -0
  71. package/dist/daily-slot.js +29 -0
  72. package/dist/daily-slot.js.map +1 -0
  73. package/dist/dash/dash.d.ts.map +1 -1
  74. package/dist/dash/dash.js +10 -0
  75. package/dist/dash/dash.js.map +1 -1
  76. package/dist/dash/entitlement.d.ts +1 -1
  77. package/dist/dash/entitlement.d.ts.map +1 -1
  78. package/dist/dash/entitlement.js +1 -0
  79. package/dist/dash/entitlement.js.map +1 -1
  80. package/dist/dash/input.d.ts +1 -1
  81. package/dist/dash/input.d.ts.map +1 -1
  82. package/dist/dash/input.js +3 -0
  83. package/dist/dash/input.js.map +1 -1
  84. package/dist/dash/render.js +1 -1
  85. package/dist/dash/render.js.map +1 -1
  86. package/dist/dash/screens/free.js +1 -1
  87. package/dist/dash/screens/free.js.map +1 -1
  88. package/dist/device-id.d.ts +5 -0
  89. package/dist/device-id.d.ts.map +1 -0
  90. package/dist/device-id.js +78 -0
  91. package/dist/device-id.js.map +1 -0
  92. package/dist/fs-atomic.d.ts +9 -3
  93. package/dist/fs-atomic.d.ts.map +1 -1
  94. package/dist/fs-atomic.js +17 -11
  95. package/dist/fs-atomic.js.map +1 -1
  96. package/dist/gate/config-gen.d.ts +26 -2
  97. package/dist/gate/config-gen.d.ts.map +1 -1
  98. package/dist/gate/config-gen.js +53 -4
  99. package/dist/gate/config-gen.js.map +1 -1
  100. package/dist/gate/otlp-receiver.d.ts.map +1 -1
  101. package/dist/gate/otlp-receiver.js +3 -1
  102. package/dist/gate/otlp-receiver.js.map +1 -1
  103. package/dist/guard-pack.d.ts +58 -0
  104. package/dist/guard-pack.d.ts.map +1 -0
  105. package/dist/guard-pack.js +753 -0
  106. package/dist/guard-pack.js.map +1 -0
  107. package/dist/ioc/exec.d.ts +19 -0
  108. package/dist/ioc/exec.d.ts.map +1 -0
  109. package/dist/ioc/exec.js +49 -0
  110. package/dist/ioc/exec.js.map +1 -0
  111. package/dist/ioc/format.d.ts +4 -0
  112. package/dist/ioc/format.d.ts.map +1 -0
  113. package/dist/ioc/format.js +23 -0
  114. package/dist/ioc/format.js.map +1 -0
  115. package/dist/ioc/list.d.ts +29 -0
  116. package/dist/ioc/list.d.ts.map +1 -0
  117. package/dist/ioc/list.js +25 -0
  118. package/dist/ioc/list.js.map +1 -0
  119. package/dist/ioc/result.d.ts +18 -0
  120. package/dist/ioc/result.d.ts.map +1 -0
  121. package/dist/ioc/result.js +26 -0
  122. package/dist/ioc/result.js.map +1 -0
  123. package/dist/ioc/scan.d.ts +21 -0
  124. package/dist/ioc/scan.d.ts.map +1 -0
  125. package/dist/ioc/scan.js +247 -0
  126. package/dist/ioc/scan.js.map +1 -0
  127. package/dist/ioc/spawn.d.ts +26 -0
  128. package/dist/ioc/spawn.d.ts.map +1 -0
  129. package/dist/ioc/spawn.js +52 -0
  130. package/dist/ioc/spawn.js.map +1 -0
  131. package/dist/map/build.d.ts +58 -0
  132. package/dist/map/build.d.ts.map +1 -0
  133. package/dist/map/build.js +439 -0
  134. package/dist/map/build.js.map +1 -0
  135. package/dist/map/heat.d.ts +44 -0
  136. package/dist/map/heat.d.ts.map +1 -0
  137. package/dist/map/heat.js +96 -0
  138. package/dist/map/heat.js.map +1 -0
  139. package/dist/map/load.d.ts +38 -0
  140. package/dist/map/load.d.ts.map +1 -0
  141. package/dist/map/load.js +38 -0
  142. package/dist/map/load.js.map +1 -0
  143. package/dist/map/page.d.ts +90 -0
  144. package/dist/map/page.d.ts.map +1 -0
  145. package/dist/map/page.js +374 -0
  146. package/dist/map/page.js.map +1 -0
  147. package/dist/map/target-class.d.ts +22 -0
  148. package/dist/map/target-class.d.ts.map +1 -0
  149. package/dist/map/target-class.js +151 -0
  150. package/dist/map/target-class.js.map +1 -0
  151. package/dist/map/types.d.ts +102 -0
  152. package/dist/map/types.d.ts.map +1 -0
  153. package/dist/map/types.js +3 -0
  154. package/dist/map/types.js.map +1 -0
  155. package/dist/mcp/protocol.d.ts.map +1 -1
  156. package/dist/mcp/protocol.js +16 -5
  157. package/dist/mcp/protocol.js.map +1 -1
  158. package/dist/never-code.d.ts +7 -0
  159. package/dist/never-code.d.ts.map +1 -0
  160. package/dist/never-code.js +9 -0
  161. package/dist/never-code.js.map +1 -0
  162. package/dist/never-list-input.d.ts +48 -12
  163. package/dist/never-list-input.d.ts.map +1 -1
  164. package/dist/never-list-input.js +696 -29
  165. package/dist/never-list-input.js.map +1 -1
  166. package/dist/notify/dispatch.d.ts +4 -0
  167. package/dist/notify/dispatch.d.ts.map +1 -1
  168. package/dist/notify/dispatch.js +3 -1
  169. package/dist/notify/dispatch.js.map +1 -1
  170. package/dist/offline-evaluate.d.ts +26 -1
  171. package/dist/offline-evaluate.d.ts.map +1 -1
  172. package/dist/offline-evaluate.js +33 -4
  173. package/dist/offline-evaluate.js.map +1 -1
  174. package/dist/otel/exporter.d.ts +15 -2
  175. package/dist/otel/exporter.d.ts.map +1 -1
  176. package/dist/otel/exporter.js +52 -6
  177. package/dist/otel/exporter.js.map +1 -1
  178. package/dist/otel/map-attrs-optin.d.ts +39 -0
  179. package/dist/otel/map-attrs-optin.d.ts.map +1 -0
  180. package/dist/otel/map-attrs-optin.js +109 -0
  181. package/dist/otel/map-attrs-optin.js.map +1 -0
  182. package/dist/parse-args.d.ts +2 -0
  183. package/dist/parse-args.d.ts.map +1 -1
  184. package/dist/parse-args.js +4 -2
  185. package/dist/parse-args.js.map +1 -1
  186. package/dist/posture/card.d.ts +26 -0
  187. package/dist/posture/card.d.ts.map +1 -0
  188. package/dist/posture/card.js +46 -0
  189. package/dist/posture/card.js.map +1 -0
  190. package/dist/posture/collect.d.ts +19 -0
  191. package/dist/posture/collect.d.ts.map +1 -0
  192. package/dist/posture/collect.js +182 -0
  193. package/dist/posture/collect.js.map +1 -0
  194. package/dist/posture/send.d.ts +24 -0
  195. package/dist/posture/send.d.ts.map +1 -0
  196. package/dist/posture/send.js +162 -0
  197. package/dist/posture/send.js.map +1 -0
  198. package/dist/posture/spawn.d.ts +20 -0
  199. package/dist/posture/spawn.d.ts.map +1 -0
  200. package/dist/posture/spawn.js +33 -0
  201. package/dist/posture/spawn.js.map +1 -0
  202. package/dist/posture/store.d.ts +34 -0
  203. package/dist/posture/store.d.ts.map +1 -0
  204. package/dist/posture/store.js +107 -0
  205. package/dist/posture/store.js.map +1 -0
  206. package/dist/posture/validate.d.ts +33 -0
  207. package/dist/posture/validate.d.ts.map +1 -0
  208. package/dist/posture/validate.js +232 -0
  209. package/dist/posture/validate.js.map +1 -0
  210. package/dist/project-policy.d.ts +23 -0
  211. package/dist/project-policy.d.ts.map +1 -0
  212. package/dist/project-policy.js +95 -0
  213. package/dist/project-policy.js.map +1 -0
  214. package/dist/receipt/daemon.d.ts +9 -9
  215. package/dist/receipt/daemon.d.ts.map +1 -1
  216. package/dist/receipt/daemon.js +26 -11
  217. package/dist/receipt/daemon.js.map +1 -1
  218. package/dist/receipt/embeddings.d.ts +28 -10
  219. package/dist/receipt/embeddings.d.ts.map +1 -1
  220. package/dist/receipt/embeddings.js +221 -35
  221. package/dist/receipt/embeddings.js.map +1 -1
  222. package/dist/receipt/gate-page.d.ts.map +1 -1
  223. package/dist/receipt/gate-page.js +5 -22
  224. package/dist/receipt/gate-page.js.map +1 -1
  225. package/dist/receipt/live-server.d.ts +8 -0
  226. package/dist/receipt/live-server.d.ts.map +1 -1
  227. package/dist/receipt/live-server.js +136 -4
  228. package/dist/receipt/live-server.js.map +1 -1
  229. package/dist/receipt/render-receipt.d.ts +7 -1
  230. package/dist/receipt/render-receipt.d.ts.map +1 -1
  231. package/dist/receipt/render-receipt.js +118 -13
  232. package/dist/receipt/render-receipt.js.map +1 -1
  233. package/dist/receipt/search.d.ts +5 -0
  234. package/dist/receipt/search.d.ts.map +1 -1
  235. package/dist/receipt/search.js +23 -2
  236. package/dist/receipt/search.js.map +1 -1
  237. package/dist/receipt/semantic-setup.d.ts +21 -0
  238. package/dist/receipt/semantic-setup.d.ts.map +1 -0
  239. package/dist/receipt/semantic-setup.js +111 -0
  240. package/dist/receipt/semantic-setup.js.map +1 -0
  241. package/dist/receipt/semantic-status.d.ts +7 -0
  242. package/dist/receipt/semantic-status.d.ts.map +1 -0
  243. package/dist/receipt/semantic-status.js +26 -0
  244. package/dist/receipt/semantic-status.js.map +1 -0
  245. package/dist/sdk/govern.d.ts.map +1 -1
  246. package/dist/sdk/govern.js +7 -3
  247. package/dist/sdk/govern.js.map +1 -1
  248. package/dist/telemetry.d.ts.map +1 -1
  249. package/dist/telemetry.js +3 -23
  250. package/dist/telemetry.js.map +1 -1
  251. package/dist/trail-map-fields.d.ts +10 -0
  252. package/dist/trail-map-fields.d.ts.map +1 -0
  253. package/dist/trail-map-fields.js +53 -0
  254. package/dist/trail-map-fields.js.map +1 -0
  255. package/dist/trail-summary.d.ts.map +1 -1
  256. package/dist/trail-summary.js +11 -3
  257. package/dist/trail-summary.js.map +1 -1
  258. package/dist/trail.d.ts +9 -1
  259. package/dist/trail.d.ts.map +1 -1
  260. package/dist/trail.js +24 -1
  261. package/dist/trail.js.map +1 -1
  262. package/droid/README.md +1 -1
  263. package/droid/mcp.example.json +1 -1
  264. package/gemini/settings.example.json +1 -1
  265. package/grok/README.md +1 -1
  266. package/kilo/kilo.example.json +1 -1
  267. package/kimi/mcp.example.json +1 -1
  268. package/kiro/mcp.example.json +1 -1
  269. package/manifest.json +1 -1
  270. package/mastracode/mcp.example.json +1 -1
  271. package/mcp.json +1 -1
  272. package/openai/codex.config.example.toml +1 -1
  273. package/opencode/opencode.example.json +1 -1
  274. package/package.json +8 -4
  275. package/qwen/settings.example.json +1 -1
  276. package/server.json +2 -2
  277. package/vendor/cytoscape/LICENSE +19 -0
  278. package/vendor/cytoscape/README.md +7 -0
  279. package/vendor/cytoscape/cytoscape.min.js +31 -0
@@ -27,18 +27,21 @@ function keptCmds(seg) {
27
27
  return (seg.kept ??= commandsAt(seg.tokens, true));
28
28
  }
29
29
  /** A command word and the tokens after it. Copied once, when the segment is built. */
30
- class Cmd {
30
+ export class Cmd {
31
31
  name;
32
32
  raw;
33
33
  tokens;
34
34
  start;
35
+ coprocName;
35
36
  cached;
36
37
  /** `raw` is the word as written (a download substitution is checked on it). */
37
- constructor(name, raw, tokens, start) {
38
+ /** `coprocName` is true for the NAME of `coproc NAME { ... }`: it never runs, so the persistence rules skip it. */
39
+ constructor(name, raw, tokens, start, coprocName = false) {
38
40
  this.name = name;
39
41
  this.raw = raw;
40
42
  this.tokens = tokens;
41
43
  this.start = start;
44
+ this.coprocName = coprocName;
42
45
  }
43
46
  /** Arguments, copied on first use: most candidate words are never asked for them. */
44
47
  get args() {
@@ -156,7 +159,7 @@ function commandsAt(tokens, keepShells) {
156
159
  // `coproc NAME { ...` or `coproc NAME for ...`: NAME is a candidate and the body is scanned too.
157
160
  // Otherwise coproc is a wrapper: `coproc sudo -u root rm ...` and `coproc git push ...` run the command.
158
161
  if (t === "coproc" && i + 2 < tokens.length && isCoprocName(tokens[i + 1]) && COPROC_BODY.has(tokens[i + 2])) {
159
- out.push(new Cmd(tokens[i + 1], tokens[i + 1], tokens, i + 2));
162
+ out.push(new Cmd(tokens[i + 1], tokens[i + 1], tokens, i + 2, true));
160
163
  i++; // NAME is a candidate; the words after it are scanned next
161
164
  continue;
162
165
  }
@@ -180,7 +183,7 @@ function commandAt(tokens, keepShells) {
180
183
  const cs = commandsAt(tokens, keepShells);
181
184
  return cs[cs.length - 1];
182
185
  }
183
- function hasCommand(sh, pred) {
186
+ export function hasCommand(sh, pred) {
184
187
  // Indexed loops: this runs once per rule per segment, so no iterator objects.
185
188
  const segs = sh.segments;
186
189
  for (let i = 0; i < segs.length; i++) {
@@ -220,7 +223,7 @@ function resolveDots(p) {
220
223
  * Canonical form for matching. Repeated slashes and `/.` go, `~`, `~user` and
221
224
  * `$HOME` are expanded when the home directory is known, and `..` is resolved.
222
225
  */
223
- function normalizePath(w, home) {
226
+ export function normalizePath(w, home) {
224
227
  let p = w.replace(/\/{2,}/g, "/").replace(/\/\.(?=\/|$)/g, "");
225
228
  if (p === "")
226
229
  p = "/";
@@ -240,12 +243,27 @@ function normalizePath(w, home) {
240
243
  p = p.slice(0, -1);
241
244
  return p;
242
245
  }
243
- /** Root, home, home contents, or a top-level system directory (with optional `/*`). */
244
- function isProtected(word, home) {
246
+ /**
247
+ * A relative path that, joined to the working directory, is a strict ancestor of it
248
+ * (`..`, `../*`, `../../*`): the delete reaches above the project root. The project root
249
+ * itself (`.`, `./*`, `*`) is not above it. Needs a known absolute cwd, else false.
250
+ */
251
+ function aboveCwd(word, cwd) {
252
+ if (!cwd || !cwd.startsWith("/") || word.startsWith("/") || word.startsWith("~") || word.includes("$"))
253
+ return false;
254
+ const root = cwd.length > 1 ? cwd.replace(/\/+$/, "") : cwd;
255
+ const target = resolveDots(`${root}/${word}`.replace(/\/{2,}/g, "/")).replace(/\/\.?\*$/, "") || "/";
256
+ return target !== root && (target === "/" || root.startsWith(`${target}/`));
257
+ }
258
+ /** Root, home, home contents, a top-level system directory (with optional `/*`), or a path above the working directory. */
259
+ function isProtected(word, sh) {
260
+ const home = sh.home;
245
261
  const p = normalizePath(word, home);
246
262
  if (ROOT_SET.has(p) || TOP_LEVEL.test(p) || USER_HOME.test(p))
247
263
  return true;
248
- return home !== undefined && (p === home || p === `${home}/*` || p === `${home}/.*`);
264
+ if (home !== undefined && (p === home || p === `${home}/*` || p === `${home}/.*`))
265
+ return true;
266
+ return aboveCwd(word, sh.cwd);
249
267
  }
250
268
  function isRecursive(flags) {
251
269
  return flags.some((f) => f === "--recursive" || (/^-[A-Za-z]+$/.test(f) && /[rR]/.test(f)));
@@ -267,7 +285,7 @@ function findDeleteProtected(c, sh) {
267
285
  break;
268
286
  starts.push(t);
269
287
  }
270
- if (!starts.some((p) => isProtected(p, sh.home)))
288
+ if (!starts.some((p) => isProtected(p, sh)))
271
289
  return false;
272
290
  if (c.args.includes("-delete"))
273
291
  return true;
@@ -279,7 +297,7 @@ function rmRecursiveProtected(c, sh) {
279
297
  return false;
280
298
  const flags = c.args.filter((t) => t.startsWith("-"));
281
299
  const paths = c.args.filter((t) => !t.startsWith("-"));
282
- return isRecursive(flags) && paths.some((p) => isProtected(p, sh.home));
300
+ return isRecursive(flags) && paths.some((p) => isProtected(p, sh));
283
301
  }
284
302
  function chmodChownRecursiveProtected(c, sh) {
285
303
  if (c.name !== "chmod" && c.name !== "chown")
@@ -290,7 +308,7 @@ function chmodChownRecursiveProtected(c, sh) {
290
308
  return false;
291
309
  if (c.name === "chmod" && !rest.some((t) => /^0?777$/.test(t)))
292
310
  return false;
293
- return rest.some((p) => isProtected(p, sh.home));
311
+ return rest.some((p) => isProtected(p, sh));
294
312
  }
295
313
  const PROTECTED_BRANCH = (b) => {
296
314
  const n = b.toLowerCase();
@@ -311,7 +329,7 @@ const GIT_GLOBAL_FLAG = new Set([
311
329
  * The subcommand index, and whether every option before it is known. An unknown option makes
312
330
  * `known` false, so no later word is exempted by the subcommand (fail closed).
313
331
  */
314
- function gitSubInfo(a) {
332
+ export function gitSubInfo(a) {
315
333
  let i = 0;
316
334
  let known = true;
317
335
  while (i < a.length && a[i].startsWith("-")) {
@@ -468,7 +486,7 @@ function globSecret(p) {
468
486
  return ssh ? base.startsWith("id_") && !base.endsWith(".pub") : base === "credentials";
469
487
  }
470
488
  /** Private key, cloud or package credential, or a real `.env` file (templates are not secret). Globs that could match one count too. */
471
- function isSecretPath(raw, home) {
489
+ export function isSecretPath(raw, home) {
472
490
  const p = normalizePath(raw, home);
473
491
  const base = p.slice(p.lastIndexOf("/") + 1);
474
492
  if (/(^|\/)\.ssh\/id_/.test(p) && !base.endsWith(".pub"))
@@ -708,6 +726,17 @@ function nestedNetExec(sh) {
708
726
  }
709
727
  // Fork bomb: `:(){ :|:& };:` or any one-word name. The name is bounded so the scan stays linear.
710
728
  const FORK_BOMB = /(^|[\s;&|(){}])(:|[A-Za-z_][\w.-]{0,63})\s*\(\s*\)\s*\{\s*\2\s*\|\s*\2\s*&/;
729
+ // ksh and bash `function NAME { NAME|NAME& }`, with or without the `()`.
730
+ const FORK_BOMB_FUNCTION = /(^|[\s;&|(){}])function\s+([A-Za-z_][\w.-]{0,63})\s*(?:\(\s*\)\s*)?\{\s*\2\s*\|\s*\2\s*&/;
731
+ /**
732
+ * Tested on the whole text and on each token: quoted code given to `bash -c`, `sh -c`,
733
+ * `eval` or `sudo bash -c` is one token, and the quote in front of it is not a boundary
734
+ * for the raw text.
735
+ */
736
+ function forkBomb(sh) {
737
+ const bomb = (t) => FORK_BOMB.test(t) || FORK_BOMB_FUNCTION.test(t);
738
+ return bomb(sh.raw) || sh.segments.some((seg) => seg.tokens.some((t) => t.includes("|") && bomb(t)));
739
+ }
711
740
  const ANSI_ESC = {
712
741
  n: "\n", t: "\t", r: "\r", a: "\x07", b: "\b", e: "\x1b", f: "\f", v: "\v",
713
742
  "\\": "\\", "'": "'", '"': '"', "?": "?",
@@ -1070,14 +1099,589 @@ function segmentsOf(text, scope, depth) {
1070
1099
  }
1071
1100
  return segs;
1072
1101
  }
1073
- function parseShell(cmd, home) {
1074
- return { raw: cmd, segments: segmentsOf(cmd, new Map(), 0), home };
1102
+ let lastParsed;
1103
+ /** One-entry memo: the never-list rules and the guard pack parse the same command back to back. */
1104
+ export function parseShell(cmd, home, cwd) {
1105
+ if (lastParsed && lastParsed.raw === cmd && lastParsed.home === home && lastParsed.cwd === cwd)
1106
+ return lastParsed;
1107
+ return (lastParsed = { raw: cmd, segments: segmentsOf(cmd, new Map(), 0), home, cwd });
1108
+ }
1109
+ /**
1110
+ * Command operands: the words after the command word with redirections removed (`>`, `>>`, `<` and
1111
+ * their target, and an fd digit before a redirect). `cp k FILE 2>/dev/null` has the operands k and FILE.
1112
+ */
1113
+ function operandsOf(tokens) {
1114
+ const out = [];
1115
+ for (let i = 0; i < tokens.length; i++) {
1116
+ const t = tokens[i];
1117
+ if (REDIRECT_OPS.has(t)) {
1118
+ i++;
1119
+ continue;
1120
+ }
1121
+ if (/^\d+$/.test(t) && REDIRECT_OPS.has(tokens[i + 1] ?? ""))
1122
+ continue;
1123
+ out.push(t);
1124
+ }
1125
+ return out;
1126
+ }
1127
+ /** Persistence paths, matched on the normalized path (see normalizePath). */
1128
+ // Case-insensitive (`i`): macOS file systems are case-insensitive by default, so `~/.SSH/Authorized_Keys` is the same file.
1129
+ const AUTHORIZED_KEYS = /(^|\/)\.ssh\/authorized_keys2?$/i;
1130
+ const CRON_PATH = /^\/(etc\/cron|var\/spool\/cron)/i;
1131
+ // Directory forms end with `(\/|$)`: normalizePath drops a trailing slash, and `cp x ~/Library/LaunchAgents/` writes inside.
1132
+ const LAUNCH_PATH = /(^|\/)Library\/Launch(Agents|Daemons)(\/|$)/i;
1133
+ const SYSTEMD_USER_PATH = /(^|\/)\.config\/systemd\/user(\/|$)|^\/etc\/systemd\/user(\/|$)/i;
1134
+ const SHELL_RC_PATH = /(^|\/)\.(zshrc|zshenv|zprofile|zlogin|bashrc|bash_profile|bash_login|profile)$/i;
1135
+ /** A curl or wget word (not part of a longer name). Used on a whole command that writes a shell rc file. */
1136
+ const NET_WORD = /(^|[^A-Za-z0-9_])(curl|wget)(?![A-Za-z0-9_])/;
1137
+ /**
1138
+ * The candidates the persistence and tunnel rules read: the last MAX_CANDIDATES command words of a segment,
1139
+ * without coproc NAMEs. The real command is the last candidate, so it is always read. Earlier candidates come
1140
+ * from expansions (`$(:)tee`) and are bounded here, so a long run of them stays linear (`coproc tee {` repeated was quadratic).
1141
+ */
1142
+ const MAX_CANDIDATES = 16;
1143
+ /** Like hasCommand, over the bounded candidates. Allocates nothing: this runs for every rule on every command. */
1144
+ function hasCandidate(sh, pred) {
1145
+ const segs = sh.segments;
1146
+ for (let i = 0; i < segs.length; i++) {
1147
+ const cs = segs[i].cmds;
1148
+ let n = 0;
1149
+ for (let k = cs.length - 1; k >= 0 && n < MAX_CANDIDATES; k--) {
1150
+ const c = cs[k];
1151
+ if (c.coprocName)
1152
+ continue;
1153
+ n++;
1154
+ if (pred(c))
1155
+ return true;
1156
+ }
1157
+ }
1158
+ return false;
1159
+ }
1160
+ /**
1161
+ * Paths that a segment writes: redirect targets, tee operands, the destination of cp, mv and install,
1162
+ * the files of `sed -i`, and dd `of=`. Only the shapes named here count (a python `open(..., 'w')` does not).
1163
+ */
1164
+ function writtenPaths(seg, out) {
1165
+ const t = seg.tokens;
1166
+ for (let i = 0; i + 1 < t.length; i++)
1167
+ if (t[i] === ">" || t[i] === ">>")
1168
+ out.push(t[i + 1]);
1169
+ const cs = seg.cmds;
1170
+ let n = 0;
1171
+ for (let k = cs.length - 1; k >= 0 && n < MAX_CANDIDATES; k--) {
1172
+ const c = cs[k];
1173
+ if (c.coprocName)
1174
+ continue;
1175
+ n++;
1176
+ if (c.name === "tee") {
1177
+ for (const a of operandsOf(c.args))
1178
+ if (!a.startsWith("-"))
1179
+ out.push(a);
1180
+ }
1181
+ else if (c.name === "cp" || c.name === "mv" || c.name === "install") {
1182
+ copyTargets(c, out);
1183
+ }
1184
+ else if (c.name === "curl" || c.name === "wget") {
1185
+ downloadTargets(c, out);
1186
+ }
1187
+ else if (c.name === "sed") {
1188
+ if (c.args.some((a) => /^-[A-Za-z]*i/.test(a) || a.startsWith("--in-place"))) {
1189
+ out.push(...operandsOf(c.args).filter((a) => !a.startsWith("-")).slice(1));
1190
+ }
1191
+ }
1192
+ else if (c.name === "dd") {
1193
+ for (const a of c.args)
1194
+ if (a.startsWith("of="))
1195
+ out.push(a.slice(3));
1196
+ }
1197
+ }
1198
+ }
1199
+ /** The last path part of a path, with trailing slashes removed (`~/.ssh/` gives `.ssh`). */
1200
+ function baseName(p) {
1201
+ const s = p.replace(/\/+$/, "");
1202
+ return s.slice(s.lastIndexOf("/") + 1);
1203
+ }
1204
+ /** Options of cp, mv and install that take a value (`install -m MODE`, `cp -S SUFFIX`): the value is not a path. */
1205
+ const COPY_VALUE_LETTERS = "Smog";
1206
+ /**
1207
+ * A long option named by `full`, or by a unique prefix of it of at least `min` characters (`--tar` for
1208
+ * `--target-directory`). The value is the text after `=`, or the next word (then `used` is true).
1209
+ */
1210
+ function longOption(t, full, min, next) {
1211
+ const eq = t.indexOf("=");
1212
+ const name = eq >= 0 ? t.slice(0, eq) : t;
1213
+ if (name.length < min || !full.startsWith(name))
1214
+ return undefined;
1215
+ return eq >= 0 ? { value: t.slice(eq + 1), used: false } : { value: next, used: true };
1216
+ }
1217
+ /** `dir/base`, with one slash between them. */
1218
+ function joinPath(dir, base) {
1219
+ return dir.endsWith("/") ? dir + base : `${dir}/${base}`;
1220
+ }
1221
+ /**
1222
+ * cp, mv and install destinations. A destination that is a directory receives each source under its basename, so
1223
+ * both the destination and `DIR/basename` are tested. `-t DIR`, `-tDIR`, `--target-directory DIR`,
1224
+ * `--target-directory=DIR` and any unique prefix of the long option name the directory first.
1225
+ */
1226
+ function copyTargets(c, out) {
1227
+ const ops = operandsOf(c.args);
1228
+ let target;
1229
+ const plain = [];
1230
+ for (let i = 0; i < ops.length; i++) {
1231
+ const a = ops[i];
1232
+ if (a === "--")
1233
+ continue;
1234
+ if (a.startsWith("--")) {
1235
+ const t = longOption(a, "--target-directory", 3, ops[i + 1]);
1236
+ if (t !== undefined) {
1237
+ target = t.value;
1238
+ if (t.used)
1239
+ i++;
1240
+ }
1241
+ continue;
1242
+ }
1243
+ if (a.length > 1 && a.startsWith("-")) {
1244
+ for (let k = 1; k < a.length; k++) {
1245
+ const ch = a[k];
1246
+ if (ch === "t") {
1247
+ if (k < a.length - 1)
1248
+ target = a.slice(k + 1);
1249
+ else {
1250
+ target = ops[i + 1];
1251
+ i++;
1252
+ }
1253
+ break;
1254
+ }
1255
+ if (COPY_VALUE_LETTERS.includes(ch)) {
1256
+ if (k === a.length - 1)
1257
+ i++;
1258
+ break;
1259
+ }
1260
+ if (!/[A-Za-z]/.test(ch))
1261
+ break;
1262
+ }
1263
+ continue;
1264
+ }
1265
+ plain.push(a);
1266
+ }
1267
+ if (target !== undefined)
1268
+ pushDestination(out, target, plain);
1269
+ else if (plain.length >= 2)
1270
+ pushDestination(out, plain[plain.length - 1], plain.slice(0, -1));
1271
+ }
1272
+ function pushDestination(out, dest, sources) {
1273
+ out.push(dest);
1274
+ for (const src of sources) {
1275
+ const base = baseName(src);
1276
+ if (base !== "")
1277
+ out.push(joinPath(dest, base));
1278
+ }
1075
1279
  }
1076
1280
  /**
1077
- * THE rule table. One row per rule, one comment per row. The owner approved this list
1078
- * on 2026-10-29, with no changes. Changes to a row need owner approval. Matching is by
1079
- * parsed command word and exact target, never by substring alone, so
1080
- * `~/proj/node_modules`, `/tmp/x` and `.env.example` stay allowed.
1281
+ * Write targets of curl and wget. curl: `-o`/`--output`, and `--output-dir DIR` (with `-O`/`--remote-name`, the file
1282
+ * is the URL basename in that directory). wget: `-O`/`--output-document`, and `-P`/`--directory-prefix DIR` (wget
1283
+ * writes the URL basename in that directory unless -O is given). A value can be attached (`-o/path`, `-sSo~/x`,
1284
+ * `--output=/x`), and a long option can be a unique prefix (`--out`). Extra paths only add tests, so this fails closed.
1285
+ */
1286
+ function downloadTargets(c, out) {
1287
+ const curl = c.name === "curl";
1288
+ const FILE_OPT = curl ? "--output" : "--output-document";
1289
+ const DIR_OPT = curl ? "--output-dir" : "--directory-prefix";
1290
+ const FILE_LETTER = curl ? "o" : "O";
1291
+ const DIR_LETTER = curl ? "" : "P";
1292
+ const a = c.args;
1293
+ const files = [];
1294
+ const urls = [];
1295
+ let dir;
1296
+ let remote = false;
1297
+ for (let i = 0; i < a.length; i++) {
1298
+ const t = a[i];
1299
+ if (t.startsWith("--")) {
1300
+ const name = t.split("=")[0];
1301
+ if (curl && name.length >= 4 && ("--remote-name".startsWith(name) || "--remote-name-all".startsWith(name) || "--remote-header-name".startsWith(name) || "--content-disposition".startsWith(name)))
1302
+ remote = true;
1303
+ const f = longOption(t, FILE_OPT, 3, a[i + 1]);
1304
+ const d = longOption(t, DIR_OPT, 3, a[i + 1]);
1305
+ if (f?.value !== undefined)
1306
+ files.push(f.value);
1307
+ if (d?.value !== undefined)
1308
+ dir = d.value;
1309
+ if (f?.used || d?.used)
1310
+ i++;
1311
+ continue;
1312
+ }
1313
+ if (t.length > 1 && t.startsWith("-")) {
1314
+ for (let k = 1; k < t.length; k++) {
1315
+ const ch = t[k];
1316
+ if (ch === FILE_LETTER || (DIR_LETTER !== "" && ch === DIR_LETTER)) {
1317
+ const used = k === t.length - 1;
1318
+ const v = used ? a[i + 1] : t.slice(k + 1);
1319
+ if (ch === FILE_LETTER) {
1320
+ if (v !== undefined)
1321
+ files.push(v);
1322
+ }
1323
+ else {
1324
+ dir = v;
1325
+ }
1326
+ if (used)
1327
+ i++;
1328
+ break;
1329
+ }
1330
+ if (curl && (ch === "O" || ch === "J"))
1331
+ remote = true;
1332
+ if (!/[A-Za-z]/.test(ch))
1333
+ break;
1334
+ }
1335
+ continue;
1336
+ }
1337
+ urls.push(t); // any operand: a scheme-less host is a URL too
1338
+ }
1339
+ for (const f of files) {
1340
+ out.push(f);
1341
+ if (dir !== undefined && dir !== "")
1342
+ out.push(joinPath(dir, f));
1343
+ }
1344
+ const named = curl ? remote : files.length === 0;
1345
+ if (named) {
1346
+ // The server can choose the file name, so the directory is a write target. `authorized_keys` is joined onto it
1347
+ // too: the rule then tests the normalized path, so `~/.SSH`, `$HOME/.ssh`, `./../.ssh` all resolve to a .ssh
1348
+ // directory. Each name is pushed bare and joined onto the directory (extra paths only add tests).
1349
+ const d = dir ?? "";
1350
+ if (d !== "") {
1351
+ out.push(d);
1352
+ out.push(joinPath(d, "authorized_keys"));
1353
+ }
1354
+ for (const u of urls) {
1355
+ const base = baseName(u.replace(/[?#].*$/, ""));
1356
+ if (base === "")
1357
+ continue;
1358
+ out.push(base);
1359
+ if (d !== "")
1360
+ out.push(joinPath(d, base));
1361
+ }
1362
+ }
1363
+ }
1364
+ /** Normalized written paths of one command, computed once for all rules that ask (they all read the same list). */
1365
+ const WRITTEN_CACHE = new WeakMap();
1366
+ function writtenIn(sh) {
1367
+ let out = WRITTEN_CACHE.get(sh);
1368
+ if (out === undefined) {
1369
+ const raw = [];
1370
+ const segs = sh.segments;
1371
+ for (let i = 0; i < segs.length; i++) {
1372
+ const seg = segs[i];
1373
+ // `>|` and `>&` end the segment at the operator, so the target is the first word of the next segment.
1374
+ const prev = i > 0 ? segs[i - 1].tokens : undefined;
1375
+ const last = prev?.[prev.length - 1];
1376
+ if (seg.tokens.length > 0 && (last === ">" || last === ">>"))
1377
+ raw.push(seg.tokens[0]);
1378
+ writtenPaths(seg, raw);
1379
+ }
1380
+ out = raw.map((w) => normalizePath(w, sh.home));
1381
+ WRITTEN_CACHE.set(sh, out);
1382
+ }
1383
+ return out;
1384
+ }
1385
+ /** True when a segment of the command writes to a path that `pred` accepts (after normalizePath). */
1386
+ function writesTo(sh, pred) {
1387
+ for (const p of writtenIn(sh))
1388
+ if (pred(p))
1389
+ return true;
1390
+ return false;
1391
+ }
1392
+ /** crontab that installs a table: `crontab -`, `crontab -e` or `crontab FILE`. `-l` and `-r` do not. */
1393
+ function cronWrites(c) {
1394
+ if (c.name !== "crontab")
1395
+ return false;
1396
+ const ops = operandsOf(c.args);
1397
+ for (let i = 0; i < ops.length; i++) {
1398
+ const t = ops[i];
1399
+ if (t === "-u") {
1400
+ i++;
1401
+ continue;
1402
+ }
1403
+ if (t === "-" || t === "-e" || !t.startsWith("-"))
1404
+ return true;
1405
+ }
1406
+ return false;
1407
+ }
1408
+ /** `at TIME` or `at -f FILE` schedules a job. `at -l` (list) and `at -d` or `-r` (remove) do not. */
1409
+ function atSchedules(c) {
1410
+ if (c.name !== "at")
1411
+ return false;
1412
+ const ops = operandsOf(c.args);
1413
+ if (ops.includes("-d") || ops.includes("-r"))
1414
+ return false;
1415
+ return ops.includes("-f") || ops.some((t) => !t.startsWith("-"));
1416
+ }
1417
+ /** The first operand that is not an option (the subcommand of launchctl and systemctl). */
1418
+ function firstSubcommand(c) {
1419
+ return operandsOf(c.args).find((t) => !t.startsWith("-"));
1420
+ }
1421
+ /** `launchctl load` or `launchctl bootstrap` starts a LaunchAgent or LaunchDaemon. `launchctl list` does not. */
1422
+ function launchLoads(c) {
1423
+ if (c.name !== "launchctl")
1424
+ return false;
1425
+ const sub = firstSubcommand(c);
1426
+ return sub === "load" || sub === "bootstrap";
1427
+ }
1428
+ /** `systemctl --user enable` or `link` makes a user unit start at login. `--user status` does not. */
1429
+ function systemdUserEnables(c) {
1430
+ if (c.name !== "systemctl")
1431
+ return false;
1432
+ const ops = operandsOf(c.args);
1433
+ if (!ops.includes("--user"))
1434
+ return false;
1435
+ const sub = firstSubcommand(c);
1436
+ return sub === "enable" || sub === "link";
1437
+ }
1438
+ /** Option letters that take a value in ssh (`-p 22`, `-oKey=val`, `-L 8080:x:80`). */
1439
+ const SSH_VALUE_LETTERS = "BbcDEeFIiJLlmOopQRSWw";
1440
+ /**
1441
+ * Every ssh option in the command, wherever it stands. OpenSSH parses options after the destination too
1442
+ * (`ssh host -R 9000:localhost:22 -N`), so every operand is read until `--`. Returns the option letters, the
1443
+ * values of `-o` options (`RemoteForward=...`), the `-F` config files, and the destination (the first operand
1444
+ * that is not an option). A value option takes the rest of its word, or the next word: `-fNR host`, `-R8080:...`,
1445
+ * `-oRemoteForward=...`, `-F~/x`.
1446
+ */
1447
+ function sshShape(c) {
1448
+ const ops = operandsOf(c.args);
1449
+ const letters = new Set();
1450
+ const opts = [];
1451
+ const configs = [];
1452
+ const libs = [];
1453
+ let dest;
1454
+ let noMoreOptions = false;
1455
+ for (let i = 0; i < ops.length; i++) {
1456
+ const t = ops[i];
1457
+ if (!noMoreOptions && t === "--") {
1458
+ noMoreOptions = true;
1459
+ continue;
1460
+ }
1461
+ if (noMoreOptions || !t.startsWith("-") || t === "-") {
1462
+ if (dest === undefined)
1463
+ dest = t;
1464
+ continue;
1465
+ }
1466
+ for (let k = 1; k < t.length; k++) {
1467
+ const ch = t[k];
1468
+ letters.add(ch);
1469
+ if (SSH_VALUE_LETTERS.includes(ch)) {
1470
+ const attached = k < t.length - 1;
1471
+ const value = attached ? t.slice(k + 1) : ops[i + 1];
1472
+ if (!attached)
1473
+ i++;
1474
+ if (ch === "o" && value !== undefined)
1475
+ opts.push(value);
1476
+ if (ch === "F" && value !== undefined)
1477
+ configs.push(value);
1478
+ if (ch === "I" && value !== undefined)
1479
+ libs.push(value);
1480
+ break;
1481
+ }
1482
+ }
1483
+ }
1484
+ return { letters, opts, configs, libs, dest };
1485
+ }
1486
+ /** The two jump shapes allowed as a ProxyCommand: `ssh -W %h:%p HOST` and `nc %h %p`. */
1487
+ const SSH_JUMP = /^(ssh -W %h:%p [A-Za-z0-9._@:-]+|nc %h %p)$/;
1488
+ /**
1489
+ * One -o option value: the keyword is the text before the first `=` or whitespace, lower-cased. Double quotes are
1490
+ * removed first, the way OpenSSH reads `-o "ProxyCommand=nc x 22"` and `-o '"ProxyCommand" nc x 22'`.
1491
+ * Undefined when no keyword can be read.
1492
+ */
1493
+ function sshOptionKeyValue(v) {
1494
+ const m = /^([A-Za-z][A-Za-z0-9]*)\s*(?:=|\s)\s*([\s\S]*)$/.exec(v.replace(/"/g, "").trim());
1495
+ return m ? { key: m[1].toLowerCase(), value: m[2].trim() } : undefined;
1496
+ }
1497
+ /**
1498
+ * A -o option that runs a command and whose value is not one of the two jump shapes (`SSH_JUMP`), or a -o option
1499
+ * that cannot be read (fail closed). Gated keys: every keyword that ends in `command` (ProxyCommand, LocalCommand,
1500
+ * KnownHostsCommand, RemoteCommand), except PermitLocalCommand, a yes/no switch. This is an allowlist of the value,
1501
+ * not a word list. IdentityAgent names a socket and is not gated.
1502
+ */
1503
+ function sshOptionDenied(v) {
1504
+ const kv = sshOptionKeyValue(v);
1505
+ if (!kv)
1506
+ return true;
1507
+ if (!kv.key.endsWith("command") || kv.key === "permitlocalcommand")
1508
+ return false;
1509
+ return !SSH_JUMP.test(kv.value);
1510
+ }
1511
+ /** -o keys that load a shared library (a provider) into ssh: only `none` or an empty value is allowed. */
1512
+ const CODE_PROVIDER_KEYS = new Set(["pkcs11provider", "securitykeyprovider"]);
1513
+ function providerValueDenied(value) {
1514
+ const v = value.replace(/"/g, "").trim().toLowerCase();
1515
+ return v !== "" && v !== "none";
1516
+ }
1517
+ function sshCodeLoadingDenied(c) {
1518
+ const shape = sshShape(c);
1519
+ if (shape.libs.some(providerValueDenied))
1520
+ return true;
1521
+ return shape.opts.some((v) => {
1522
+ const kv = sshOptionKeyValue(v);
1523
+ return kv !== undefined && CODE_PROVIDER_KEYS.has(kv.key) && providerValueDenied(kv.value);
1524
+ });
1525
+ }
1526
+ const IPV4 = /^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/;
1527
+ /** Octets of a dotted IPv4 literal, or undefined. */
1528
+ function ipv4Octets(s) {
1529
+ const m = IPV4.exec(s);
1530
+ if (!m)
1531
+ return undefined;
1532
+ const o = m.slice(1).map(Number);
1533
+ return o.every((x) => x <= 255) ? o : undefined;
1534
+ }
1535
+ /** A dotted IPv4 host, with an optional `user@` in front. A name such as `1.2.3.4.nip.io` is not one. */
1536
+ function isIpv4Host(d) {
1537
+ if (d === undefined)
1538
+ return false;
1539
+ return ipv4Octets(d.slice(d.lastIndexOf("@") + 1)) !== undefined;
1540
+ }
1541
+ /** Public IPv4: not loopback (127), private (10, 172.16/12, 192.168), link-local (169.254), or 0. */
1542
+ function isPublicIpv4(s) {
1543
+ const [a = -1, b = -1] = ipv4Octets(s) ?? [];
1544
+ if (a < 0)
1545
+ return false;
1546
+ return !(a === 0 || a === 10 || a === 127 || (a === 169 && b === 254) || (a === 172 && b >= 16 && b <= 31) || (a === 192 && b === 168));
1547
+ }
1548
+ const IPV4_IN_TEXT = /(?:^|[^\d.])((?:\d{1,3}\.){3}\d{1,3})(?![\d.])/g;
1549
+ /** True when a word holds a public dotted IPv4 literal (a URL `http://8.8.8.8:80/x` counts). */
1550
+ function hasPublicIpv4(s) {
1551
+ for (const m of s.matchAll(IPV4_IN_TEXT))
1552
+ if (isPublicIpv4(m[1]))
1553
+ return true;
1554
+ return false;
1555
+ }
1556
+ /** `ssh -R` (any destination, any position, or `RemoteForward` in an option): a reverse tunnel into this machine. */
1557
+ function sshReverseForward(c) {
1558
+ if (c.name !== "ssh")
1559
+ return false;
1560
+ const { letters, opts } = sshShape(c);
1561
+ return letters.has("R") || opts.some((v) => /^remoteforward/i.test(v));
1562
+ }
1563
+ /** `ssh -L ... -N` (or `-o LocalForward`) to a dotted IPv4 address. A tunnel to a host name is allowed (a normal dev tunnel). */
1564
+ function sshLocalToRawIp(c) {
1565
+ if (c.name !== "ssh")
1566
+ return false;
1567
+ const { letters, opts, dest } = sshShape(c);
1568
+ const forward = letters.has("L") || opts.some((v) => /^localforward/i.test(v));
1569
+ return forward && letters.has("N") && isIpv4Host(dest);
1570
+ }
1571
+ /** `ssh -w` opens a layer 3 tunnel device on both ends. Denied: it is a tunnel with no host check. */
1572
+ function sshTunnelDevice(c) {
1573
+ return c.name === "ssh" && sshShape(c).letters.has("w");
1574
+ }
1575
+ /** `ssh -o ProxyCommand=...` that runs a shell, netcat, socat, an interpreter or a download: a command run on connect. */
1576
+ function sshProxyExec(c) {
1577
+ return c.name === "ssh" && sshShape(c).opts.some(sshOptionDenied);
1578
+ }
1579
+ /**
1580
+ * `ssh -F FILE` when this same command writes FILE (a redirect, tee, cp and so on): the config can carry a
1581
+ * ProxyCommand that was written in the same call. A config written by an earlier call is not seen (documented limit).
1582
+ */
1583
+ function sshConfigWritten(sh) {
1584
+ const written = writtenIn(sh);
1585
+ if (written.length === 0)
1586
+ return false;
1587
+ return hasCandidate(sh, (c) => c.name === "ssh" && sshShape(c).configs.some((f) => written.includes(normalizePath(f, sh.home))));
1588
+ }
1589
+ const NC_NAMES = new Set(["nc", "ncat", "netcat"]);
1590
+ /**
1591
+ * A netcat or socat listener or client that runs a program. For nc, ncat and netcat, `-e`, `-c` or `--exec`,
1592
+ * `--sh-exec` (also inside a short cluster such as `-nve`). `nc -z`, `nc -l PORT`, and `nc host port < file` are not matches.
1593
+ */
1594
+ function netcatExec(c) {
1595
+ if (NC_NAMES.has(c.name)) {
1596
+ return operandsOf(c.args).some((a) => /^-[A-Za-z]*[ec]/.test(a) || a === "--exec" || a === "--sh-exec");
1597
+ }
1598
+ if (c.name === "socat")
1599
+ return operandsOf(c.args).some((a) => /(^|[,:])(EXEC|SYSTEM):/i.test(a));
1600
+ return false;
1601
+ }
1602
+ const DEV_TCP = /^\/dev\/(tcp|udp)\//;
1603
+ const REDIRECT_IN_OUT = new Set(["<", ">", ">>"]);
1604
+ /**
1605
+ * A bash network redirect: `/dev/tcp/HOST/PORT` as the target of `<`, `>` or `>>`, or as the first word of a
1606
+ * segment (`bash -i >& /dev/tcp/...` splits at the `&`, so the target starts the next segment). Text that
1607
+ * only names the path (`git commit -m "/dev/tcp"`) does not count.
1608
+ */
1609
+ function devTcpRedirect(sh) {
1610
+ if (!sh.raw.includes("/dev/"))
1611
+ return false;
1612
+ for (const seg of sh.segments) {
1613
+ const t = seg.tokens;
1614
+ if (t.length > 0 && DEV_TCP.test(t[0]))
1615
+ return true;
1616
+ for (let i = 0; i + 1 < t.length; i++) {
1617
+ if (REDIRECT_IN_OUT.has(t[i]) && DEV_TCP.test(t[i + 1]))
1618
+ return true;
1619
+ }
1620
+ }
1621
+ return false;
1622
+ }
1623
+ /**
1624
+ * True when the words of one command dump the environment: `env` or `printenv` with no command after it,
1625
+ * bare `set` (no operands), `export -p`, `declare -p` and `compgen -e`. `set -e` and `env FOO=1 node x` are not dumps.
1626
+ */
1627
+ function isEnvDump(tokens) {
1628
+ let dump = false;
1629
+ for (let i = 0; i < tokens.length; i++) {
1630
+ const t = tokens[i];
1631
+ if (t.startsWith("-") || ASSIGN.test(t))
1632
+ continue;
1633
+ const name = wordName(t);
1634
+ if (name === "printenv")
1635
+ return true;
1636
+ if (name === "env") {
1637
+ dump = true;
1638
+ continue;
1639
+ }
1640
+ if (WRAPPERS.has(name))
1641
+ continue;
1642
+ if (name === "set")
1643
+ return i === tokens.length - 1;
1644
+ if (name === "export" || name === "declare")
1645
+ return tokens.slice(i + 1).includes("-p");
1646
+ if (name === "compgen")
1647
+ return tokens.slice(i + 1).includes("-e");
1648
+ return false;
1649
+ }
1650
+ return dump;
1651
+ }
1652
+ const RAW_IP_CLIENTS = new Set(["curl", "wget", "nc", "ncat", "netcat", "socat", "telnet"]);
1653
+ const VAR_REF = /\$\{?[A-Za-z_]/;
1654
+ /**
1655
+ * Environment names or values sent to a public raw IPv4 address. The command must have a client
1656
+ * (curl, wget, nc, socat, telnet) with a public IPv4 operand, and either an environment dump
1657
+ * (`env | ...`, `printenv`, bare `set`, `$(env)`) or a `$NAME` reference anywhere in the same command.
1658
+ * Linear: the client check runs first and stops most commands; then one scan for the dump, one for the reference.
1659
+ * Loopback and private addresses are allowed on purpose.
1660
+ */
1661
+ function envToPublicIp(sh) {
1662
+ if (!hasCandidate(sh, (c) => RAW_IP_CLIENTS.has(c.name) && operandsOf(c.args).some(hasPublicIpv4)))
1663
+ return false;
1664
+ for (const seg of sh.segments)
1665
+ if (isEnvDump(seg.tokens))
1666
+ return true;
1667
+ for (const seg of sh.segments)
1668
+ if (seg.tokens.some((t) => VAR_REF.test(t)))
1669
+ return true;
1670
+ return false;
1671
+ }
1672
+ /** Path of one line of an apply_patch body (Codex): Add File, Update File, Delete File or Move to. */
1673
+ const PATCH_PATH = /^\*\*\* (?:Add File|Update File|Delete File|Move to): ([^\r\n]+)$/gm;
1674
+ /**
1675
+ * Words of the persistence, tunnel and exfiltration rules, searched in a whole command longer than MAX_SCAN_CHARS.
1676
+ * One alternation with no nested repeats, so the search is linear. It is coarse on purpose: a long command that
1677
+ * names one of these tools is denied.
1678
+ */
1679
+ const LONG_INPUT_WORDS = /authorized_keys|LaunchAgents|LaunchDaemons|systemd\/user|\/etc\/cron|\/var\/spool\/cron|crontab|launchctl|systemctl|schtasks|ssh-copy-id|autossh|RemoteForward|LocalForward|ProxyCommand|\/dev\/(tcp|udp)\/|\bssh\b|\bnc\b|ncat|netcat|socat|\.(zshrc|zshenv|zprofile|zlogin|bashrc|bash_profile|bash_login|profile)\b/i;
1680
+ /**
1681
+ * THE rule table. One row per rule, one comment per row. The owner approved groups a to e
1682
+ * on 2026-10-29, with no changes. Groups f to h were added on 2026-10-30 and wait for owner
1683
+ * review. Changes to a row need owner approval. Matching is by parsed command word and exact
1684
+ * target, never by substring alone, so `~/proj/node_modules`, `/tmp/x` and `.env.example` stay allowed.
1081
1685
  */
1082
1686
  export const NEVER_LIST_INPUT_RULES = [
1083
1687
  // a. Recursive delete of home, root or a top-level system dir (also find -delete / -exec rm on those).
@@ -1100,10 +1704,34 @@ export const NEVER_LIST_INPUT_RULES = [
1100
1704
  group: "d",
1101
1705
  shell: (sh) => sh.segments.some((seg) => seg.tokens.some((t, i) => (t === ">" || t === ">>") && BLOCK_DEVICE.test(seg.tokens[i + 1] ?? ""))),
1102
1706
  },
1103
- { id: "fork-bomb", group: "d", shell: (sh) => FORK_BOMB.test(sh.raw) },
1707
+ { id: "fork-bomb", group: "d", shell: forkBomb },
1104
1708
  { id: "chmod-chown-recursive-root", group: "d", shell: (sh) => hasCommand(sh, chmodChownRecursiveProtected) },
1105
1709
  // e. Network download piped into a shell, or run as code through a substitution (`sh -c "$(curl ...)"`).
1106
1710
  { id: "pipe-network-to-shell", group: "e", shell: (sh) => pipeNetworkToShell(sh) || nestedNetExec(sh) },
1711
+ // f. Agent-driven persistence (added 2026-10-30). A write rule also runs on Write, Edit and apply_patch paths.
1712
+ { id: "ssh-authorized-keys-write", group: "f", shell: (sh) => writesTo(sh, (p) => AUTHORIZED_KEYS.test(p)), writeFile: (p, home) => AUTHORIZED_KEYS.test(normalizePath(p, home)) },
1713
+ { id: "ssh-copy-id", group: "f", shell: (sh) => hasCandidate(sh, (c) => c.name === "ssh-copy-id") },
1714
+ { id: "persistence-cron", group: "f", shell: (sh) => hasCandidate(sh, cronWrites) || writesTo(sh, (p) => CRON_PATH.test(p)), writeFile: (p, home) => CRON_PATH.test(normalizePath(p, home)) },
1715
+ { id: "persistence-at-job", group: "f", shell: (sh) => hasCandidate(sh, atSchedules) },
1716
+ { id: "persistence-launchd", group: "f", shell: (sh) => hasCandidate(sh, launchLoads) || writesTo(sh, (p) => LAUNCH_PATH.test(p)), writeFile: (p, home) => LAUNCH_PATH.test(normalizePath(p, home)) },
1717
+ { id: "persistence-systemd-user", group: "f", shell: (sh) => hasCandidate(sh, systemdUserEnables) || writesTo(sh, (p) => SYSTEMD_USER_PATH.test(p)), writeFile: (p, home) => SYSTEMD_USER_PATH.test(normalizePath(p, home)) },
1718
+ { id: "persistence-schtasks-create", group: "f", shell: (sh) => hasCandidate(sh, (c) => c.name === "schtasks" && c.args.some((a) => a.toLowerCase() === "/create")) },
1719
+ // A download (curl, wget) in a command that writes a shell rc file: it runs at every shell start.
1720
+ { id: "shell-rc-download-append", group: "f", shell: (sh) => NET_WORD.test(sh.raw) && writesTo(sh, (p) => SHELL_RC_PATH.test(p)) },
1721
+ // g. Reverse tunnels and reverse shells (added 2026-10-30).
1722
+ { id: "ssh-reverse-forward", group: "g", shell: (sh) => hasCandidate(sh, sshReverseForward) },
1723
+ { id: "ssh-local-forward-raw-ip", group: "g", shell: (sh) => hasCandidate(sh, sshLocalToRawIp) },
1724
+ { id: "reverse-shell-netcat-exec", group: "g", shell: (sh) => hasCandidate(sh, netcatExec) },
1725
+ { id: "reverse-shell-dev-tcp", group: "g", shell: devTcpRedirect },
1726
+ { id: "autossh-tunnel", group: "g", shell: (sh) => hasCandidate(sh, (c) => c.name === "autossh") },
1727
+ { id: "ssh-tunnel-device", group: "g", shell: (sh) => hasCandidate(sh, sshTunnelDevice) },
1728
+ { id: "ssh-proxy-command-exec", group: "g", shell: (sh) => hasCandidate(sh, sshProxyExec) },
1729
+ { id: "ssh-config-written", group: "g", shell: sshConfigWritten },
1730
+ { id: "ssh-code-loading-option", group: "g", shell: (sh) => hasCandidate(sh, sshCodeLoadingDenied) },
1731
+ // Long command (over MAX_SCAN_CHARS): the parsed windows skip the middle, so the whole text is searched for these words.
1732
+ { id: "long-input-persistence-words", group: "f", fullText: (text) => LONG_INPUT_WORDS.test(text) },
1733
+ // h. Exfiltration of environment names or values to a public raw IPv4 address (added 2026-10-30).
1734
+ { id: "env-dump-to-raw-ip", group: "h", shell: envToPublicIp },
1107
1735
  ];
1108
1736
  const WRITE_NAME = /write|edit|create|update|delete|remove|move|rename|patch|replace|save|append|mkdir|touch/;
1109
1737
  const SHELL_NAME = /bash|shell|terminal|exec|command/;
@@ -1115,12 +1743,12 @@ const PATH_LIST_FIELDS = ["include", "paths"];
1115
1743
  function lastSegment(toolId) {
1116
1744
  return toolId.split("__").pop().trim().toLowerCase();
1117
1745
  }
1118
- function isShellTool(toolId) {
1746
+ export function isShellTool(toolId) {
1119
1747
  const last = lastSegment(toolId);
1120
1748
  return findHostToolTier(last) === "SHELL" || SHELL_NAME.test(last);
1121
1749
  }
1122
1750
  /** Tools that write. The file rule skips them: a write to `.env` is not a read of a secret. */
1123
- function isWriteLike(toolId) {
1751
+ export function isWriteLike(toolId) {
1124
1752
  const last = lastSegment(toolId);
1125
1753
  if (findHostToolTier(last) === "WRITE" || WRITE_NAME.test(last))
1126
1754
  return true;
@@ -1130,7 +1758,7 @@ function isWriteLike(toolId) {
1130
1758
  const tier = mcpToolTier(mcp.server, mcp.tool);
1131
1759
  return tier === "WRITE" || tier === "ADMIN";
1132
1760
  }
1133
- function fieldsOf(toolInput) {
1761
+ export function fieldsOf(toolInput) {
1134
1762
  let v = toolInput;
1135
1763
  if (typeof v === "string") {
1136
1764
  if (v.length > MAX_JSON_CHARS)
@@ -1147,10 +1775,10 @@ function fieldsOf(toolInput) {
1147
1775
  return v;
1148
1776
  }
1149
1777
  /** The first and last window of a long field. A short field is one window. */
1150
- function windowsOf(s) {
1778
+ export function windowsOf(s) {
1151
1779
  return s.length <= MAX_SCAN_CHARS ? [s] : [s.slice(0, MAX_SCAN_CHARS), s.slice(-MAX_SCAN_CHARS)];
1152
1780
  }
1153
- function commandStrings(f) {
1781
+ export function commandStrings(f) {
1154
1782
  const v = f.command ?? f.cmd;
1155
1783
  let s;
1156
1784
  if (typeof v === "string")
@@ -1159,7 +1787,7 @@ function commandStrings(f) {
1159
1787
  s = v.join(" ");
1160
1788
  return s === undefined ? [] : windowsOf(s);
1161
1789
  }
1162
- function pathStrings(f, toolId) {
1790
+ export function pathStrings(f, toolId) {
1163
1791
  const out = [];
1164
1792
  for (const k of PATH_FIELDS) {
1165
1793
  const v = f[k];
@@ -1179,6 +1807,30 @@ function pathStrings(f, toolId) {
1179
1807
  }
1180
1808
  return out;
1181
1809
  }
1810
+ /** Paths that a write-like tool writes: its path fields, and for Codex apply_patch the paths named in the patch body. */
1811
+ function writePathStrings(f, toolId) {
1812
+ const out = pathStrings(f, toolId);
1813
+ if (lastSegment(toolId) === "apply_patch") {
1814
+ // The patch is one string, or an argv array whose items are lines or chunks. Each item is scanned on its own
1815
+ // (a joined array does not hide a line start), and the whole item is scanned, not a window: the header regex is
1816
+ // linear, and a long patch must not hide a header in its middle.
1817
+ const v = f.command ?? f.cmd;
1818
+ const items = typeof v === "string" ? [v] : Array.isArray(v) ? v.filter((x) => typeof x === "string") : [];
1819
+ for (const item of items)
1820
+ for (const m of item.matchAll(PATCH_PATH))
1821
+ out.push(m[1].trim());
1822
+ }
1823
+ return out;
1824
+ }
1825
+ /** The whole shell command text, unwindowed (the same join as commandStrings), or undefined when there is none. */
1826
+ function fullCommandText(f) {
1827
+ const v = f.command ?? f.cmd;
1828
+ if (typeof v === "string")
1829
+ return v;
1830
+ if (Array.isArray(v) && v.every((x) => typeof x === "string"))
1831
+ return v.join(" ");
1832
+ return undefined;
1833
+ }
1182
1834
  /** Home directory for `~` and absolute home paths. Undefined when the OS has none. */
1183
1835
  function safeHome() {
1184
1836
  try {
@@ -1193,20 +1845,35 @@ function safeHome() {
1193
1845
  * First matching input rule for this tool call, or undefined (allow). The caller
1194
1846
  * denies with reasonCode NEVER_EVENT. Never throws: any error is an undefined result.
1195
1847
  */
1196
- export function neverListInputMatch(toolId, toolInput, home = safeHome()) {
1848
+ export function neverListInputMatch(toolId, toolInput, home = safeHome(), cwd) {
1197
1849
  try {
1198
1850
  const f = fieldsOf(toolInput);
1199
1851
  if (!f)
1200
1852
  return undefined;
1201
1853
  if (isShellTool(toolId)) {
1854
+ // Long command: the parsed windows do not cover the middle, so the whole text gets the word search first.
1855
+ const full = fullCommandText(f);
1856
+ if (full !== undefined && full.length > MAX_SCAN_CHARS) {
1857
+ for (const r of NEVER_LIST_INPUT_RULES)
1858
+ if (r.fullText?.(full))
1859
+ return { rule: r.id };
1860
+ }
1202
1861
  for (const cmd of commandStrings(f)) {
1203
- const sh = parseShell(cmd, home);
1862
+ const sh = parseShell(cmd, home, cwd);
1204
1863
  for (const r of NEVER_LIST_INPUT_RULES)
1205
1864
  if (r.shell?.(sh))
1206
1865
  return { rule: r.id };
1207
1866
  }
1208
1867
  }
1209
- if (!isWriteLike(toolId)) {
1868
+ if (isWriteLike(toolId)) {
1869
+ // Write tools: only the writeFile rules (persistence files). A write to `.env` is not a read of a secret.
1870
+ for (const p of writePathStrings(f, toolId)) {
1871
+ for (const r of NEVER_LIST_INPUT_RULES)
1872
+ if (r.writeFile?.(p, home))
1873
+ return { rule: r.id };
1874
+ }
1875
+ }
1876
+ else {
1210
1877
  for (const p of pathStrings(f, toolId)) {
1211
1878
  for (const r of NEVER_LIST_INPUT_RULES)
1212
1879
  if (r.file?.(p, home))