one-code-extension 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (153) hide show
  1. package/LICENSE +32 -0
  2. package/README.md +202 -0
  3. package/agents/explore.md +24 -0
  4. package/agents/general-purpose.md +21 -0
  5. package/agents/plan.md +22 -0
  6. package/docs/decisions/auto-mode.md +526 -0
  7. package/docs/decisions/branding.md +127 -0
  8. package/docs/decisions/code-review-remediation.md +106 -0
  9. package/docs/decisions/compaction.md +140 -0
  10. package/docs/decisions/distribution.md +133 -0
  11. package/docs/decisions/hooks.md +64 -0
  12. package/docs/decisions/lsp.md +58 -0
  13. package/docs/decisions/mcp.md +104 -0
  14. package/docs/decisions/memory-state.md +170 -0
  15. package/docs/decisions/model-policy.md +126 -0
  16. package/docs/decisions/modes.md +178 -0
  17. package/docs/decisions/skills-plugins.md +56 -0
  18. package/docs/decisions/subagents-workflows.md +225 -0
  19. package/docs/decisions/system-prompt.md +98 -0
  20. package/docs/decisions/tools.md +407 -0
  21. package/docs/decisions/tui.md +202 -0
  22. package/docs/decisions.md +148 -0
  23. package/extensions/ask-user/index.ts +124 -0
  24. package/extensions/ask-user/questions.ts +60 -0
  25. package/extensions/auto-mode/actions.ts +78 -0
  26. package/extensions/auto-mode/classifier.ts +388 -0
  27. package/extensions/auto-mode/config.ts +237 -0
  28. package/extensions/auto-mode/decision-log.ts +60 -0
  29. package/extensions/auto-mode/defaults.ts +65 -0
  30. package/extensions/auto-mode/instructions.ts +74 -0
  31. package/extensions/auto-mode/model-picker.ts +225 -0
  32. package/extensions/auto-mode/model-select.ts +178 -0
  33. package/extensions/auto-mode/paths.ts +109 -0
  34. package/extensions/auto-mode/pause.ts +96 -0
  35. package/extensions/auto-mode/prompt.ts +307 -0
  36. package/extensions/auto-mode/rules.ts +96 -0
  37. package/extensions/auto-mode/safety-floor.ts +100 -0
  38. package/extensions/auto-mode/sensitive.ts +137 -0
  39. package/extensions/auto-mode/shell-analysis.ts +702 -0
  40. package/extensions/background/index.ts +405 -0
  41. package/extensions/background/registry.ts +81 -0
  42. package/extensions/background/wakeup.ts +41 -0
  43. package/extensions/bash/background.ts +136 -0
  44. package/extensions/bash/index.ts +149 -0
  45. package/extensions/branding/assistant-marker.ts +53 -0
  46. package/extensions/branding/index.ts +366 -0
  47. package/extensions/branding/prompt-editor.ts +42 -0
  48. package/extensions/branding/prompt-marker.ts +29 -0
  49. package/extensions/branding/startup.ts +152 -0
  50. package/extensions/claude-compat/index.ts +50 -0
  51. package/extensions/claude-context/index.ts +79 -0
  52. package/extensions/compaction/index.ts +231 -0
  53. package/extensions/compaction/prompt.ts +191 -0
  54. package/extensions/context-management/index.ts +147 -0
  55. package/extensions/effort/index.ts +216 -0
  56. package/extensions/effort/slider.ts +253 -0
  57. package/extensions/exit/index.ts +28 -0
  58. package/extensions/file-tracker/index.ts +103 -0
  59. package/extensions/file-tracker/tracker.ts +145 -0
  60. package/extensions/hooks/debug.ts +53 -0
  61. package/extensions/hooks/executor.ts +132 -0
  62. package/extensions/hooks/index.ts +310 -0
  63. package/extensions/hooks/matcher.ts +72 -0
  64. package/extensions/hooks/plugin-hooks.ts +79 -0
  65. package/extensions/hooks/protocol.ts +151 -0
  66. package/extensions/hooks/settings.ts +163 -0
  67. package/extensions/hooks/trust.ts +151 -0
  68. package/extensions/lib/branch-restore.ts +35 -0
  69. package/extensions/lib/claude-context.ts +148 -0
  70. package/extensions/lib/deferred.ts +119 -0
  71. package/extensions/lib/git.ts +14 -0
  72. package/extensions/lib/memory.ts +219 -0
  73. package/extensions/lib/model-policy.ts +436 -0
  74. package/extensions/lib/model-tier.ts +99 -0
  75. package/extensions/lib/notifications.ts +18 -0
  76. package/extensions/lib/paths.ts +26 -0
  77. package/extensions/lib/per-cwd.ts +21 -0
  78. package/extensions/lib/persisted-output.ts +70 -0
  79. package/extensions/lib/pi-version.ts +54 -0
  80. package/extensions/lib/plugins.ts +227 -0
  81. package/extensions/lib/reminders.ts +212 -0
  82. package/extensions/lib/scratchpad.ts +68 -0
  83. package/extensions/lib/tui-render.ts +522 -0
  84. package/extensions/lsp/client.ts +278 -0
  85. package/extensions/lsp/format.ts +46 -0
  86. package/extensions/lsp/index.ts +193 -0
  87. package/extensions/lsp/protocol.ts +64 -0
  88. package/extensions/lsp/servers.ts +79 -0
  89. package/extensions/mcp/client.ts +205 -0
  90. package/extensions/mcp/config.ts +199 -0
  91. package/extensions/mcp/index.ts +373 -0
  92. package/extensions/mcp/schema.ts +161 -0
  93. package/extensions/memory/index.ts +90 -0
  94. package/extensions/notebook/index.ts +77 -0
  95. package/extensions/notebook/notebook.ts +108 -0
  96. package/extensions/permissions/index.ts +876 -0
  97. package/extensions/permissions/matcher.ts +455 -0
  98. package/extensions/permissions/modes.ts +107 -0
  99. package/extensions/permissions/protected-paths.ts +125 -0
  100. package/extensions/permissions/settings.ts +98 -0
  101. package/extensions/plan-mode/index.ts +209 -0
  102. package/extensions/plan-mode/reminder.ts +33 -0
  103. package/extensions/plan-mode/slug.ts +28 -0
  104. package/extensions/plan-mode/viewer.ts +124 -0
  105. package/extensions/plugins/index.ts +115 -0
  106. package/extensions/plugins/template.ts +42 -0
  107. package/extensions/search-tools/index.ts +0 -0
  108. package/extensions/search-tools/tools.ts +24 -0
  109. package/extensions/skill/index.ts +170 -0
  110. package/extensions/subagents/agents.ts +126 -0
  111. package/extensions/subagents/child.ts +414 -0
  112. package/extensions/subagents/default-model.ts +122 -0
  113. package/extensions/subagents/index.ts +1119 -0
  114. package/extensions/subagents/model-select.ts +346 -0
  115. package/extensions/subagents/rpc-turns.ts +123 -0
  116. package/extensions/subagents/runs.ts +93 -0
  117. package/extensions/subagents/usage.ts +53 -0
  118. package/extensions/subagents/worktree.ts +69 -0
  119. package/extensions/system-prompt/environment.ts +35 -0
  120. package/extensions/system-prompt/index.ts +54 -0
  121. package/extensions/system-prompt/template.ts +103 -0
  122. package/extensions/system-prompt/tiers/common.ts +63 -0
  123. package/extensions/system-prompt/tiers/frontier.ts +24 -0
  124. package/extensions/system-prompt/tiers/low.ts +71 -0
  125. package/extensions/system-prompt/tiers/mid.ts +56 -0
  126. package/extensions/system-reminder/index.ts +37 -0
  127. package/extensions/tasks/index.ts +140 -0
  128. package/extensions/tasks/store.ts +174 -0
  129. package/extensions/todo/index.ts +133 -0
  130. package/extensions/tool-search/index.ts +168 -0
  131. package/extensions/tool-style/index.ts +84 -0
  132. package/extensions/web/index.ts +43 -0
  133. package/extensions/web-fetch/extract.ts +139 -0
  134. package/extensions/web-fetch/index.ts +291 -0
  135. package/extensions/web-fetch/summarize.ts +78 -0
  136. package/extensions/workflow/agent-session.ts +368 -0
  137. package/extensions/workflow/globals.ts +251 -0
  138. package/extensions/workflow/index.ts +364 -0
  139. package/extensions/workflow/journal.ts +75 -0
  140. package/extensions/workflow/permission-gate.ts +71 -0
  141. package/extensions/workflow/run-manager.ts +306 -0
  142. package/extensions/workflow/saved-workflows.ts +45 -0
  143. package/extensions/workflow/script-source.ts +201 -0
  144. package/extensions/workflow/types.ts +99 -0
  145. package/extensions/workflow/vm-runtime.ts +31 -0
  146. package/extensions/workflow/widget.ts +56 -0
  147. package/extensions/worktree/index.ts +266 -0
  148. package/extensions/worktree/rewrite.ts +59 -0
  149. package/package.json +101 -0
  150. package/themes/one-code-light.json +76 -0
  151. package/themes/one-code.json +76 -0
  152. package/types/app.d.ts +5 -0
  153. package/types/community-packages.d.ts +12 -0
@@ -0,0 +1,702 @@
1
+ /**
2
+ * Deterministic shell pre-gate for auto mode (pure apart from path resolution).
3
+ *
4
+ * ## Why this exists and what its contract is
5
+ *
6
+ * Derived from the MI Copilot shell sandbox, but with an inverted contract. In
7
+ * that codebase the analyzer was the *only* gate, so every tokenizer gap was an
8
+ * exploitable bypass — the security review found dozens. Here it sits in front
9
+ * of an LLM classifier which sits in front of a user prompt, so the contract is:
10
+ *
11
+ * **This module may only ever conclude "provably safe". It never denies.**
12
+ *
13
+ * A gap therefore costs one classifier call, not a bypass. `verdict: "escalate"`
14
+ * is the default for everything not positively recognised: unknown commands,
15
+ * parse failures, dynamic expansion, unresolvable paths, any redirection to a
16
+ * path that is not provably inside the working directory.
17
+ *
18
+ * Its second job matters as much: it extracts **deterministic evidence** —
19
+ * resolved write targets, credential paths touched, whether a target escapes
20
+ * the working directory, which wrapped command actually runs — and hands that
21
+ * to the classifier. Parsing shell is exactly what an LLM classifier is worst
22
+ * at, so giving it facts instead of a command string is the point of having
23
+ * both layers.
24
+ *
25
+ * Review findings fixed relative to the original: N1 (`$'…'` ANSI-C quoting),
26
+ * N2 (bare `.`/`..`), N3 (brace expansion), N4 (unspaced `<>&|` boundaries),
27
+ * N5 (transparent wrappers), N6 (archive/sync tools), N7 (`find` actions),
28
+ * N8 (in-script redirection), N10 (`>|`), N11 (git default-deny by subcommand),
29
+ * N12 (sensitive check on every token, `cd` tracked), N13/F3/F10 (one shared
30
+ * denylist — see `sensitive.ts`), F1 (git global flags).
31
+ */
32
+
33
+ import { isExecutionPrimitivePath, isSensitivePath } from "./sensitive.ts";
34
+ import { isWithin, resolveForContainment, toAbsolute } from "./paths.ts";
35
+
36
+ export type ShellVerdict = "safe" | "escalate";
37
+
38
+ export interface ShellEvidence {
39
+ verdict: ShellVerdict;
40
+ /** Why it escalated / what the classifier should weigh. Never contains expanded variable values. */
41
+ notes: string[];
42
+ /** The commands that actually run, wrappers peeled (`env rm` → `rm`). */
43
+ commands: string[];
44
+ /** Resolved paths this command may write, with containment already decided. */
45
+ writes: { token: string; resolved?: string; outsideCwd: boolean }[];
46
+ /** Credential/secret paths named anywhere in the command (original tokens, never expanded). */
47
+ sensitivePaths: string[];
48
+ /** In-project paths whose contents execute later (`.git/hooks/*`, `.vscode/*.json`). */
49
+ executionPrimitives: string[];
50
+ /** Network-capable commands present (curl, ssh, …) — an egress signal for the classifier. */
51
+ network: string[];
52
+ }
53
+
54
+ /** Commands that read and do not write, and are safe to fast-path. */
55
+ const READ_ONLY_COMMANDS = new Set([
56
+ "basename",
57
+ "cat",
58
+ "cksum",
59
+ "column",
60
+ "comm",
61
+ "cut",
62
+ "date",
63
+ "diff",
64
+ "dirname",
65
+ "du",
66
+ "echo",
67
+ "false",
68
+ "file",
69
+ "fold",
70
+ "head",
71
+ "hostname",
72
+ "id",
73
+ "join",
74
+ "jq",
75
+ "ls",
76
+ "md5sum",
77
+ "nl",
78
+ "od",
79
+ "paste",
80
+ "printenv",
81
+ "printf",
82
+ "pwd",
83
+ "readlink",
84
+ "realpath",
85
+ "rev",
86
+ "rg",
87
+ "sha1sum",
88
+ "sha256sum",
89
+ "shasum",
90
+ "sort",
91
+ "stat",
92
+ "tail",
93
+ "tr",
94
+ "tree",
95
+ "true",
96
+ "uname",
97
+ "uniq",
98
+ "wc",
99
+ "which",
100
+ "whoami",
101
+ "yes",
102
+ ]);
103
+
104
+ /**
105
+ * `grep` and friends take a pattern before their paths, so the first positional
106
+ * is not a path token. Tracked separately to avoid classifying a regex as a file.
107
+ */
108
+ const PATTERN_FIRST_COMMANDS = new Set(["grep", "egrep", "fgrep", "rg", "ag", "ack"]);
109
+
110
+ /**
111
+ * Commands that run whatever follows them. The original only knew three of
112
+ * these and never looked past them, so `env rm -rf ~/Desktop` classified as a
113
+ * harmless `env` (review finding N5). Every one of these is peeled and the
114
+ * payload command is classified instead.
115
+ */
116
+ const TRANSPARENT_WRAPPERS = new Set([
117
+ "command",
118
+ "env",
119
+ "flock",
120
+ "ionice",
121
+ "nice",
122
+ "nohup",
123
+ "script",
124
+ "setsid",
125
+ "stdbuf",
126
+ "time",
127
+ "timeout",
128
+ "xargs",
129
+ ]);
130
+
131
+ /** Options of wrapper commands that consume the following token as a value. */
132
+ const WRAPPER_VALUE_OPTIONS = new Set(["-u", "-c", "-n", "-I", "-L", "-P", "--signal", "-s", "-k"]);
133
+
134
+ /**
135
+ * Anything here writes, deletes, or fetches-and-writes. The list is only used to
136
+ * *escalate* — a command missing from it still escalates via the unknown-command
137
+ * path, which is why the original's omissions (tar, rsync, zip — N6) are not
138
+ * exploitable here. It is kept current anyway so the evidence names the risk.
139
+ */
140
+ const MUTATION_COMMANDS = new Set([
141
+ "7z",
142
+ "cp",
143
+ "dd",
144
+ "gunzip",
145
+ "gzip",
146
+ "install",
147
+ "ln",
148
+ "mkdir",
149
+ "mv",
150
+ "patch",
151
+ "rm",
152
+ "rmdir",
153
+ "rsync",
154
+ "shred",
155
+ "tar",
156
+ "tee",
157
+ "touch",
158
+ "truncate",
159
+ "unzip",
160
+ "zip",
161
+ ]);
162
+
163
+ /**
164
+ * Interpreters whose *scripts* can write files the token-level pass never sees
165
+ * (review finding N8: `awk 'BEGIN{print > "f"}'`). Their programs are not
166
+ * parsed — they escalate unconditionally.
167
+ */
168
+ const SCRIPT_INTERPRETERS = new Set([
169
+ "awk",
170
+ "bash",
171
+ "deno",
172
+ "gawk",
173
+ "node",
174
+ "perl",
175
+ "php",
176
+ "python",
177
+ "python3",
178
+ "ruby",
179
+ "sed",
180
+ "sh",
181
+ "tclsh",
182
+ "zsh",
183
+ ]);
184
+
185
+ const NETWORK_COMMANDS = new Set([
186
+ "curl",
187
+ "dig",
188
+ "ftp",
189
+ "host",
190
+ "nc",
191
+ "netcat",
192
+ "nmap",
193
+ "nslookup",
194
+ "ping",
195
+ "rsync",
196
+ "scp",
197
+ "sftp",
198
+ "ssh",
199
+ "telnet",
200
+ "traceroute",
201
+ "wget",
202
+ ]);
203
+
204
+ /**
205
+ * git is **default-deny by subcommand**: only these read-only subcommands can be
206
+ * fast-pathed, and everything else escalates. The original enumerated *mutating*
207
+ * subcommands and allowed the rest, so `git rm`, `git mv`, `git archive`,
208
+ * `git config`, and `git update-ref` all slipped through (review finding N11).
209
+ * Enumerating the safe set instead means a git subcommand added upstream
210
+ * tomorrow escalates rather than being silently permitted.
211
+ */
212
+ const GIT_READ_ONLY_SUBCOMMANDS = new Set([
213
+ "blame",
214
+ "branch", // listing; mutation forms carry flags, and any flag we do not model escalates
215
+ "cat-file",
216
+ "describe",
217
+ "diff",
218
+ "grep",
219
+ "log",
220
+ "ls-files",
221
+ "ls-remote",
222
+ "ls-tree",
223
+ "rev-parse",
224
+ "shortlog",
225
+ "show",
226
+ "show-ref",
227
+ "status",
228
+ "tag", // listing only, same reasoning as branch
229
+ ]);
230
+
231
+ /**
232
+ * git global flags that take a value. `git -c k=v <sub>` can turn a read into
233
+ * code execution (`git -c protocol.ext.allow=always clone ext::sh …` — review
234
+ * finding F1, reproduced live there), so `-c`/`--config-env` are *not* skipped
235
+ * silently: seeing either escalates.
236
+ */
237
+ const GIT_GLOBAL_VALUE_FLAGS = new Set(["-C", "--git-dir", "--work-tree", "--namespace", "--exec-path"]);
238
+
239
+ interface Token {
240
+ /** The token with quotes removed and `$'…'` decoded. */
241
+ value: string;
242
+ /** True when the token was written with any quoting or expansion syntax. */
243
+ hadExpansion: boolean;
244
+ }
245
+
246
+ interface Segment {
247
+ tokens: Token[];
248
+ /** Redirection targets found in this segment, in written order. */
249
+ redirects: string[];
250
+ raw: string;
251
+ }
252
+
253
+ /** Decode the escapes bash understands inside `$'…'`. */
254
+ function decodeAnsiC(body: string): string {
255
+ return body.replace(/\\(n|t|r|\\|'|"|a|b|f|v|0|x[0-9a-fA-F]{1,2})/g, (match, escape: string) => {
256
+ switch (escape) {
257
+ case "n":
258
+ return "\n";
259
+ case "t":
260
+ return "\t";
261
+ case "r":
262
+ return "\r";
263
+ case "\\":
264
+ return "\\";
265
+ case "'":
266
+ return "'";
267
+ case '"':
268
+ return '"';
269
+ case "a":
270
+ return "\x07";
271
+ case "b":
272
+ return "\b";
273
+ case "f":
274
+ return "\f";
275
+ case "v":
276
+ return "\v";
277
+ case "0":
278
+ return "\0";
279
+ default:
280
+ return escape.startsWith("x") ? String.fromCharCode(Number.parseInt(escape.slice(1), 16)) : match;
281
+ }
282
+ });
283
+ }
284
+
285
+ /** Syntax we do not model at all; its presence alone forces escalation. */
286
+ export function hasUnmodelledSyntax(command: string): string | undefined {
287
+ if (command.includes("\n")) return "spans multiple lines";
288
+ if (/\$\(/.test(command)) return "uses command substitution $( )";
289
+ if (/(^|[^\\])`/.test(command)) return "uses backtick command substitution";
290
+ if (/<<</.test(command)) return "uses a here-string";
291
+ if (/<</.test(command)) return "uses a heredoc";
292
+ if (/[<>]\(/.test(command)) return "uses process substitution";
293
+ // Brace expansion resolves to paths we cannot enumerate (review finding N3).
294
+ if (/\{[^{}]*,[^{}]*\}/.test(command)) return "uses brace expansion, whose expanded paths cannot be checked";
295
+ if (/\$\{?[A-Za-z_]/.test(command)) return "references environment variables, whose values are unknown here";
296
+ if (/\beval\b|\bexec\b/.test(command)) return "uses eval/exec";
297
+ if (/\|\s*(bash|sh|zsh|python|perl|node|ruby)\b/.test(command)) return "pipes into an interpreter";
298
+ if (/base64\s+(-d|--decode)/.test(command)) return "decodes base64, which can hide the real command";
299
+ return undefined;
300
+ }
301
+
302
+ /**
303
+ * Split a command into pipeline/list segments and tokenize each. Unlike the
304
+ * original, `<`, `>`, `&`, and `|` terminate a token even without surrounding
305
+ * whitespace, so `cmd>file` and `a|b` are seen (review finding N4).
306
+ */
307
+ export function parseCommand(command: string): { segments: Segment[]; parseFailed: boolean } {
308
+ const segments: Segment[] = [];
309
+ let tokens: Token[] = [];
310
+ let redirects: string[] = [];
311
+ let rawStart = 0;
312
+
313
+ let current = "";
314
+ let hadExpansion = false;
315
+ let quoted = false;
316
+ let inSingle = false;
317
+ let inDouble = false;
318
+ let escape = false;
319
+ /** Set while consuming the token that follows a redirection operator. */
320
+ let pendingRedirect = false;
321
+
322
+ const pushToken = () => {
323
+ if (!current && !quoted) return;
324
+ if (pendingRedirect) {
325
+ redirects.push(current);
326
+ pendingRedirect = false;
327
+ } else {
328
+ tokens.push({ value: current, hadExpansion });
329
+ }
330
+ current = "";
331
+ hadExpansion = false;
332
+ quoted = false;
333
+ };
334
+
335
+ const pushSegment = (at: number) => {
336
+ pushToken();
337
+ if (tokens.length > 0 || redirects.length > 0) {
338
+ segments.push({ tokens, redirects, raw: command.slice(rawStart, at).trim() });
339
+ }
340
+ tokens = [];
341
+ redirects = [];
342
+ rawStart = at + 1;
343
+ };
344
+
345
+ for (let i = 0; i < command.length; i++) {
346
+ const ch = command[i];
347
+
348
+ if (escape) {
349
+ current += ch;
350
+ escape = false;
351
+ continue;
352
+ }
353
+ if (ch === "\\" && !inSingle) {
354
+ escape = true;
355
+ continue;
356
+ }
357
+ if (ch === "'" && !inDouble) {
358
+ inSingle = !inSingle;
359
+ quoted = true;
360
+ continue;
361
+ }
362
+ if (ch === '"' && !inSingle) {
363
+ inDouble = !inDouble;
364
+ quoted = true;
365
+ continue;
366
+ }
367
+ // ANSI-C quoting: `$'\x2e\x2e'` is `..`. The original kept the leading `$`,
368
+ // which made the token look like a variable and skipped every path check
369
+ // (review finding N1, reproduced there).
370
+ if (ch === "$" && command[i + 1] === "'" && !inSingle && !inDouble) {
371
+ const end = command.indexOf("'", i + 2);
372
+ if (end === -1) return { segments: [], parseFailed: true };
373
+ current += decodeAnsiC(command.slice(i + 2, end));
374
+ quoted = true;
375
+ hadExpansion = true;
376
+ i = end;
377
+ continue;
378
+ }
379
+
380
+ if (inSingle || inDouble) {
381
+ current += ch;
382
+ continue;
383
+ }
384
+
385
+ if (/\s/.test(ch)) {
386
+ pushToken();
387
+ continue;
388
+ }
389
+
390
+ // Redirection. `>|` is the clobber-override form and behaves as `>`
391
+ // (review finding N10); `2>&1` and `&>` are duplications, not paths.
392
+ if (ch === ">" || ch === "<") {
393
+ pushToken();
394
+ let j = i + 1;
395
+ if (command[j] === ">" || command[j] === "|") j++;
396
+ if (command[j] === "&") {
397
+ // fd duplication — consume the fd and move on, no path involved.
398
+ j++;
399
+ while (j < command.length && /[0-9-]/.test(command[j])) j++;
400
+ i = j - 1;
401
+ continue;
402
+ }
403
+ pendingRedirect = ch === ">";
404
+ i = j - 1;
405
+ continue;
406
+ }
407
+
408
+ if (ch === "|" || ch === ";" || ch === "&") {
409
+ // `&&`, `||`, `;`, `|`, `&` all end a segment. Any of them means the
410
+ // next command is separate, which is all we need to know.
411
+ pushSegment(i);
412
+ if (command[i + 1] === ch) i++;
413
+ continue;
414
+ }
415
+
416
+ current += ch;
417
+ }
418
+
419
+ if (escape || inSingle || inDouble) return { segments: [], parseFailed: true };
420
+ pushSegment(command.length);
421
+ return { segments, parseFailed: false };
422
+ }
423
+
424
+ /** Peel wrappers to the command that actually runs (review finding N5). */
425
+ function resolvePayload(tokens: Token[]): { command: string; args: Token[]; peeled: string[] } {
426
+ const peeled: string[] = [];
427
+ let index = 0;
428
+
429
+ for (;;) {
430
+ // Leading `VAR=value` assignments are not the command.
431
+ while (index < tokens.length && /^[A-Za-z_][A-Za-z0-9_]*=/.test(tokens[index].value)) index++;
432
+ if (index >= tokens.length) return { command: "", args: [], peeled };
433
+
434
+ const name = commandName(tokens[index].value);
435
+ if (!TRANSPARENT_WRAPPERS.has(name)) {
436
+ return { command: name, args: tokens.slice(index + 1), peeled };
437
+ }
438
+
439
+ peeled.push(name);
440
+ index++;
441
+ // Step over the wrapper's own flags and their values.
442
+ while (index < tokens.length && tokens[index].value.startsWith("-")) {
443
+ const flag = tokens[index].value;
444
+ index++;
445
+ if (WRAPPER_VALUE_OPTIONS.has(flag) && index < tokens.length) index++;
446
+ }
447
+ // `timeout 30 cmd` — a bare duration is not the payload.
448
+ while (index < tokens.length && /^[0-9]+[smhd]?$/.test(tokens[index].value)) index++;
449
+ }
450
+ }
451
+
452
+ /** Basename of a command token, `.exe` stripped, lowercased. */
453
+ function commandName(token: string): string {
454
+ const withForwardSlashes = token.trim().toLowerCase().replace(/\\/g, "/");
455
+ const base = withForwardSlashes.slice(withForwardSlashes.lastIndexOf("/") + 1);
456
+ return base.endsWith(".exe") ? base.slice(0, -4) : base;
457
+ }
458
+
459
+ /**
460
+ * Whether a token names a path. Unlike the original, bare `.` and `..` count
461
+ * (review finding N2) — `rm -rf ..` deletes the parent directory and the
462
+ * original saw no path at all.
463
+ */
464
+ function looksLikePath(value: string): boolean {
465
+ if (!value || value.startsWith("-")) return false;
466
+ if (value.includes("://")) return false;
467
+ if (/^[A-Za-z_][A-Za-z0-9_]*=/.test(value)) return false;
468
+ if (value === "." || value === "..") return true;
469
+ if (value === "~" || value.startsWith("~/")) return true;
470
+ return value.startsWith("/") || value.includes("/");
471
+ }
472
+
473
+ /** `find` actions that execute or write (review finding N7 adds the last four). */
474
+ function findHasAction(args: Token[]): boolean {
475
+ return args.some(({ value }) =>
476
+ ["-delete", "-exec", "-execdir", "-ok", "-okdir", "-fprintf", "-fls", "-fprint", "-fprint0"].includes(value),
477
+ );
478
+ }
479
+
480
+ /** The file named by a `-o FILE` / `-oFILE` / `--output=FILE` flag, if present. */
481
+ function outputFlagTarget(args: Token[]): string | undefined {
482
+ for (let i = 0; i < args.length; i++) {
483
+ const value = args[i].value;
484
+ if (value === "-o" || value === "--output") return args[i + 1]?.value;
485
+ if (value.startsWith("--output=")) return value.slice("--output=".length);
486
+ if (value.startsWith("-o") && value.length > 2) return value.slice(2);
487
+ }
488
+ return undefined;
489
+ }
490
+
491
+ /**
492
+ * Decide git: only an explicitly read-only subcommand with no flags we cannot
493
+ * account for is safe. Returns undefined when safe, else the reason to escalate.
494
+ */
495
+ function gitEscalationReason(args: Token[], isDirOutsideCwd: (dir: string) => boolean): string | undefined {
496
+ let index = 0;
497
+ while (index < args.length) {
498
+ const token = args[index].value;
499
+ if (!token.startsWith("-")) break;
500
+ // `-c key=value` can reconfigure git into executing an arbitrary helper.
501
+ if (token === "-c" || token.startsWith("-c=") || token === "--config-env") {
502
+ return "passes git -c/--config-env, which can turn a read into code execution";
503
+ }
504
+ if (GIT_GLOBAL_VALUE_FLAGS.has(token)) {
505
+ const value = args[index + 1]?.value;
506
+ // `-C`/`--git-dir`/`--work-tree` retarget git at another directory. If that
507
+ // directory escapes the working directory the operation is no longer
508
+ // provably in-project, so escalate rather than skipping the flag blindly
509
+ // (was review gap: `git -C /etc status` classified safe).
510
+ if ((token === "-C" || token === "--git-dir" || token === "--work-tree") && value && isDirOutsideCwd(value)) {
511
+ return `runs git ${token} ${value}, which points outside the working directory`;
512
+ }
513
+ index += 2;
514
+ continue;
515
+ }
516
+ index++;
517
+ }
518
+ const subcommand = args[index]?.value?.toLowerCase();
519
+ if (!subcommand) return undefined; // bare `git` prints help
520
+ if (!GIT_READ_ONLY_SUBCOMMANDS.has(subcommand)) return `runs git ${subcommand}, which is not a read-only subcommand`;
521
+ return undefined;
522
+ }
523
+
524
+ export interface AnalyzeInput {
525
+ command: string;
526
+ cwd: string;
527
+ home: string;
528
+ }
529
+
530
+ /**
531
+ * Classify a shell command. Never denies — see the module contract above.
532
+ */
533
+ export function analyzeShellCommand({ command, cwd, home }: AnalyzeInput): ShellEvidence {
534
+ const evidence: ShellEvidence = {
535
+ verdict: "safe",
536
+ notes: [],
537
+ commands: [],
538
+ writes: [],
539
+ sensitivePaths: [],
540
+ executionPrimitives: [],
541
+ network: [],
542
+ };
543
+ const escalate = (note: string) => {
544
+ evidence.verdict = "escalate";
545
+ if (!evidence.notes.includes(note)) evidence.notes.push(note);
546
+ };
547
+
548
+ const trimmed = command.trim();
549
+ if (!trimmed) return { ...evidence, verdict: "escalate", notes: ["empty command"] };
550
+
551
+ const unmodelled = hasUnmodelledSyntax(trimmed);
552
+ if (unmodelled) escalate(unmodelled);
553
+
554
+ const { segments, parseFailed } = parseCommand(trimmed);
555
+ if (parseFailed) {
556
+ escalate("could not be parsed (unbalanced quotes), so nothing about it is known");
557
+ return evidence;
558
+ }
559
+
560
+ /**
561
+ * Containment is checked against the *resolved* working directory. Write
562
+ * targets come back realpath'd, and on macOS the working directory often sits
563
+ * under a symlinked root (`/var/folders/…` → `/private/var/folders/…`), so
564
+ * comparing a resolved target against an unresolved base reports every
565
+ * in-project write as an escape.
566
+ */
567
+ const containmentRoot = resolveForContainment(cwd) ?? cwd;
568
+
569
+ /** `cd` changes what later relative paths mean; the original never tracked it (F6/N12). */
570
+ let effectiveCwd = cwd;
571
+
572
+ /**
573
+ * Resolve a write-target token, record it as evidence, and escalate on any
574
+ * target that cannot be resolved, escapes the working directory, or names a
575
+ * credential / execution-primitive path. Shared by redirections, mutating
576
+ * command positionals, and output-flag targets so all writes get one check.
577
+ */
578
+ const checkWriteTarget = (token: string) => {
579
+ if (token === "/dev/null") return;
580
+ const absolute = toAbsolute(effectiveCwd, token, home);
581
+ const resolved = resolveForContainment(absolute);
582
+ const outsideCwd = resolved === undefined || !isWithin(containmentRoot, resolved);
583
+ evidence.writes.push({ token, resolved, outsideCwd });
584
+ if (resolved === undefined) {
585
+ escalate(`writes to ${token}, which could not be resolved to a real path`);
586
+ } else if (outsideCwd) {
587
+ escalate(`writes to ${token}, which is outside the working directory`);
588
+ }
589
+ if (isSensitivePath(absolute) && !evidence.sensitivePaths.includes(token)) {
590
+ evidence.sensitivePaths.push(token);
591
+ escalate(`writes to ${token}, a credential or secret path`);
592
+ }
593
+ if (isExecutionPrimitivePath(absolute) && !evidence.executionPrimitives.includes(token)) {
594
+ evidence.executionPrimitives.push(token);
595
+ escalate(`writes to ${token}, whose contents execute later without further approval`);
596
+ }
597
+ };
598
+
599
+ for (const segment of segments) {
600
+ // Redirection targets are writes regardless of the command word: a bare
601
+ // `> file` truncates/creates it with no command at all, and `git log > file`
602
+ // writes it too. Check them first so the command-specific `continue`s below
603
+ // (cd/git) can never skip a redirect (was review gap: bare-redirect writes and
604
+ // read-only-command redirects were fast-pathed to "safe").
605
+ for (const token of segment.redirects) checkWriteTarget(token);
606
+
607
+ const { command: name, args, peeled } = resolvePayload(segment.tokens);
608
+ if (!name) continue;
609
+ evidence.commands.push(name);
610
+ if (peeled.length > 0) {
611
+ escalate(`wraps the real command in ${peeled.join(" → ")}, so ${name} is what actually runs`);
612
+ }
613
+
614
+ if (NETWORK_COMMANDS.has(name)) {
615
+ evidence.network.push(name);
616
+ escalate(`runs ${name}, which can reach the network`);
617
+ }
618
+
619
+ // Every positional token is checked against the credential denylist,
620
+ // whether or not it looks like a path: `cd .kube && cat config` reads
621
+ // `~/.kube/config` through two tokens that each look harmless (N12).
622
+ for (const { value } of args) {
623
+ if (!value || value.startsWith("-")) continue;
624
+ const absolute = toAbsolute(effectiveCwd, value, home);
625
+ if (isSensitivePath(value) || isSensitivePath(absolute)) {
626
+ // The *original* token, never the resolved/expanded form (N18).
627
+ if (!evidence.sensitivePaths.includes(value)) evidence.sensitivePaths.push(value);
628
+ escalate(`names ${value}, which is a credential or secret path`);
629
+ }
630
+ if (isExecutionPrimitivePath(absolute)) {
631
+ if (!evidence.executionPrimitives.includes(value)) evidence.executionPrimitives.push(value);
632
+ escalate(`touches ${value}, whose contents execute later without further approval`);
633
+ }
634
+ }
635
+
636
+ if (name === "cd") {
637
+ const target = args.find((token) => !token.value.startsWith("-"))?.value;
638
+ if (target) {
639
+ effectiveCwd = toAbsolute(effectiveCwd, target, home);
640
+ escalate(`changes directory to ${target}, so later paths in this command resolve elsewhere`);
641
+ }
642
+ continue;
643
+ }
644
+
645
+ if (name === "git") {
646
+ const reason = gitEscalationReason(args, (dir) => {
647
+ const resolved = resolveForContainment(toAbsolute(effectiveCwd, dir, home));
648
+ return resolved === undefined || !isWithin(containmentRoot, resolved);
649
+ });
650
+ if (reason) escalate(reason);
651
+ continue;
652
+ }
653
+
654
+ if (SCRIPT_INTERPRETERS.has(name)) {
655
+ escalate(`runs ${name}, whose script can read and write files this check cannot see`);
656
+ }
657
+
658
+ if (name === "find" && findHasAction(args)) {
659
+ escalate("uses a find action that executes commands or writes files");
660
+ }
661
+
662
+ const isMutation = MUTATION_COMMANDS.has(name);
663
+ if (isMutation) escalate(`runs ${name}, which modifies the filesystem`);
664
+
665
+ if (!isMutation && !READ_ONLY_COMMANDS.has(name) && !PATTERN_FIRST_COMMANDS.has(name) && name !== "find") {
666
+ escalate(`runs ${name}, which is not on the read-only allowlist`);
667
+ }
668
+
669
+ // The positional destinations of writing commands are the paths that get
670
+ // written. (Redirections were already handled at the top of the loop.)
671
+ const writeTokens: string[] = [];
672
+ if (isMutation) {
673
+ const positionals = args.filter((token) => !token.value.startsWith("-") && looksLikePath(token.value));
674
+ // For cp/mv/rsync/ln the destination is last; for the rest every
675
+ // positional is a candidate target.
676
+ if (["cp", "mv", "rsync", "ln", "install"].includes(name)) {
677
+ const last = positionals[positionals.length - 1];
678
+ if (last) writeTokens.push(last.value);
679
+ } else {
680
+ writeTokens.push(...positionals.map((token) => token.value));
681
+ }
682
+ for (const { value } of args) {
683
+ if (value.startsWith("of=")) writeTokens.push(value.slice(3)); // dd
684
+ }
685
+ }
686
+
687
+ // A few otherwise-read-only commands write when handed an output flag.
688
+ // `sort -o FILE` / `sort --output=FILE` truncates/creates FILE, so it must
689
+ // not be fast-pathed as a pure read (was review gap: `sort -o` classified safe).
690
+ if (name === "sort") {
691
+ const outTarget = outputFlagTarget(args);
692
+ if (outTarget !== undefined) {
693
+ escalate("runs sort with -o/--output, which writes a file");
694
+ writeTokens.push(outTarget);
695
+ }
696
+ }
697
+
698
+ for (const token of writeTokens) checkWriteTarget(token);
699
+ }
700
+
701
+ return evidence;
702
+ }