@shield-agent/kya 0.32.6 → 0.32.9

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