@shield-agent/kya 0.32.7 → 0.33.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 (306) 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 +232 -17
  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 +237 -53
  13. package/dist/cli.js.map +1 -1
  14. package/dist/client.d.ts +2 -0
  15. package/dist/client.d.ts.map +1 -1
  16. package/dist/client.js +1 -1
  17. package/dist/client.js.map +1 -1
  18. package/dist/commands/connect.d.ts +1 -1
  19. package/dist/commands/connect.d.ts.map +1 -1
  20. package/dist/commands/connect.js +11 -3
  21. package/dist/commands/connect.js.map +1 -1
  22. package/dist/commands/dash.d.ts.map +1 -1
  23. package/dist/commands/dash.js +25 -1
  24. package/dist/commands/dash.js.map +1 -1
  25. package/dist/commands/doctor.d.ts +8 -0
  26. package/dist/commands/doctor.d.ts.map +1 -0
  27. package/dist/commands/doctor.js +26 -0
  28. package/dist/commands/doctor.js.map +1 -0
  29. package/dist/commands/eval-tool.d.ts +3 -0
  30. package/dist/commands/eval-tool.d.ts.map +1 -1
  31. package/dist/commands/eval-tool.js +2 -1
  32. package/dist/commands/eval-tool.js.map +1 -1
  33. package/dist/commands/export.d.ts +33 -0
  34. package/dist/commands/export.d.ts.map +1 -0
  35. package/dist/commands/export.js +121 -0
  36. package/dist/commands/export.js.map +1 -0
  37. package/dist/commands/hook.d.ts.map +1 -1
  38. package/dist/commands/hook.js +135 -26
  39. package/dist/commands/hook.js.map +1 -1
  40. package/dist/commands/hosted-hook.d.ts +8 -1
  41. package/dist/commands/hosted-hook.d.ts.map +1 -1
  42. package/dist/commands/hosted-hook.js +15 -5
  43. package/dist/commands/hosted-hook.js.map +1 -1
  44. package/dist/commands/login.d.ts +34 -0
  45. package/dist/commands/login.d.ts.map +1 -0
  46. package/dist/commands/login.js +74 -0
  47. package/dist/commands/login.js.map +1 -0
  48. package/dist/commands/map.d.ts +21 -0
  49. package/dist/commands/map.d.ts.map +1 -0
  50. package/dist/commands/map.js +76 -0
  51. package/dist/commands/map.js.map +1 -0
  52. package/dist/commands/notify-flush.d.ts.map +1 -1
  53. package/dist/commands/notify-flush.js +7 -1
  54. package/dist/commands/notify-flush.js.map +1 -1
  55. package/dist/commands/posture.d.ts +21 -0
  56. package/dist/commands/posture.d.ts.map +1 -0
  57. package/dist/commands/posture.js +177 -0
  58. package/dist/commands/posture.js.map +1 -0
  59. package/dist/commands/receipt-serve.d.ts +7 -0
  60. package/dist/commands/receipt-serve.d.ts.map +1 -1
  61. package/dist/commands/receipt-serve.js +50 -0
  62. package/dist/commands/receipt-serve.js.map +1 -1
  63. package/dist/commands/start.d.ts.map +1 -1
  64. package/dist/commands/start.js +5 -1
  65. package/dist/commands/start.js.map +1 -1
  66. package/dist/commands/team.d.ts.map +1 -1
  67. package/dist/commands/team.js +30 -11
  68. package/dist/commands/team.js.map +1 -1
  69. package/dist/commands/wire-hooks.d.ts +5 -1
  70. package/dist/commands/wire-hooks.d.ts.map +1 -1
  71. package/dist/commands/wire-hooks.js +23 -8
  72. package/dist/commands/wire-hooks.js.map +1 -1
  73. package/dist/commands/wrap.d.ts +38 -1
  74. package/dist/commands/wrap.d.ts.map +1 -1
  75. package/dist/commands/wrap.js +82 -6
  76. package/dist/commands/wrap.js.map +1 -1
  77. package/dist/config.d.ts +2 -0
  78. package/dist/config.d.ts.map +1 -1
  79. package/dist/config.js.map +1 -1
  80. package/dist/cta.d.ts +23 -0
  81. package/dist/cta.d.ts.map +1 -0
  82. package/dist/cta.js +86 -0
  83. package/dist/cta.js.map +1 -0
  84. package/dist/daily-slot.d.ts +2 -0
  85. package/dist/daily-slot.d.ts.map +1 -0
  86. package/dist/daily-slot.js +29 -0
  87. package/dist/daily-slot.js.map +1 -0
  88. package/dist/dash/dash.d.ts.map +1 -1
  89. package/dist/dash/dash.js +10 -0
  90. package/dist/dash/dash.js.map +1 -1
  91. package/dist/dash/entitlement.d.ts +1 -1
  92. package/dist/dash/entitlement.d.ts.map +1 -1
  93. package/dist/dash/entitlement.js +1 -0
  94. package/dist/dash/entitlement.js.map +1 -1
  95. package/dist/dash/input.d.ts +1 -1
  96. package/dist/dash/input.d.ts.map +1 -1
  97. package/dist/dash/input.js +3 -0
  98. package/dist/dash/input.js.map +1 -1
  99. package/dist/dash/render.js +1 -1
  100. package/dist/dash/render.js.map +1 -1
  101. package/dist/dash/screens/free.js +1 -1
  102. package/dist/dash/screens/free.js.map +1 -1
  103. package/dist/device-id.d.ts +5 -0
  104. package/dist/device-id.d.ts.map +1 -0
  105. package/dist/device-id.js +78 -0
  106. package/dist/device-id.js.map +1 -0
  107. package/dist/fs-atomic.d.ts +15 -3
  108. package/dist/fs-atomic.d.ts.map +1 -1
  109. package/dist/fs-atomic.js +31 -14
  110. package/dist/fs-atomic.js.map +1 -1
  111. package/dist/gate/config-gen.d.ts +26 -2
  112. package/dist/gate/config-gen.d.ts.map +1 -1
  113. package/dist/gate/config-gen.js +53 -4
  114. package/dist/gate/config-gen.js.map +1 -1
  115. package/dist/gate/otlp-receiver.d.ts.map +1 -1
  116. package/dist/gate/otlp-receiver.js +3 -1
  117. package/dist/gate/otlp-receiver.js.map +1 -1
  118. package/dist/guard-pack.d.ts +58 -0
  119. package/dist/guard-pack.d.ts.map +1 -0
  120. package/dist/guard-pack.js +753 -0
  121. package/dist/guard-pack.js.map +1 -0
  122. package/dist/ioc/exec.d.ts +19 -0
  123. package/dist/ioc/exec.d.ts.map +1 -0
  124. package/dist/ioc/exec.js +49 -0
  125. package/dist/ioc/exec.js.map +1 -0
  126. package/dist/ioc/format.d.ts +4 -0
  127. package/dist/ioc/format.d.ts.map +1 -0
  128. package/dist/ioc/format.js +23 -0
  129. package/dist/ioc/format.js.map +1 -0
  130. package/dist/ioc/list.d.ts +29 -0
  131. package/dist/ioc/list.d.ts.map +1 -0
  132. package/dist/ioc/list.js +25 -0
  133. package/dist/ioc/list.js.map +1 -0
  134. package/dist/ioc/result.d.ts +18 -0
  135. package/dist/ioc/result.d.ts.map +1 -0
  136. package/dist/ioc/result.js +26 -0
  137. package/dist/ioc/result.js.map +1 -0
  138. package/dist/ioc/scan.d.ts +21 -0
  139. package/dist/ioc/scan.d.ts.map +1 -0
  140. package/dist/ioc/scan.js +247 -0
  141. package/dist/ioc/scan.js.map +1 -0
  142. package/dist/ioc/spawn.d.ts +26 -0
  143. package/dist/ioc/spawn.d.ts.map +1 -0
  144. package/dist/ioc/spawn.js +52 -0
  145. package/dist/ioc/spawn.js.map +1 -0
  146. package/dist/login/credentials.d.ts +17 -0
  147. package/dist/login/credentials.d.ts.map +1 -0
  148. package/dist/login/credentials.js +64 -0
  149. package/dist/login/credentials.js.map +1 -0
  150. package/dist/login/device-flow.d.ts +44 -0
  151. package/dist/login/device-flow.d.ts.map +1 -0
  152. package/dist/login/device-flow.js +205 -0
  153. package/dist/login/device-flow.js.map +1 -0
  154. package/dist/map/build.d.ts +58 -0
  155. package/dist/map/build.d.ts.map +1 -0
  156. package/dist/map/build.js +439 -0
  157. package/dist/map/build.js.map +1 -0
  158. package/dist/map/heat.d.ts +44 -0
  159. package/dist/map/heat.d.ts.map +1 -0
  160. package/dist/map/heat.js +96 -0
  161. package/dist/map/heat.js.map +1 -0
  162. package/dist/map/load.d.ts +38 -0
  163. package/dist/map/load.d.ts.map +1 -0
  164. package/dist/map/load.js +38 -0
  165. package/dist/map/load.js.map +1 -0
  166. package/dist/map/page.d.ts +90 -0
  167. package/dist/map/page.d.ts.map +1 -0
  168. package/dist/map/page.js +374 -0
  169. package/dist/map/page.js.map +1 -0
  170. package/dist/map/target-class.d.ts +22 -0
  171. package/dist/map/target-class.d.ts.map +1 -0
  172. package/dist/map/target-class.js +151 -0
  173. package/dist/map/target-class.js.map +1 -0
  174. package/dist/map/types.d.ts +102 -0
  175. package/dist/map/types.d.ts.map +1 -0
  176. package/dist/map/types.js +3 -0
  177. package/dist/map/types.js.map +1 -0
  178. package/dist/mcp/protocol.d.ts.map +1 -1
  179. package/dist/mcp/protocol.js +16 -5
  180. package/dist/mcp/protocol.js.map +1 -1
  181. package/dist/never-code.d.ts +7 -0
  182. package/dist/never-code.d.ts.map +1 -0
  183. package/dist/never-code.js +9 -0
  184. package/dist/never-code.js.map +1 -0
  185. package/dist/never-list-input.d.ts +92 -0
  186. package/dist/never-list-input.d.ts.map +1 -0
  187. package/dist/never-list-input.js +1889 -0
  188. package/dist/never-list-input.js.map +1 -0
  189. package/dist/notify/dispatch.d.ts +4 -0
  190. package/dist/notify/dispatch.d.ts.map +1 -1
  191. package/dist/notify/dispatch.js +3 -1
  192. package/dist/notify/dispatch.js.map +1 -1
  193. package/dist/offline-evaluate.d.ts +42 -5
  194. package/dist/offline-evaluate.d.ts.map +1 -1
  195. package/dist/offline-evaluate.js +96 -9
  196. package/dist/offline-evaluate.js.map +1 -1
  197. package/dist/otel/exporter.d.ts +15 -2
  198. package/dist/otel/exporter.d.ts.map +1 -1
  199. package/dist/otel/exporter.js +52 -6
  200. package/dist/otel/exporter.js.map +1 -1
  201. package/dist/otel/map-attrs-optin.d.ts +39 -0
  202. package/dist/otel/map-attrs-optin.d.ts.map +1 -0
  203. package/dist/otel/map-attrs-optin.js +109 -0
  204. package/dist/otel/map-attrs-optin.js.map +1 -0
  205. package/dist/otel.d.ts +8 -2
  206. package/dist/otel.d.ts.map +1 -1
  207. package/dist/otel.js +32 -16
  208. package/dist/otel.js.map +1 -1
  209. package/dist/parse-args.d.ts +2 -0
  210. package/dist/parse-args.d.ts.map +1 -1
  211. package/dist/parse-args.js +4 -2
  212. package/dist/parse-args.js.map +1 -1
  213. package/dist/posture/card.d.ts +26 -0
  214. package/dist/posture/card.d.ts.map +1 -0
  215. package/dist/posture/card.js +46 -0
  216. package/dist/posture/card.js.map +1 -0
  217. package/dist/posture/collect.d.ts +19 -0
  218. package/dist/posture/collect.d.ts.map +1 -0
  219. package/dist/posture/collect.js +182 -0
  220. package/dist/posture/collect.js.map +1 -0
  221. package/dist/posture/send.d.ts +24 -0
  222. package/dist/posture/send.d.ts.map +1 -0
  223. package/dist/posture/send.js +162 -0
  224. package/dist/posture/send.js.map +1 -0
  225. package/dist/posture/spawn.d.ts +20 -0
  226. package/dist/posture/spawn.d.ts.map +1 -0
  227. package/dist/posture/spawn.js +33 -0
  228. package/dist/posture/spawn.js.map +1 -0
  229. package/dist/posture/store.d.ts +34 -0
  230. package/dist/posture/store.d.ts.map +1 -0
  231. package/dist/posture/store.js +107 -0
  232. package/dist/posture/store.js.map +1 -0
  233. package/dist/posture/validate.d.ts +33 -0
  234. package/dist/posture/validate.d.ts.map +1 -0
  235. package/dist/posture/validate.js +232 -0
  236. package/dist/posture/validate.js.map +1 -0
  237. package/dist/project-policy.d.ts +23 -0
  238. package/dist/project-policy.d.ts.map +1 -0
  239. package/dist/project-policy.js +95 -0
  240. package/dist/project-policy.js.map +1 -0
  241. package/dist/receipt/daemon.d.ts +9 -9
  242. package/dist/receipt/daemon.d.ts.map +1 -1
  243. package/dist/receipt/daemon.js +26 -11
  244. package/dist/receipt/daemon.js.map +1 -1
  245. package/dist/receipt/embeddings.d.ts +29 -7
  246. package/dist/receipt/embeddings.d.ts.map +1 -1
  247. package/dist/receipt/embeddings.js +282 -33
  248. package/dist/receipt/embeddings.js.map +1 -1
  249. package/dist/receipt/gate-page.d.ts.map +1 -1
  250. package/dist/receipt/gate-page.js +5 -22
  251. package/dist/receipt/gate-page.js.map +1 -1
  252. package/dist/receipt/live-server.d.ts +8 -0
  253. package/dist/receipt/live-server.d.ts.map +1 -1
  254. package/dist/receipt/live-server.js +136 -4
  255. package/dist/receipt/live-server.js.map +1 -1
  256. package/dist/receipt/render-receipt.d.ts +7 -1
  257. package/dist/receipt/render-receipt.d.ts.map +1 -1
  258. package/dist/receipt/render-receipt.js +101 -13
  259. package/dist/receipt/render-receipt.js.map +1 -1
  260. package/dist/receipt/search.d.ts +5 -0
  261. package/dist/receipt/search.d.ts.map +1 -1
  262. package/dist/receipt/search.js +23 -2
  263. package/dist/receipt/search.js.map +1 -1
  264. package/dist/receipt/semantic-setup.d.ts +21 -0
  265. package/dist/receipt/semantic-setup.d.ts.map +1 -0
  266. package/dist/receipt/semantic-setup.js +111 -0
  267. package/dist/receipt/semantic-setup.js.map +1 -0
  268. package/dist/receipt/semantic-status.d.ts +7 -0
  269. package/dist/receipt/semantic-status.d.ts.map +1 -0
  270. package/dist/receipt/semantic-status.js +26 -0
  271. package/dist/receipt/semantic-status.js.map +1 -0
  272. package/dist/sdk/govern.d.ts.map +1 -1
  273. package/dist/sdk/govern.js +7 -3
  274. package/dist/sdk/govern.js.map +1 -1
  275. package/dist/telemetry.d.ts.map +1 -1
  276. package/dist/telemetry.js +3 -23
  277. package/dist/telemetry.js.map +1 -1
  278. package/dist/trail-map-fields.d.ts +10 -0
  279. package/dist/trail-map-fields.d.ts.map +1 -0
  280. package/dist/trail-map-fields.js +53 -0
  281. package/dist/trail-map-fields.js.map +1 -0
  282. package/dist/trail-summary.d.ts.map +1 -1
  283. package/dist/trail-summary.js +11 -3
  284. package/dist/trail-summary.js.map +1 -1
  285. package/dist/trail.d.ts +13 -1
  286. package/dist/trail.d.ts.map +1 -1
  287. package/dist/trail.js +38 -1
  288. package/dist/trail.js.map +1 -1
  289. package/droid/README.md +1 -1
  290. package/droid/mcp.example.json +1 -1
  291. package/gemini/settings.example.json +1 -1
  292. package/grok/README.md +1 -1
  293. package/kilo/kilo.example.json +1 -1
  294. package/kimi/mcp.example.json +1 -1
  295. package/kiro/mcp.example.json +1 -1
  296. package/manifest.json +1 -1
  297. package/mastracode/mcp.example.json +1 -1
  298. package/mcp.json +1 -1
  299. package/openai/codex.config.example.toml +1 -1
  300. package/opencode/opencode.example.json +1 -1
  301. package/package.json +8 -7
  302. package/qwen/settings.example.json +1 -1
  303. package/server.json +2 -2
  304. package/vendor/cytoscape/LICENSE +19 -0
  305. package/vendor/cytoscape/README.md +7 -0
  306. package/vendor/cytoscape/cytoscape.min.js +31 -0
@@ -0,0 +1,1889 @@
1
+ /**
2
+ * Never-list rules on tool INPUT: shell command text and file paths.
3
+ * matchesNeverList (offline-evaluate.ts) sees the tool name only, so a shell
4
+ * tool was never blocked on its command. This file adds the input rules.
5
+ *
6
+ * One table (NEVER_LIST_INPUT_RULES) drives every caller: the offline evaluator,
7
+ * the hook's normal path, its error fallback and the hosted hook. They cannot drift.
8
+ *
9
+ * A seatbelt, not a sandbox. Matching is a linear quote-aware scanner, not a full
10
+ * shell parser. Fails open: unknown shapes and parse trouble are not matches.
11
+ * Work is linear in the input size, with bounded nesting.
12
+ */
13
+ import { homedir } from "node:os";
14
+ import { findHostToolTier } from "./host-tools.js";
15
+ import { mcpToolTier, parseMcpToolId } from "./mcp-servers.js";
16
+ /**
17
+ * A field longer than this is scanned as two windows: its first and its last
18
+ * MAX_SCAN_CHARS. Text in the middle of a longer field is not scanned (documented limit).
19
+ */
20
+ export const MAX_SCAN_CHARS = 65536;
21
+ /** A JSON string tool input is parsed up to this size. Larger strings fail open. */
22
+ const MAX_JSON_CHARS = 1 << 20;
23
+ /** Nesting limit for substitutions, quoted code and heredoc bodies. */
24
+ const MAX_DEPTH = 4;
25
+ /** Candidates with shells kept, computed once per segment. */
26
+ function keptCmds(seg) {
27
+ return (seg.kept ??= commandsAt(seg.tokens, true));
28
+ }
29
+ /** A command word and the tokens after it. Copied once, when the segment is built. */
30
+ export class Cmd {
31
+ name;
32
+ raw;
33
+ tokens;
34
+ start;
35
+ coprocName;
36
+ cached;
37
+ /** `raw` is the word as written (a download substitution is checked on it). */
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) {
40
+ this.name = name;
41
+ this.raw = raw;
42
+ this.tokens = tokens;
43
+ this.start = start;
44
+ this.coprocName = coprocName;
45
+ }
46
+ /** Arguments, copied on first use: most candidate words are never asked for them. */
47
+ get args() {
48
+ return (this.cached ??= this.tokens.slice(this.start));
49
+ }
50
+ }
51
+ // Words that run another command: skip them to find the real command word.
52
+ // Shells are skipped too, except where a pipe needs the shell name itself.
53
+ const WRAPPERS = new Set([
54
+ "sudo", "doas", "env", "command", "builtin", "exec", "nohup", "time", "coproc", "nice", "ionice",
55
+ "stdbuf", "xargs", "eval", "busybox", "timeout", "if", "then", "else", "elif", "do", "while", "until", "!",
56
+ "{", "}",
57
+ ]);
58
+ /** Options that take their value in the next word, per wrapper (`sudo -u root`, `nice -n 5`). Other flags take none. */
59
+ const VALUE_OPTS = {
60
+ sudo: new Set(["-u", "-g", "-C", "-h", "-p", "-r", "-U", "-T"]),
61
+ doas: new Set(["-u", "-C"]),
62
+ env: new Set(["-u", "-C"]),
63
+ nice: new Set(["-n"]),
64
+ ionice: new Set(["-c", "-n", "-p"]),
65
+ timeout: new Set(["-s", "-k"]),
66
+ xargs: new Set(["-I", "-d", "-P", "-L", "-n", "-E", "-s"]),
67
+ };
68
+ const SHELLS = new Set(["sh", "bash", "zsh", "dash", "ksh"]);
69
+ /** Words that run their argument as code. */
70
+ const EXEC_WORDS = new Set([...SHELLS, "eval", "source", "."]);
71
+ const NET_CLIENTS = new Set(["curl", "wget"]);
72
+ const INTERPRETERS = new Set(["python", "python2", "python3", "node", "ruby", "perl", "php", "deno", "bun", "lua"]);
73
+ const ASSIGN = /^[A-Za-z_][A-Za-z0-9_]*=/;
74
+ const ASSIGN_FULL = /^([A-Za-z_][A-Za-z0-9_]*)=([\s\S]*)$/;
75
+ const DURATION = /^\d+(\.\d+)?[smhd]?$/;
76
+ /** A word that is a download substitution: `$(curl ...)`. */
77
+ const NET_SUBST = /^\$\(\s*(curl|wget)\b/;
78
+ /** A command word that contains a download substitution anywhere: `/bin/$(curl ...)` runs curl's output. */
79
+ const NET_SUBST_WORD = /\$\(\s*(curl|wget)\b/;
80
+ /** Option values that name a host or key, not a file to read: `scp -i KEY`, `rsync -e "ssh -i KEY"`. */
81
+ const OPTION_VALUES = {
82
+ scp: new Set(["-i", "-F", "-o", "-P", "-S", "-J", "-c", "-l"]),
83
+ sftp: new Set(["-i", "-F", "-o", "-P", "-S", "-J", "-b"]),
84
+ rsync: new Set(["-e", "-M", "--rsh"]),
85
+ ssh: new Set(["-i", "-F", "-o", "-l", "-p", "-J", "-E", "-b", "-c", "-D", "-L", "-R", "-S", "-W", "-w"]),
86
+ // openssl writes its output to these files; they are not inputs.
87
+ openssl: new Set(["-out", "-keyout", "-passout", "-config"]),
88
+ // `-e PATTERN` names the pattern, not a file. `-f FILE` names a real file, so it is not listed.
89
+ grep: new Set(["-e", "--regexp"]),
90
+ egrep: new Set(["-e", "--regexp"]),
91
+ fgrep: new Set(["-e", "--regexp"]),
92
+ };
93
+ /**
94
+ * The word with each balanced `$(...)` group removed, so a `/` inside a substitution is not a path
95
+ * separator. `ok` is false when the groups or quotes do not close cleanly (`x$(echo ")")`): the name
96
+ * is then not known, and the scan keeps going.
97
+ */
98
+ function stripSubst(t) {
99
+ if (!t.includes("$("))
100
+ return { plain: t, ok: true };
101
+ let out = "";
102
+ let depth = 0;
103
+ for (let i = 0; i < t.length; i++) {
104
+ if (depth === 0 && t[i] === "$" && t[i + 1] === "(") {
105
+ depth = 1;
106
+ i++;
107
+ }
108
+ else if (depth > 0) {
109
+ if (t[i] === "(")
110
+ depth++;
111
+ else if (t[i] === ")")
112
+ depth--;
113
+ }
114
+ else {
115
+ out += t[i];
116
+ }
117
+ }
118
+ return { plain: out, ok: depth === 0 && !/[()'"`]/.test(out) };
119
+ }
120
+ /** The last path part of a command word, with substitutions removed (`/bin$(:)/rm` is `rm`). */
121
+ function wordName(t) {
122
+ const plain = stripSubst(t).plain;
123
+ return plain.slice(plain.lastIndexOf("/") + 1);
124
+ }
125
+ /** `coproc NAME`: the word after `coproc` is a name when it is a plain identifier. */
126
+ const COPROC_NAME = /^[A-Za-z_][A-Za-z0-9_]*$/;
127
+ /** Words that make `coproc X` a named compound: `coproc X { ...`, `coproc X for ...`. */
128
+ const COPROC_BODY = new Set(["{", "if", "while", "until", "for", "case", "select"]);
129
+ /** A coproc NAME is a plain identifier that is not a wrapper, a shell or `function`. */
130
+ function isCoprocName(t) {
131
+ return COPROC_NAME.test(t) && !WRAPPERS.has(t) && !SHELLS.has(t) && t !== "function";
132
+ }
133
+ /**
134
+ * Every candidate command word of one command, in order. The first real command word ends
135
+ * the scan. Two constructs add candidates instead of hiding the body:
136
+ * - `function NAME`: the raw word `function` is a candidate and NAME is skipped, so the body
137
+ * after NAME is scanned too. Repeated `function a function b` is peeled in this one pass.
138
+ * - `coproc NAME`: NAME is a candidate, and the words after it are scanned too.
139
+ * Fails closed: an unknown shape stays a candidate. `echo function` and `git log --grep
140
+ * function` are arguments, not command starts, so they add nothing.
141
+ */
142
+ function commandsAt(tokens, keepShells) {
143
+ const out = [];
144
+ let wrapper = "";
145
+ for (let i = 0; i < tokens.length; i++) {
146
+ const t = tokens[i];
147
+ if (t.startsWith("-")) {
148
+ if (VALUE_OPTS[wrapper]?.has(t))
149
+ i++;
150
+ continue;
151
+ }
152
+ if (ASSIGN.test(t) || DURATION.test(t))
153
+ continue;
154
+ if (t === "function" && i + 1 < tokens.length) {
155
+ out.push(new Cmd("function", t, tokens, i + 1));
156
+ i++; // NAME is not a command
157
+ continue;
158
+ }
159
+ // `coproc NAME { ...` or `coproc NAME for ...`: NAME is a candidate and the body is scanned too.
160
+ // Otherwise coproc is a wrapper: `coproc sudo -u root rm ...` and `coproc git push ...` run the command.
161
+ if (t === "coproc" && i + 2 < tokens.length && isCoprocName(tokens[i + 1]) && COPROC_BODY.has(tokens[i + 2])) {
162
+ out.push(new Cmd(tokens[i + 1], tokens[i + 1], tokens, i + 2, true));
163
+ i++; // NAME is a candidate; the words after it are scanned next
164
+ continue;
165
+ }
166
+ const name = wordName(t);
167
+ if (WRAPPERS.has(name)) {
168
+ wrapper = name;
169
+ continue;
170
+ }
171
+ if (!keepShells && SHELLS.has(name))
172
+ continue;
173
+ out.push(new Cmd(name, t, tokens, i + 1));
174
+ // A word that starts with an expansion can expand to nothing (`$(:) rm -rf ~`, `$X rm -rf ~`), and a word
175
+ // whose substitution does not strip cleanly has no known name. Both keep scanning: the next word is a candidate too.
176
+ if (name !== "" && !t.startsWith("$") && stripSubst(t).ok)
177
+ return out;
178
+ }
179
+ return out;
180
+ }
181
+ /** The real command of a command (its last candidate), or undefined. */
182
+ function commandAt(tokens, keepShells) {
183
+ const cs = commandsAt(tokens, keepShells);
184
+ return cs[cs.length - 1];
185
+ }
186
+ export function hasCommand(sh, pred) {
187
+ // Indexed loops: this runs once per rule per segment, so no iterator objects.
188
+ const segs = sh.segments;
189
+ for (let i = 0; i < segs.length; i++) {
190
+ const cs = segs[i].cmds;
191
+ for (let k = 0; k < cs.length; k++)
192
+ if (pred(cs[k], sh))
193
+ return true;
194
+ }
195
+ return false;
196
+ }
197
+ const TOP_LEVEL = /^\/(bin|boot|etc|home|lib|lib64|opt|root|sbin|srv|usr|var|System|Library|Applications|private|Users)(\/\*)?$/;
198
+ const ROOT_SET = new Set(["/", "/*", "~", "~/*", "~/.*", "~/."]);
199
+ /** Another user's home directory on Linux or macOS, with optional contents glob. */
200
+ const USER_HOME = /^\/(home|Users)\/[^/]+(\/\*|\/\.\*)?$/;
201
+ /** Expand a leading `~` to the home directory when it is known. */
202
+ function expandHome(p, home) {
203
+ if (home && (p === "~" || p.startsWith("~/")))
204
+ return home + p.slice(1);
205
+ return p;
206
+ }
207
+ /** Resolve `.` and `..` in an absolute path. Relative paths are returned as they are. */
208
+ function resolveDots(p) {
209
+ if (!p.startsWith("/"))
210
+ return p;
211
+ const out = [];
212
+ for (const seg of p.split("/")) {
213
+ if (seg === "" || seg === ".")
214
+ continue;
215
+ if (seg === "..")
216
+ out.pop();
217
+ else
218
+ out.push(seg);
219
+ }
220
+ return "/" + out.join("/");
221
+ }
222
+ /**
223
+ * Canonical form for matching. Repeated slashes and `/.` go, `~`, `~user` and
224
+ * `$HOME` are expanded when the home directory is known, and `..` is resolved.
225
+ */
226
+ export function normalizePath(w, home) {
227
+ let p = w.replace(/\/{2,}/g, "/").replace(/\/\.(?=\/|$)/g, "");
228
+ if (p === "")
229
+ p = "/";
230
+ const named = /^~([A-Za-z0-9._-]+)(\/.*)?$/.exec(p);
231
+ if (named) {
232
+ const name = named[1];
233
+ const rest = named[2] ?? "";
234
+ const homeName = home ? home.slice(home.lastIndexOf("/") + 1) : undefined;
235
+ const base = name === "root" ? "/root" : home && name === homeName ? home : `/home/${name}`;
236
+ p = base + rest;
237
+ }
238
+ else {
239
+ p = expandHome(p, home);
240
+ }
241
+ p = resolveDots(p.replace(/\/{2,}/g, "/"));
242
+ while (p.length > 1 && p.endsWith("/"))
243
+ p = p.slice(0, -1);
244
+ return p;
245
+ }
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;
261
+ const p = normalizePath(word, home);
262
+ if (ROOT_SET.has(p) || TOP_LEVEL.test(p) || USER_HOME.test(p))
263
+ return true;
264
+ if (home !== undefined && (p === home || p === `${home}/*` || p === `${home}/.*`))
265
+ return true;
266
+ return aboveCwd(word, sh.cwd);
267
+ }
268
+ function isRecursive(flags) {
269
+ return flags.some((f) => f === "--recursive" || (/^-[A-Za-z]+$/.test(f) && /[rR]/.test(f)));
270
+ }
271
+ /**
272
+ * find with -delete, or -exec rm, on a protected start path. Filters do not help:
273
+ * `find ~ -type f -delete` deletes under home just the same.
274
+ */
275
+ function findDeleteProtected(c, sh) {
276
+ if (c.name !== "find")
277
+ return false;
278
+ let i = 0;
279
+ while (i < c.args.length && /^-[HLP]$/.test(c.args[i]))
280
+ i++;
281
+ const starts = [];
282
+ for (; i < c.args.length; i++) {
283
+ const t = c.args[i];
284
+ if (t.startsWith("-") || t === "!" || t === "(")
285
+ break;
286
+ starts.push(t);
287
+ }
288
+ if (!starts.some((p) => isProtected(p, sh)))
289
+ return false;
290
+ if (c.args.includes("-delete"))
291
+ return true;
292
+ const exec = c.args.some((t) => t === "-exec" || t === "-execdir" || t === "-ok" || t === "-okdir");
293
+ return exec && c.args.includes("rm");
294
+ }
295
+ function rmRecursiveProtected(c, sh) {
296
+ if (c.name !== "rm")
297
+ return false;
298
+ const flags = c.args.filter((t) => t.startsWith("-"));
299
+ const paths = c.args.filter((t) => !t.startsWith("-"));
300
+ return isRecursive(flags) && paths.some((p) => isProtected(p, sh));
301
+ }
302
+ function chmodChownRecursiveProtected(c, sh) {
303
+ if (c.name !== "chmod" && c.name !== "chown")
304
+ return false;
305
+ const flags = c.args.filter((t) => t.startsWith("-"));
306
+ const rest = c.args.filter((t) => !t.startsWith("-"));
307
+ if (!isRecursive(flags))
308
+ return false;
309
+ if (c.name === "chmod" && !rest.some((t) => /^0?777$/.test(t)))
310
+ return false;
311
+ return rest.some((p) => isProtected(p, sh));
312
+ }
313
+ const PROTECTED_BRANCH = (b) => {
314
+ const n = b.toLowerCase();
315
+ return n === "main" || n === "master" || n.startsWith("release") || n.startsWith("prod");
316
+ };
317
+ /** Index of the git subcommand: global options are skipped, and `-C` and `-c` take a value. */
318
+ /** Git global options that take their value in the next word (`git -C dir`, `git --git-dir .git`). */
319
+ const GIT_GLOBAL_VALUE = new Set([
320
+ "-C", "-c", "--git-dir", "--work-tree", "--namespace", "--super-prefix", "--config-env", "--attr-source", "--shallow-file",
321
+ ]);
322
+ /** Git global options that take no value. Any other option before the subcommand is unknown. */
323
+ const GIT_GLOBAL_FLAG = new Set([
324
+ "-p", "-P", "--paginate", "--no-pager", "--bare", "--no-replace-objects", "--literal-pathspecs", "--glob-pathspecs",
325
+ "--noglob-pathspecs", "--icase-pathspecs", "--no-optional-locks", "--no-lazy-fetch", "--version", "--help",
326
+ "--html-path", "--man-path", "--info-path",
327
+ ]);
328
+ /**
329
+ * The subcommand index, and whether every option before it is known. An unknown option makes
330
+ * `known` false, so no later word is exempted by the subcommand (fail closed).
331
+ */
332
+ export function gitSubInfo(a) {
333
+ let i = 0;
334
+ let known = true;
335
+ while (i < a.length && a[i].startsWith("-")) {
336
+ const t = a[i];
337
+ if (GIT_GLOBAL_VALUE.has(t)) {
338
+ i += 2;
339
+ continue;
340
+ }
341
+ const name = t.split("=")[0];
342
+ const attached = t.includes("=") && (GIT_GLOBAL_VALUE.has(name) || name === "--exec-path");
343
+ if (!GIT_GLOBAL_FLAG.has(t) && !attached)
344
+ known = false;
345
+ i += 1;
346
+ }
347
+ return { idx: i, known };
348
+ }
349
+ function gitSubIndex(a) {
350
+ return gitSubInfo(a).idx;
351
+ }
352
+ /**
353
+ * Force push (flag or `+` refspec) whose destination is a protected branch. Covers
354
+ * `-fu` bundles, `--force-with-lease`, `HEAD:main` and `refs/heads/main`. HEAD-based
355
+ * pushes (no refspec) are not seen, and neither are git aliases.
356
+ */
357
+ function gitForcePushProtected(c) {
358
+ if (c.name !== "git")
359
+ return false;
360
+ const a = c.args;
361
+ const i = gitSubIndex(a);
362
+ if (a[i] !== "push")
363
+ return false;
364
+ const rest = a.slice(i + 1);
365
+ const force = rest.some((t) => /^--force(-with-lease|-if-includes)?(=|$)/.test(t) || /^-[A-Za-z]*f/.test(t));
366
+ const refspecs = rest.filter((t) => !t.startsWith("-")).slice(1);
367
+ return refspecs.some((r) => {
368
+ const plus = r.startsWith("+");
369
+ if (!force && !plus)
370
+ return false;
371
+ const body = plus ? r.slice(1) : r;
372
+ const dst = (body.includes(":") ? body.slice(body.lastIndexOf(":") + 1) : body).replace(/^refs\/heads\//, "");
373
+ return dst !== "" && PROTECTED_BRANCH(dst);
374
+ });
375
+ }
376
+ /** A `.env` name that ends in one of these is a template, not a secret (`.env.local.example`). Only the last suffix counts. */
377
+ const ENV_TEMPLATE = /\.(example|sample|template)$/;
378
+ const HOME_CREDENTIAL = /^(~|\/Users\/[^/]+|\/home\/[^/]+|\/root)\/\.(npmrc|netrc|git-credentials)$/;
379
+ const GCLOUD_CREDENTIAL = /(^|\/)\.config\/gcloud\/(application_default_credentials\.json|credentials\.db|access_tokens\.db|legacy_credentials(\/|$))/;
380
+ const BLOCK_DEVICE = /^\/dev\/(sd|hd|vd|xvd|nvme|mmcblk|disk|rdisk|md|dm-|loop|mapper\/)/;
381
+ const GLOB_CHARS = /[*?[]/;
382
+ /** Secret names that a glob segment is tested against (fixed, short targets). */
383
+ const GLOB_ENV_NAMES = [".env", ".env.local"];
384
+ const GLOB_HOME_NAMES = [".npmrc", ".netrc", ".git-credentials"];
385
+ /** A directory that is a home directory: `~`, `/root`, `/home/<u>` or `/Users/<u>`. */
386
+ const HOME_DIR = /^(~|\/Users\/[^/]+|\/home\/[^/]+|\/root)$/;
387
+ /** Tokens of one glob segment. Linear: a `[` with no closing `]` is a literal, and a class is read once. */
388
+ function globTokens(pat) {
389
+ const out = [];
390
+ const lastClose = pat.lastIndexOf("]");
391
+ for (let i = 0; i < pat.length; i++) {
392
+ const c = pat[i];
393
+ if (c === "*") {
394
+ if (out[out.length - 1]?.kind !== "star")
395
+ out.push({ kind: "star" });
396
+ }
397
+ else if (c === "?") {
398
+ out.push({ kind: "any", test: () => true });
399
+ }
400
+ else if (c === "[" && i < lastClose) {
401
+ let j = i + 1;
402
+ const neg = pat[j] === "!" || pat[j] === "^";
403
+ if (neg)
404
+ j++;
405
+ const singles = new Set();
406
+ const ranges = [];
407
+ let end = -1;
408
+ for (let p = j; p < pat.length; p++) {
409
+ if (pat[p] === "]" && p > j) {
410
+ end = p;
411
+ break;
412
+ }
413
+ if (pat[p + 1] === "-" && p + 2 < pat.length && pat[p + 2] !== "]") {
414
+ ranges.push([pat[p], pat[p + 2]]);
415
+ p += 2;
416
+ }
417
+ else {
418
+ singles.add(pat[p]);
419
+ }
420
+ }
421
+ if (end < 0) {
422
+ out.push({ kind: "lit", ch: "[" });
423
+ continue;
424
+ }
425
+ out.push({ kind: "any", test: (ch) => (singles.has(ch) || ranges.some(([a, b]) => ch >= a && ch <= b)) !== neg });
426
+ i = end;
427
+ }
428
+ else {
429
+ out.push({ kind: "lit", ch: c });
430
+ }
431
+ }
432
+ return out;
433
+ }
434
+ /**
435
+ * Does a shell glob segment match a fixed name? Shell rule: a name that starts with a dot is
436
+ * matched only by a pattern that starts with a literal dot, so `cat *.env` and `cat *` do not
437
+ * reach `.env` (checked with bash and zsh). Cost is pattern length times name length.
438
+ */
439
+ function globSegMatch(pat, name) {
440
+ if (!GLOB_CHARS.test(pat))
441
+ return pat === name;
442
+ const toks = globTokens(pat);
443
+ if (name[0] === "." && !(toks[0]?.kind === "lit" && toks[0].ch === "."))
444
+ return false;
445
+ const n = name.length;
446
+ let reach = new Array(n + 1).fill(false);
447
+ reach[0] = true;
448
+ for (const t of toks) {
449
+ const next = new Array(n + 1).fill(false);
450
+ if (t.kind === "star") {
451
+ for (let j = 0; j <= n; j++)
452
+ next[j] = reach[j] || (j > 0 && next[j - 1]);
453
+ }
454
+ else {
455
+ for (let j = 0; j < n; j++) {
456
+ if (!reach[j])
457
+ continue;
458
+ if (t.kind === "lit" ? name[j] === t.ch : t.test(name[j]))
459
+ next[j + 1] = true;
460
+ }
461
+ }
462
+ reach = next;
463
+ }
464
+ return reach[n];
465
+ }
466
+ /**
467
+ * Glob forms of the secret rules, for a path that has a glob. A segment counts when it could
468
+ * match a secret name: `.e[n]v`, `.en?`, `.e?v.local` (`.env`, `.env.local`), `~/.n?mrc` (home credentials),
469
+ * `~/.s?h/id_rsa` and `.a?s/credentials` (key or credentials under a credential directory), and any glob
470
+ * name under `.ssh` or `.aws`, as the literal rule does for `~/.ssh/*`.
471
+ */
472
+ function globSecret(p) {
473
+ const cut = p.lastIndexOf("/");
474
+ const dir = cut < 0 ? "" : p.slice(0, cut);
475
+ const base = p.slice(cut + 1);
476
+ const dirName = dir.slice(dir.lastIndexOf("/") + 1);
477
+ if (GLOB_ENV_NAMES.some((name) => globSegMatch(base, name)))
478
+ return true;
479
+ if (HOME_DIR.test(dir) && GLOB_HOME_NAMES.some((name) => globSegMatch(base, name)))
480
+ return true;
481
+ const ssh = globSegMatch(dirName, ".ssh");
482
+ if (!ssh && !globSegMatch(dirName, ".aws"))
483
+ return false;
484
+ if (GLOB_CHARS.test(base))
485
+ return true;
486
+ return ssh ? base.startsWith("id_") && !base.endsWith(".pub") : base === "credentials";
487
+ }
488
+ /** Private key, cloud or package credential, or a real `.env` file (templates are not secret). Globs that could match one count too. */
489
+ export function isSecretPath(raw, home) {
490
+ const p = normalizePath(raw, home);
491
+ const base = p.slice(p.lastIndexOf("/") + 1);
492
+ if (/(^|\/)\.ssh\/id_/.test(p) && !base.endsWith(".pub"))
493
+ return true;
494
+ if (/(^|\/)\.(ssh|aws)\/[^/]*[*?[]/.test(p))
495
+ return true;
496
+ if (/(^|\/)\.aws\/credentials$/.test(p))
497
+ return true;
498
+ if (GCLOUD_CREDENTIAL.test(p))
499
+ return true;
500
+ if (HOME_CREDENTIAL.test(p))
501
+ return true;
502
+ if (base === ".env" || (base.startsWith(".env.") && !ENV_TEMPLATE.test(base)))
503
+ return true;
504
+ if (base.startsWith(".env") && /[*?[]/.test(base))
505
+ return true;
506
+ if (GLOB_CHARS.test(p) && globSecret(p))
507
+ return true;
508
+ return /\.(pem|key)$/.test(base);
509
+ }
510
+ /**
511
+ * Options whose separate value is text (a message, a grep pattern, an author), not a path. A short
512
+ * cluster that ends in m, S or G (`-m`, `-am`, `-S`) takes the next word too. Attached forms
513
+ * (`--message=x`, `-mx`) are one option word, so they are skipped by the option check.
514
+ */
515
+ const GIT_TEXT_CLUSTER = /^-[A-Za-z]*[mSG]$/;
516
+ /** Subcommands whose `-m`/`--message` takes text. In `git log`, `-m` is a flag, so its next word is checked. */
517
+ const MESSAGE_CMDS = new Set(["commit", "tag", "merge", "revert", "notes", "stash", "am", "cherry-pick"]);
518
+ /** Subcommands whose `-S`, `-G`, `--grep` and `--author` take a search pattern. */
519
+ const SEARCH_CMDS = new Set(["log", "whatchanged", "show", "shortlog", "rev-list", "reflog"]);
520
+ function takesText(sub, t) {
521
+ if (MESSAGE_CMDS.has(sub) && (t === "--message" || (GIT_TEXT_CLUSTER.test(t) && t.endsWith("m"))))
522
+ return true;
523
+ return SEARCH_CMDS.has(sub) && (t === "--grep" || t === "--author" || (GIT_TEXT_CLUSTER.test(t) && /[SG]$/.test(t)));
524
+ }
525
+ /** Git config from the environment that defines an alias (`GIT_CONFIG_KEY_0=alias.x`, `GIT_CONFIG_PARAMETERS=...`). */
526
+ const GIT_ENV_ALIAS = /^(GIT_CONFIG_KEY_\d+=alias\.|GIT_CONFIG_PARAMETERS=.*alias\.)/i;
527
+ /** The part after any colon of a word leaves a secret path: `HEAD:.env`, `:0:.env`, `HEAD@{1}:.env`. */
528
+ function colonPathSecret(t, home) {
529
+ for (let k = t.indexOf(":"); k >= 0; k = t.indexOf(":", k + 1)) {
530
+ if (k + 1 < t.length && isSecretPath(t.slice(k + 1), home))
531
+ return true;
532
+ }
533
+ return false;
534
+ }
535
+ /**
536
+ * Any git command, any subcommand (so unknown subcommands and `whatchanged` count as possible reads).
537
+ * A word shaped like REF:PATH, or `-L<range>:<path>`, is checked by its colon parts. A text option
538
+ * skips its value. `-F FILE` is a file, so it is checked as one. `-c alias.*` is denied: an alias can run
539
+ * any command, and its text is not checked.
540
+ */
541
+ function gitReadsSecret(c, home) {
542
+ const a = c.args;
543
+ const { idx, known } = gitSubInfo(a);
544
+ const sub = a[idx] ?? "";
545
+ for (let i = 0; i < a.length; i++) {
546
+ const t = a[i];
547
+ // Free text (message, search pattern, author) is skipped, and only then. Unknown options disable it.
548
+ if (known && takesText(sub, t)) {
549
+ i++;
550
+ continue;
551
+ }
552
+ // `-F FILE` is a file: its value is checked as a file, and the value is still visited as a word.
553
+ if (t === "-F" || t === "--file") {
554
+ if (i + 1 < a.length && isSecretPath(a[i + 1], home))
555
+ return true;
556
+ continue;
557
+ }
558
+ // An alias can run any command. The key is compared without case.
559
+ if ((t === "-c" || t === "--config-env") && i + 1 < a.length && /^alias\./i.test(a[i + 1]))
560
+ return true;
561
+ if (/^--config-env=alias\./i.test(t))
562
+ return true;
563
+ if (t.startsWith("-")) {
564
+ if (t.startsWith("-L") && colonPathSecret(t, home))
565
+ return true;
566
+ continue;
567
+ }
568
+ if (colonPathSecret(t, home))
569
+ return true;
570
+ }
571
+ return false;
572
+ }
573
+ /** A whole credential directory. Only shell copy or read verbs check it: listing it is not a read of a key. */
574
+ function isSecretDir(raw, home) {
575
+ return /(^|\/)\.(ssh|aws)$/.test(normalizePath(raw, home));
576
+ }
577
+ /** Verbs that read a file. Copy verbs check the sources only (the last argument is the destination). */
578
+ const READ_VERBS = new Set([
579
+ "cat", "less", "more", "head", "tail", "cp", "scp", "rsync", "base64", "xxd", "od", "hexdump",
580
+ "strings", "nl", "tac", "bat", "sed", "awk", "grep", "egrep", "cut", "sort", "tar", "zip", "gzip",
581
+ "xz", "openssl", "curl", "wget", "nc", "ncat", "dd", "install", "diff", "cmp", "wc", "file", "jq",
582
+ "yq", "column", "md5sum", "shasum", "sha256sum",
583
+ ]);
584
+ const COPY_VERBS = new Set(["cp", "scp", "rsync", "install"]);
585
+ /** Shell redirection operators, as tokens. */
586
+ const REDIRECT_OPS = new Set(["<", "<<", "<<<", ">", ">>"]);
587
+ /** Verbs whose first operand is a pattern, not a file. */
588
+ const PATTERN_VERBS = new Set(["grep", "egrep", "fgrep"]);
589
+ /**
590
+ * Code that reads a file by name, as a call: `open('.env')`, `readFileSync(".env")`, Ruby `File.read('.env')`,
591
+ * PHP `file_get_contents('.env')`. Only the first quoted or bare name after the `(` counts.
592
+ */
593
+ const READ_CALL = /\b(open|Path|readFileSync|readFile|read_text|read_bytes|File\.read|IO\.read|file_get_contents|fopen|readfile)\s*\(\s*(['"`]?)([^'"`)\s,]+)/g;
594
+ /** One pass over a shell's segments: redirects, read verbs and git object reads. */
595
+ function secretReadShell(sh) {
596
+ for (const seg of sh.segments) {
597
+ const toks = seg.tokens;
598
+ for (let i = 0; i + 1 < toks.length; i++) {
599
+ if (toks[i] === "<" && isSecretPath(toks[i + 1], sh.home))
600
+ return true;
601
+ }
602
+ for (let k = 0; k < seg.cmds.length; k++) {
603
+ const c = seg.cmds[k];
604
+ if (c.name === "git") {
605
+ if (gitReadsSecret(c, sh.home) || seg.tokens.some((t) => GIT_ENV_ALIAS.test(t)))
606
+ return true;
607
+ continue;
608
+ }
609
+ if (!READ_VERBS.has(c.name))
610
+ continue;
611
+ const args = COPY_VERBS.has(c.name) ? c.args.slice(0, -1) : c.args;
612
+ const valueOpts = OPTION_VALUES[c.name];
613
+ // grep: the first operand is the pattern, unless -e or -f names the patterns.
614
+ const namesPatterns = c.args.some((a) => /^-[A-Za-z]*[ef]/.test(a) || /^--(regexp|file)/.test(a));
615
+ const patternIdx = PATTERN_VERBS.has(c.name) && !namesPatterns ? args.findIndex((a) => !a.startsWith("-")) : -1;
616
+ for (let i = 0; i < args.length; i++) {
617
+ const a = args[i];
618
+ if (i === patternIdx)
619
+ continue;
620
+ // Redirections are not operands: `> file` writes to file, and `<<` only starts a heredoc.
621
+ if (REDIRECT_OPS.has(a)) {
622
+ if (a === ">" || a === ">>")
623
+ i++;
624
+ continue;
625
+ }
626
+ // A value of an option is a host, key or command, not a file read: skip it by advancing the index.
627
+ if (valueOpts?.has(a)) {
628
+ i++;
629
+ continue;
630
+ }
631
+ const prev = args[i - 1];
632
+ if (c.name === "dd") {
633
+ if (a.startsWith("if=") && isSecretPath(a.slice(3), sh.home))
634
+ return true;
635
+ }
636
+ else if (NET_CLIENTS.has(c.name)) {
637
+ // Upload forms only: `-d @file`, `-F f=@file`, `-T file`, `--upload-file file`. Never `-o file`.
638
+ const at = a.indexOf("@");
639
+ if (at >= 0 && isSecretPath(a.slice(at + 1), sh.home))
640
+ return true;
641
+ if ((prev === "-T" || prev === "--upload-file") && isSecretPath(a, sh.home))
642
+ return true;
643
+ }
644
+ else if (!a.startsWith("-") && (isSecretPath(a, sh.home) || isSecretDir(a, sh.home))) {
645
+ // Options such as `--include=*.pem` are not file operands.
646
+ return true;
647
+ }
648
+ }
649
+ }
650
+ }
651
+ return false;
652
+ }
653
+ /**
654
+ * Any command in a shell command that runs an interpreter, and a READ_CALL that names a secret
655
+ * file anywhere in the raw text. The scan is over the whole command, not only the interpreter's
656
+ * code: `python3 x.py; echo "open('.env')"` is denied.
657
+ */
658
+ function interpreterReadsSecret(sh) {
659
+ if (!sh.segments.some((s) => s.cmds.some((c) => INTERPRETERS.has(c.name))))
660
+ return false;
661
+ for (const m of sh.raw.matchAll(READ_CALL)) {
662
+ if (isSecretPath(m[3], sh.home))
663
+ return true;
664
+ }
665
+ return false;
666
+ }
667
+ /** Does this stage download? A network client, or a shell whose `-c` code starts with one. */
668
+ function netStage(c) {
669
+ if (NET_CLIENTS.has(c.name))
670
+ return true;
671
+ if (!SHELLS.has(c.name))
672
+ return false;
673
+ const i = c.args.findIndex((a) => /^-[A-Za-z]*c[A-Za-z]*$/.test(a));
674
+ if (i < 0)
675
+ return false;
676
+ const inner = commandAt(c.args.slice(i + 1), true);
677
+ return inner !== undefined && NET_CLIENTS.has(inner.name);
678
+ }
679
+ /** A network client's output piped (directly or through more pipes) into a shell. */
680
+ function pipeNetworkToShell(sh) {
681
+ let fetched = false;
682
+ for (const seg of sh.segments) {
683
+ if (!seg.pipeIn)
684
+ fetched = false;
685
+ // Cheap pre-check (necessary condition): a download needs a curl or wget word in this segment,
686
+ // and a shell after a download needs fetched. Skips the candidate pass for the rest.
687
+ if (!fetched && !seg.tokens.some((t) => t.includes("curl") || t.includes("wget") || t.includes("$(")))
688
+ continue;
689
+ const kept = keptCmds(seg);
690
+ for (let k = 0; k < kept.length; k++) {
691
+ const c = kept[k];
692
+ if (fetched && SHELLS.has(c.name))
693
+ return true;
694
+ if (netStage(c))
695
+ fetched = true;
696
+ }
697
+ // A download substitution in this segment (`echo $(curl ...)`, `cat <(curl ...)`, a backtick) is the
698
+ // output that the next stage of a pipe reads. It is set after this segment's own commands, so
699
+ // `bash -c "echo $(curl ...)"` (the download is only an argument) is not a pipe into a shell.
700
+ if (seg.tokens.some((t) => NET_SUBST_WORD.test(t)))
701
+ fetched = true;
702
+ }
703
+ return false;
704
+ }
705
+ /**
706
+ * A download run as code: `sh -c "$(curl ...)"`, `bash <(curl ...)`, `source <(...)`,
707
+ * `eval "$(curl ...)"`. Only the command word decides. A download used as a plain
708
+ * argument (`docker build --build-arg V=$(curl ...)`, `cp $(curl ...) .`) does not match.
709
+ */
710
+ function nestedNetExec(sh) {
711
+ for (let i = 0; i < sh.segments.length; i++) {
712
+ const seg = sh.segments[i];
713
+ // Necessary condition: a download substitution is a word that starts with `$(`.
714
+ if (!seg.tokens.some((t) => t.includes("$(")))
715
+ continue;
716
+ const kept = keptCmds(seg);
717
+ for (let k = 0; k < kept.length; k++) {
718
+ const c = kept[k];
719
+ if (NET_SUBST_WORD.test(c.raw))
720
+ return true;
721
+ if (EXEC_WORDS.has(c.name) && c.args.some((a) => NET_SUBST.test(a)))
722
+ return true;
723
+ }
724
+ }
725
+ return false;
726
+ }
727
+ // Fork bomb: `:(){ :|:& };:` or any one-word name. The name is bounded so the scan stays linear.
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
+ }
740
+ const ANSI_ESC = {
741
+ n: "\n", t: "\t", r: "\r", a: "\x07", b: "\b", e: "\x1b", f: "\f", v: "\v",
742
+ "\\": "\\", "'": "'", '"': '"', "?": "?",
743
+ };
744
+ /** Decode the escapes of a `$'...'` word: `\xHH`, octal, and the simple C escapes. */
745
+ function decodeAnsiC(body) {
746
+ return body.replace(/\\(x[0-9a-fA-F]{1,2}|[0-7]{1,3}|.)/g, (_m, e) => {
747
+ if (e.length > 1 && e[0] === "x")
748
+ return String.fromCharCode(parseInt(e.slice(1), 16));
749
+ if (/^[0-7]+$/.test(e))
750
+ return String.fromCharCode(parseInt(e, 8));
751
+ return ANSI_ESC[e] ?? e;
752
+ });
753
+ }
754
+ /** Is this command word a shell or interpreter (for heredoc bodies)? */
755
+ function isCodeCommand(toks) {
756
+ return commandsAt(toks, true).some((k) => SHELLS.has(k.name) || INTERPRETERS.has(k.name));
757
+ }
758
+ /** Index of the `)` that closes a `$(` whose text starts at `start`. Quoted text, escapes and comments do not count. */
759
+ function closeParen(text, start) {
760
+ const n = text.length;
761
+ let level = 1;
762
+ let j = start;
763
+ while (j < n) {
764
+ const ch = text[j];
765
+ if (ch === "\\")
766
+ j += 2;
767
+ else if (ch === "'") {
768
+ const e = text.indexOf("'", j + 1);
769
+ j = e < 0 ? j + 1 : e + 1;
770
+ }
771
+ else if (ch === '"')
772
+ j = skipDouble(text, j);
773
+ else if (ch === "#" && (j === start || /\s/.test(text[j - 1]))) {
774
+ const e = text.indexOf("\n", j);
775
+ j = e < 0 ? n : e;
776
+ }
777
+ else if (ch === "(") {
778
+ level++;
779
+ j++;
780
+ }
781
+ else if (ch === ")") {
782
+ level--;
783
+ if (level === 0)
784
+ return j;
785
+ j++;
786
+ }
787
+ else
788
+ j++;
789
+ }
790
+ return n;
791
+ }
792
+ /** Index of the backtick that closes a backtick substitution starting at `start`. Quoted text does not count. */
793
+ function closeBacktick(text, start) {
794
+ const n = text.length;
795
+ let j = start;
796
+ while (j < n && text[j] !== "`") {
797
+ const ch = text[j];
798
+ if (ch === "\\")
799
+ j += 2;
800
+ else if (ch === "'") {
801
+ const e = text.indexOf("'", j + 1);
802
+ j = e < 0 ? j + 1 : e + 1;
803
+ }
804
+ else if (ch === '"')
805
+ j = skipDouble(text, j);
806
+ else
807
+ j++;
808
+ }
809
+ return j;
810
+ }
811
+ /** Index after the closing quote of a double-quoted word that opens at `j`. An unterminated quote is a literal character. */
812
+ function skipDouble(text, j) {
813
+ let k = j + 1;
814
+ while (k < text.length && text[k] !== '"')
815
+ k += text[k] === "\\" ? 2 : 1;
816
+ return k < text.length ? k + 1 : j + 1;
817
+ }
818
+ /**
819
+ * Splits shell text into command segments. Quotes group words, so separators inside
820
+ * quotes are text. `$(...)`, `<(...)`, `>(...)` and backticks become one word whose text
821
+ * is `$(inner)`; the inner text is scanned as commands too. A heredoc body is text,
822
+ * unless it is fed to a shell or interpreter (or piped into one), then it is code.
823
+ */
824
+ function scanInto(text, out, depth) {
825
+ const n = text.length;
826
+ let i = 0;
827
+ let tokens = [];
828
+ let word = "";
829
+ let inWord = false;
830
+ let pipeIn = false;
831
+ const pending = [];
832
+ // Heredoc pipe check: the end of the current line, and whether that line pipes into a shell. Cached per line.
833
+ let nextNl = -1;
834
+ let pipeLine = -1;
835
+ let pipeHere = false;
836
+ const endWord = () => {
837
+ if (inWord) {
838
+ tokens.push(word);
839
+ word = "";
840
+ inWord = false;
841
+ }
842
+ };
843
+ const endSeg = (nextPipe) => {
844
+ endWord();
845
+ if (tokens.length > 0)
846
+ out.push({ tokens, pipeIn });
847
+ tokens = [];
848
+ pipeIn = nextPipe;
849
+ };
850
+ // Adds a substitution to the word. `at` is the `$`, `<`, `>` or backtick. Returns the index after it.
851
+ const subst = (at) => {
852
+ let end;
853
+ let inner;
854
+ if (text[at] === "`") {
855
+ const close = Math.min(closeBacktick(text, at + 1), n);
856
+ inner = text.slice(at + 1, close);
857
+ end = close + 1;
858
+ }
859
+ else {
860
+ const start = at + 2; // at+1 is the "("
861
+ const close = Math.min(closeParen(text, start), n);
862
+ inner = text.slice(start, close);
863
+ end = close + 1;
864
+ }
865
+ word += `$(${inner})`;
866
+ inWord = true;
867
+ if (depth < MAX_DEPTH)
868
+ scanInto(inner, out, depth + 1);
869
+ return end;
870
+ };
871
+ // Reads the heredoc bodies that start after the current newline.
872
+ const consumeHeredocs = () => {
873
+ for (const h of pending) {
874
+ let body = "";
875
+ while (i < n) {
876
+ let e = text.indexOf("\n", i);
877
+ if (e < 0)
878
+ e = n;
879
+ const line = text.slice(i, e);
880
+ i = e + 1;
881
+ if ((h.strip ? line.replace(/^\t+/, "") : line) === h.delim)
882
+ break;
883
+ body += line + "\n";
884
+ }
885
+ if (h.code && depth < MAX_DEPTH)
886
+ scanInto(body, out, depth + 1);
887
+ }
888
+ pending.length = 0;
889
+ };
890
+ while (i < n) {
891
+ const c = text[i];
892
+ if (c === "'") {
893
+ const e = text.indexOf("'", i + 1);
894
+ if (e < 0) {
895
+ // Unterminated quote: a literal character, so the text after it is still scanned.
896
+ word += c;
897
+ inWord = true;
898
+ i++;
899
+ }
900
+ else {
901
+ word += text.slice(i + 1, e);
902
+ inWord = true;
903
+ i = e + 1;
904
+ }
905
+ }
906
+ else if (c === '"' && skipDouble(text, i) === i + 1) {
907
+ word += c;
908
+ inWord = true;
909
+ i++;
910
+ }
911
+ else if (c === '"') {
912
+ inWord = true;
913
+ let j = i + 1;
914
+ while (j < n && text[j] !== '"') {
915
+ const d = text[j];
916
+ if (d === "\\" && j + 1 < n) {
917
+ // Backslash-newline is a line continuation: nothing is added.
918
+ if (text[j + 1] !== "\n")
919
+ word += text[j + 1];
920
+ j += 2;
921
+ }
922
+ else if ((d === "$" && text[j + 1] === "(") || d === "`") {
923
+ j = subst(j);
924
+ }
925
+ else {
926
+ word += d;
927
+ j++;
928
+ }
929
+ }
930
+ i = j + 1;
931
+ }
932
+ else if (c === "#" && !inWord) {
933
+ // A comment starts at the beginning of a word and runs to the end of the line.
934
+ const e = text.indexOf("\n", i);
935
+ i = e < 0 ? n : e;
936
+ }
937
+ else if (c === "$" && text[i + 1] === "'" && text.indexOf("'", i + 2) >= 0) {
938
+ // $'...': the decoded text is part of this word. Its characters never act as quotes or separators.
939
+ let j = i + 2;
940
+ let body = "";
941
+ while (j < n && text[j] !== "'") {
942
+ if (text[j] === "\\" && j + 1 < n) {
943
+ body += text[j] + text[j + 1];
944
+ j += 2;
945
+ }
946
+ else {
947
+ body += text[j];
948
+ j++;
949
+ }
950
+ }
951
+ word += decodeAnsiC(body);
952
+ inWord = true;
953
+ i = j + 1;
954
+ }
955
+ else if (c === "\\" && text[i + 1] === "\n") {
956
+ // Line continuation: the word goes on after it (`rm -rf \<LF>~` is `rm -rf ~`).
957
+ i += 2;
958
+ }
959
+ else if (c === "\\") {
960
+ if (i + 1 < n)
961
+ word += text[i + 1];
962
+ inWord = true;
963
+ i += 2;
964
+ }
965
+ else if (c === " " || c === "\t" || c === "\r") {
966
+ endWord();
967
+ i++;
968
+ }
969
+ else if (c === "\n") {
970
+ endSeg(false);
971
+ i++;
972
+ if (pending.length > 0)
973
+ consumeHeredocs();
974
+ }
975
+ else if (c === "&" && text[i - 1] === "|") {
976
+ i++; // `|&`: the pipe was already recorded
977
+ }
978
+ else if (c === ";" || c === "&" || c === "|") {
979
+ let step = 1;
980
+ let pipe = false;
981
+ if (text[i + 1] === c)
982
+ step = 2; // && and ||
983
+ else if (c === "|")
984
+ pipe = true;
985
+ endSeg(pipe);
986
+ i += step;
987
+ }
988
+ else if (c === "(" || c === ")") {
989
+ endSeg(false);
990
+ i++;
991
+ }
992
+ else if (c === "`" || (c === "$" && text[i + 1] === "(")) {
993
+ inWord = true;
994
+ i = subst(i);
995
+ }
996
+ else if ((c === "<" || c === ">") && text[i + 1] === "(") {
997
+ inWord = true;
998
+ i = subst(i);
999
+ }
1000
+ else if (c === "<" && text[i + 1] === "<") {
1001
+ endWord();
1002
+ if (text[i + 2] === "<") {
1003
+ tokens.push("<<<");
1004
+ i += 3;
1005
+ }
1006
+ else {
1007
+ if (nextNl < i) {
1008
+ const e = text.indexOf("\n", i);
1009
+ nextNl = e < 0 ? n : e;
1010
+ }
1011
+ if (pipeLine !== nextNl) {
1012
+ pipeLine = nextNl;
1013
+ pipeHere = /\|\s*(sudo\s+)?(sh|bash|zsh|dash|ksh)\b/.test(text.slice(i, nextNl));
1014
+ }
1015
+ const code = isCodeCommand(tokens) || pipeHere;
1016
+ // A heredoc is not an operand: no token is emitted, so many heredocs do not grow the command.
1017
+ let j = i + 2;
1018
+ const strip = text[j] === "-";
1019
+ if (strip)
1020
+ j++;
1021
+ while (j < n && (text[j] === " " || text[j] === "\t"))
1022
+ j++;
1023
+ let delim = "";
1024
+ while (j < n && !/[\s;&|()<>]/.test(text[j])) {
1025
+ const d = text[j];
1026
+ if (d === "'" || d === '"') {
1027
+ let e = text.indexOf(d, j + 1);
1028
+ if (e < 0)
1029
+ e = n;
1030
+ delim += text.slice(j + 1, e);
1031
+ j = e + 1;
1032
+ }
1033
+ else {
1034
+ delim += d;
1035
+ j++;
1036
+ }
1037
+ }
1038
+ pending.push({ delim, strip, code });
1039
+ i = j;
1040
+ }
1041
+ }
1042
+ else if (c === "<" || c === ">") {
1043
+ endWord();
1044
+ const dbl = c === ">" && text[i + 1] === ">";
1045
+ tokens.push(dbl ? ">>" : c);
1046
+ i += dbl ? 2 : 1;
1047
+ }
1048
+ else {
1049
+ word += c;
1050
+ inWord = true;
1051
+ i++;
1052
+ }
1053
+ }
1054
+ endSeg(false);
1055
+ }
1056
+ /**
1057
+ * Segments of one piece of shell text: variables substituted, command words found, and
1058
+ * quoted code given to a shell, eval or source (`bash -c "rm -rf ~"`) scanned as commands too.
1059
+ */
1060
+ function segmentsOf(text, scope, depth) {
1061
+ const raw = [];
1062
+ scanInto(text, raw, depth);
1063
+ for (const s of raw) {
1064
+ for (const t of s.tokens) {
1065
+ const m = ASSIGN_FULL.exec(t);
1066
+ if (m)
1067
+ scope.set(m[1], m[2]);
1068
+ }
1069
+ }
1070
+ const sub = (t) => {
1071
+ if (!t.includes("$"))
1072
+ return t;
1073
+ let out = t.replace(/\$\{HOME\}|\$HOME(?![A-Za-z0-9_])/g, "~");
1074
+ if (scope.size > 0 && out.includes("$")) {
1075
+ out = out.replace(/\$\{([A-Za-z_]\w*)\}|\$([A-Za-z_]\w*)/g, (m, a, b) => scope.get((a ?? b)) ?? m);
1076
+ }
1077
+ return out;
1078
+ };
1079
+ const segs = raw.map((s) => {
1080
+ const tokens = s.tokens.map(sub);
1081
+ return { tokens, pipeIn: s.pipeIn, cmds: commandsAt(tokens, false), kept: undefined };
1082
+ });
1083
+ if (depth < MAX_DEPTH) {
1084
+ const extra = [];
1085
+ for (const s of segs) {
1086
+ // `eval` and `env` are wrappers, so the command word can be past them: look for the exec word anywhere.
1087
+ const e0 = s.tokens.findIndex((t) => EXEC_WORDS.has(wordName(t)));
1088
+ if (e0 < 0)
1089
+ continue;
1090
+ for (const a of s.tokens.slice(e0 + 1)) {
1091
+ if (/[\s;&|()`]/.test(a)) {
1092
+ for (const e of segmentsOf(a, scope, depth + 1))
1093
+ extra.push(e);
1094
+ }
1095
+ }
1096
+ }
1097
+ for (const e of extra)
1098
+ segs.push(e);
1099
+ }
1100
+ return segs;
1101
+ }
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
+ }
1279
+ }
1280
+ /**
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.
1685
+ */
1686
+ export const NEVER_LIST_INPUT_RULES = [
1687
+ // a. Recursive delete of home, root or a top-level system dir (also find -delete / -exec rm on those).
1688
+ { id: "rm-recursive-home-or-system", group: "a", shell: (sh) => hasCommand(sh, rmRecursiveProtected) },
1689
+ { id: "find-delete-home-or-system", group: "a", shell: (sh) => hasCommand(sh, findDeleteProtected) },
1690
+ // b. Force push (flag or +refspec) to main, master, release* or prod*.
1691
+ { id: "git-force-push-protected-branch", group: "b", shell: (sh) => hasCommand(sh, (c) => gitForcePushProtected(c)) },
1692
+ // c. Read or copy of a private key, cloud or package credential, or a real .env file.
1693
+ { id: "secret-read-shell", group: "c", shell: (sh) => secretReadShell(sh) || interpreterReadsSecret(sh) },
1694
+ { id: "secret-read-file", group: "c", file: isSecretPath },
1695
+ // d. Disk and system destroyers.
1696
+ { id: "mkfs", group: "d", shell: (sh) => hasCommand(sh, (c) => c.name.startsWith("mkfs")) },
1697
+ {
1698
+ id: "dd-to-block-device",
1699
+ group: "d",
1700
+ shell: (sh) => hasCommand(sh, (c) => c.name === "dd" && c.args.some((a) => a.startsWith("of=") && BLOCK_DEVICE.test(a.slice(3)))),
1701
+ },
1702
+ {
1703
+ id: "redirect-to-block-device",
1704
+ group: "d",
1705
+ shell: (sh) => sh.segments.some((seg) => seg.tokens.some((t, i) => (t === ">" || t === ">>") && BLOCK_DEVICE.test(seg.tokens[i + 1] ?? ""))),
1706
+ },
1707
+ { id: "fork-bomb", group: "d", shell: forkBomb },
1708
+ { id: "chmod-chown-recursive-root", group: "d", shell: (sh) => hasCommand(sh, chmodChownRecursiveProtected) },
1709
+ // e. Network download piped into a shell, or run as code through a substitution (`sh -c "$(curl ...)"`).
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 },
1735
+ ];
1736
+ const WRITE_NAME = /write|edit|create|update|delete|remove|move|rename|patch|replace|save|append|mkdir|touch/;
1737
+ const SHELL_NAME = /bash|shell|terminal|exec|command/;
1738
+ /** Single path fields of read tools (Claude, Codex, Gemini `read_file` uses file_path, `absolute_path` is its fallback). */
1739
+ const PATH_FIELDS = ["file_path", "filePath", "path", "target_file", "notebook_path", "absolute_path"];
1740
+ /** Path list fields of read tools (Gemini `read_many_files` takes `include`, glob patterns). */
1741
+ const PATH_LIST_FIELDS = ["include", "paths"];
1742
+ /** Last name segment (after `mcp__server__`), lower case. */
1743
+ function lastSegment(toolId) {
1744
+ return toolId.split("__").pop().trim().toLowerCase();
1745
+ }
1746
+ export function isShellTool(toolId) {
1747
+ const last = lastSegment(toolId);
1748
+ return findHostToolTier(last) === "SHELL" || SHELL_NAME.test(last);
1749
+ }
1750
+ /** Tools that write. The file rule skips them: a write to `.env` is not a read of a secret. */
1751
+ export function isWriteLike(toolId) {
1752
+ const last = lastSegment(toolId);
1753
+ if (findHostToolTier(last) === "WRITE" || WRITE_NAME.test(last))
1754
+ return true;
1755
+ const mcp = parseMcpToolId(toolId);
1756
+ if (!mcp)
1757
+ return false;
1758
+ const tier = mcpToolTier(mcp.server, mcp.tool);
1759
+ return tier === "WRITE" || tier === "ADMIN";
1760
+ }
1761
+ export function fieldsOf(toolInput) {
1762
+ let v = toolInput;
1763
+ if (typeof v === "string") {
1764
+ if (v.length > MAX_JSON_CHARS)
1765
+ return undefined;
1766
+ try {
1767
+ v = JSON.parse(v);
1768
+ }
1769
+ catch {
1770
+ return undefined;
1771
+ }
1772
+ }
1773
+ if (!v || typeof v !== "object" || Array.isArray(v))
1774
+ return undefined;
1775
+ return v;
1776
+ }
1777
+ /** The first and last window of a long field. A short field is one window. */
1778
+ export function windowsOf(s) {
1779
+ return s.length <= MAX_SCAN_CHARS ? [s] : [s.slice(0, MAX_SCAN_CHARS), s.slice(-MAX_SCAN_CHARS)];
1780
+ }
1781
+ export function commandStrings(f) {
1782
+ const v = f.command ?? f.cmd;
1783
+ let s;
1784
+ if (typeof v === "string")
1785
+ s = v;
1786
+ else if (Array.isArray(v) && v.every((x) => typeof x === "string"))
1787
+ s = v.join(" ");
1788
+ return s === undefined ? [] : windowsOf(s);
1789
+ }
1790
+ export function pathStrings(f, toolId) {
1791
+ const out = [];
1792
+ for (const k of PATH_FIELDS) {
1793
+ const v = f[k];
1794
+ if (typeof v === "string" && v.length > 0)
1795
+ out.push(...windowsOf(v));
1796
+ }
1797
+ if (/read/.test(lastSegment(toolId))) {
1798
+ for (const k of PATH_LIST_FIELDS) {
1799
+ const v = f[k];
1800
+ if (!Array.isArray(v))
1801
+ continue;
1802
+ for (const item of v) {
1803
+ if (typeof item === "string" && item.length > 0)
1804
+ out.push(...windowsOf(item));
1805
+ }
1806
+ }
1807
+ }
1808
+ return out;
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
+ }
1834
+ /** Home directory for `~` and absolute home paths. Undefined when the OS has none. */
1835
+ function safeHome() {
1836
+ try {
1837
+ const h = homedir();
1838
+ return h && h.startsWith("/") ? h : undefined;
1839
+ }
1840
+ catch {
1841
+ return undefined;
1842
+ }
1843
+ }
1844
+ /**
1845
+ * First matching input rule for this tool call, or undefined (allow). The caller
1846
+ * denies with reasonCode NEVER_EVENT. Never throws: any error is an undefined result.
1847
+ */
1848
+ export function neverListInputMatch(toolId, toolInput, home = safeHome(), cwd) {
1849
+ try {
1850
+ const f = fieldsOf(toolInput);
1851
+ if (!f)
1852
+ return undefined;
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
+ }
1861
+ for (const cmd of commandStrings(f)) {
1862
+ const sh = parseShell(cmd, home, cwd);
1863
+ for (const r of NEVER_LIST_INPUT_RULES)
1864
+ if (r.shell?.(sh))
1865
+ return { rule: r.id };
1866
+ }
1867
+ }
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 {
1877
+ for (const p of pathStrings(f, toolId)) {
1878
+ for (const r of NEVER_LIST_INPUT_RULES)
1879
+ if (r.file?.(p, home))
1880
+ return { rule: r.id };
1881
+ }
1882
+ }
1883
+ }
1884
+ catch {
1885
+ // Fail open: a parse problem never adds a deny.
1886
+ }
1887
+ return undefined;
1888
+ }
1889
+ //# sourceMappingURL=never-list-input.js.map