@saadnvd1/agent-os 0.3.489 → 0.3.494

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 (38) hide show
  1. package/README.md +38 -2
  2. package/app/api/sessions/[id]/fresh/route.ts +31 -0
  3. package/components/Chat/ChatPanel.tsx +19 -3
  4. package/components/Chat/Tools.tsx +9 -3
  5. package/components/Pane/index.tsx +4 -0
  6. package/components/SessionList/OrchestratorPinRow.test.ts +3 -0
  7. package/components/SessionList/OrchestratorPinRow.tsx +36 -0
  8. package/components/SessionList/RowContext.tsx +5 -0
  9. package/components/SessionList/SessionRow.tsx +17 -4
  10. package/components/SessionList/SessionRowMenu.tsx +10 -0
  11. package/components/SessionList/SessionShelves.tsx +8 -2
  12. package/components/SessionList/SidebarBody.tsx +17 -1
  13. package/components/SessionList/index.tsx +23 -3
  14. package/components/SessionList/useSidebarData.ts +64 -18
  15. package/data/chat/useChat.ts +12 -1
  16. package/data/sessions/fresh.ts +20 -0
  17. package/data/sessions/index.ts +1 -0
  18. package/lib/chat/bodyCache.ts +69 -0
  19. package/lib/chat/commands.ts +16 -0
  20. package/lib/chat/refresh.ts +18 -0
  21. package/lib/chat/registry.ts +2 -0
  22. package/lib/chat/runner.ts +50 -0
  23. package/lib/chat/store.ts +89 -0
  24. package/lib/chat/worker/host.ts +46 -12
  25. package/lib/chat/worker/main.ts +1 -1
  26. package/lib/chat/worker/protocol.ts +4 -0
  27. package/lib/orchestrator/brief.ts +1 -1
  28. package/lib/orchestrator/shell-guard.ts +536 -0
  29. package/lib/orchestrator/shell-parse.ts +289 -0
  30. package/lib/orchestrator/tool-names.ts +13 -22
  31. package/lib/orchestrator/write-guard.ts +54 -57
  32. package/lib/sidebar/queued.ts +3 -0
  33. package/lib/sidebar/rollup.ts +58 -0
  34. package/lib/sidebar/shelves.ts +24 -0
  35. package/lib/tasks/queue.ts +3 -0
  36. package/package.json +1 -1
  37. package/stores/sidebarUi.ts +15 -3
  38. package/lib/orchestrator/git-guard.ts +0 -201
@@ -0,0 +1,536 @@
1
+ import fs from "fs";
2
+ import os from "os";
3
+ import path from "path";
4
+ import { isSecretPath } from "../secret-paths";
5
+ import { TOOL_NAMES } from "./tool-names";
6
+ import { parseShell, UNKNOWN, type SimpleCommand } from "./shell-parse";
7
+
8
+ /**
9
+ * The orchestrator's shell runs anything (Saad's call, 2026-10-11), except
10
+ * this deny list: merging, closing or retargeting a PR outside the gates,
11
+ * pushing, writing in a project repository or task worktree other than its
12
+ * own quick changes, writing AgentOS's own state, stopping AgentOS or its
13
+ * tmux, and reading secrets. Each is caught in its plain shape and the
14
+ * common wrappers (`bash -c`, `env`, `sudo`, `xargs`, `find -exec`, an
15
+ * absolute path, `git -C`, inside pipes, chains and substitutions), and
16
+ * refused with the tool to use instead. It catches honest mistakes, not a
17
+ * determined bypass: `python -c` or a script can do any of these unseen.
18
+ */
19
+
20
+ export interface ShellContext {
21
+ // Where the shell starts; a `cd` in the line moves it.
22
+ cwd: string;
23
+ // Why a write to this path is refused (a project repository or task
24
+ // worktree that isn't its own quick change, AgentOS's state outside its
25
+ // folder), or null.
26
+ writeRefusal: (p: string) => Promise<string | null>;
27
+ // Whether this file is AgentOS's database or other state of its own.
28
+ isAgentOsState: (p: string) => Promise<boolean>;
29
+ // AgentOS's server and the tmux servers it runs sessions in.
30
+ protectedPids: () => Promise<Set<number>>;
31
+ }
32
+
33
+ interface Invocation {
34
+ name: string;
35
+ args: string[];
36
+ cmd: SimpleCommand;
37
+ }
38
+
39
+ const T = TOOL_NAMES;
40
+
41
+ const has = (w: string) => w.includes(UNKNOWN);
42
+ const show = (w: string) => w.replaceAll(UNKNOWN, "$(…)");
43
+
44
+ // Commands that only run the rest of their arguments, and their options
45
+ // that take a value.
46
+ const WRAPPERS: Record<string, string[]> = {
47
+ env: ["-u", "--unset", "-C", "--chdir"],
48
+ sudo: ["-u", "-g", "-C", "-h", "-p", "-U", "-r", "-t", "-T", "-D"],
49
+ command: [],
50
+ builtin: [],
51
+ exec: ["-a"],
52
+ nohup: [],
53
+ time: [],
54
+ noglob: [],
55
+ nice: ["-n"],
56
+ stdbuf: ["-i", "-o", "-e"],
57
+ caffeinate: ["-t", "-w"],
58
+ timeout: ["-s", "--signal", "-k", "--kill-after"],
59
+ gtimeout: ["-s", "--signal", "-k", "--kill-after"],
60
+ xargs: ["-I", "-n", "-P", "-L", "-d", "-E", "-s", "-a", "--max-args"],
61
+ watch: ["-n", "--interval"],
62
+ };
63
+
64
+ const KEYWORDS = new Set([
65
+ "if",
66
+ "then",
67
+ "else",
68
+ "elif",
69
+ "fi",
70
+ "do",
71
+ "done",
72
+ "while",
73
+ "until",
74
+ "!",
75
+ "{",
76
+ "}",
77
+ ]);
78
+
79
+ const SHELLS = /^(ba|z|da|k|fi|c|tc)?sh$/;
80
+
81
+ // What a simple command runs once its wrappers are taken off: one
82
+ // invocation, or several (`bash -c '…'`, `find -exec`).
83
+ function invocations(cmd: SimpleCommand, depth = 0): Invocation[] | string {
84
+ let words = cmd.words;
85
+ for (;;) {
86
+ while (
87
+ words.length &&
88
+ (KEYWORDS.has(words[0]) || /^[A-Za-z_][A-Za-z0-9_]*=/.test(words[0]))
89
+ )
90
+ words = words.slice(1);
91
+ if (!words.length) return [];
92
+ const name = path.basename(words[0]);
93
+ const rest = words.slice(1);
94
+ if (SHELLS.test(name) || name === "eval" || name === "watch") {
95
+ const script = shellScript(name, rest);
96
+ if (script === null) return [{ name, args: rest, cmd }];
97
+ return nested(script, cmd, depth);
98
+ }
99
+ if (name === "env") {
100
+ const s = rest.findIndex((w) => w === "-S" || w === "--split-string");
101
+ if (s >= 0) return nested(rest.slice(s + 1).join(" "), cmd, depth);
102
+ }
103
+ const opts = WRAPPERS[name];
104
+ if (opts) {
105
+ let i = 0;
106
+ for (; i < rest.length && rest[i].startsWith("-"); i++)
107
+ if (opts.includes(rest[i])) i++;
108
+ if (name === "timeout" || name === "gtimeout") i++;
109
+ words = rest.slice(i);
110
+ // xargs adds arguments that come from its input.
111
+ if (name === "xargs" && words.length) words = [...words, UNKNOWN];
112
+ continue;
113
+ }
114
+ if (name === "find") return findInvocations(rest, cmd, depth);
115
+ return [{ name, args: rest, cmd }];
116
+ }
117
+ }
118
+
119
+ function shellScript(name: string, args: string[]): string | null {
120
+ if (name === "eval") return args.join(" ");
121
+ if (name === "watch") {
122
+ let i = 0;
123
+ for (; i < args.length && args[i].startsWith("-"); i++)
124
+ if (WRAPPERS.watch.includes(args[i])) i++;
125
+ return args.slice(i).join(" ") || null;
126
+ }
127
+ const c = args.findIndex((a) => /^-[a-zA-Z]*c[a-zA-Z]*$/.test(a));
128
+ if (c < 0) return null;
129
+ return args.slice(c + 1).find((a) => !a.startsWith("-")) ?? "";
130
+ }
131
+
132
+ function nested(
133
+ script: string,
134
+ cmd: SimpleCommand,
135
+ depth: number
136
+ ): Invocation[] | string {
137
+ if (depth > 8) return "commands nested too deeply to check";
138
+ const parsed = parseShell(script, depth + 1);
139
+ if (typeof parsed === "string") return parsed;
140
+ const out: Invocation[] = [];
141
+ for (const inner of parsed) {
142
+ const r = invocations(
143
+ {
144
+ ...inner,
145
+ writes: [...inner.writes, ...cmd.writes],
146
+ stdin: inner.stdin + cmd.stdin,
147
+ },
148
+ depth + 1
149
+ );
150
+ if (typeof r === "string") return r;
151
+ out.push(...r);
152
+ }
153
+ return out;
154
+ }
155
+
156
+ // find's -exec commands, and -delete as an rm of the folders it searches.
157
+ function findInvocations(
158
+ args: string[],
159
+ cmd: SimpleCommand,
160
+ depth: number
161
+ ): Invocation[] | string {
162
+ const out: Invocation[] = [{ name: "find", args, cmd }];
163
+ const first = args.findIndex((a) => /^[-(!]/.test(a));
164
+ const roots = args.slice(0, first < 0 ? args.length : first);
165
+ if (args.includes("-delete")) out.push({ name: "rm", args: roots, cmd });
166
+ for (let i = 0; i < args.length; i++) {
167
+ if (!/^-(exec|execdir|ok|okdir)$/.test(args[i])) continue;
168
+ const end = args.findIndex((a, j) => j > i && (a === ";" || a === "+"));
169
+ const words = args.slice(i + 1, end < 0 ? args.length : end);
170
+ const r = invocations({ ...cmd, words, writes: [] }, depth + 1);
171
+ if (typeof r === "string") return r;
172
+ out.push(...r);
173
+ i = end < 0 ? args.length : end;
174
+ }
175
+ return out;
176
+ }
177
+
178
+ // Words that aren't options; `--` ends options. `values` take the next word.
179
+ function positionals(args: string[], values: string[] = []): string[] {
180
+ const out: string[] = [];
181
+ for (let i = 0; i < args.length; i++) {
182
+ const a = args[i];
183
+ if (a === "--") return [...out, ...args.slice(i + 1)];
184
+ if (a.startsWith("-") && a.length > 1) {
185
+ if (values.includes(a)) i++;
186
+ continue;
187
+ }
188
+ out.push(a);
189
+ }
190
+ return out;
191
+ }
192
+
193
+ // An option's values, given as `-X v`, `-Xv`, `--opt v` or `--opt=v`.
194
+ function optionValues(args: string[], short: string[], long: string[]) {
195
+ const out: string[] = [];
196
+ for (let i = 0; i < args.length; i++) {
197
+ const a = args[i];
198
+ if ([...short, ...long].includes(a)) out.push(args[++i] ?? "");
199
+ else if (long.some((l) => a.startsWith(`${l}=`)))
200
+ out.push(a.slice(a.indexOf("=") + 1));
201
+ else if (short.some((s) => a.startsWith(s) && a.length > s.length))
202
+ out.push(a.slice(2));
203
+ }
204
+ return out;
205
+ }
206
+
207
+ const MERGE_MUTATION =
208
+ /\b(mergePullRequest|closePullRequest|updatePullRequest|updatePullRequestBranch|enablePullRequestAutoMerge|mergeBranch)\b/;
209
+ const PULL = /(^|\/)pulls\/[^/?\s]+/;
210
+ const PULL_MERGE = /(^|\/)pulls\/[^/?\s]+\/merge\/?(\?|$)/;
211
+
212
+ const MERGE_MSG = `Refused: a PR merges only through the gates: ${T.sign_off} (or ${T.arm}) for a task's or an external PR, ${T.land} for a stack. Nothing merges, closes or retargets a PR from the shell.`;
213
+
214
+ // gh: pr merge, close and edit --base, and api calls that merge or change a
215
+ // pull request.
216
+ function ghRefusal(args: string[]): string | null {
217
+ const values = [
218
+ "-R",
219
+ "--repo",
220
+ "-X",
221
+ "--method",
222
+ "-H",
223
+ "--header",
224
+ "-f",
225
+ "-F",
226
+ "--field",
227
+ "--raw-field",
228
+ "--input",
229
+ "-q",
230
+ "--jq",
231
+ "-t",
232
+ "--template",
233
+ "--hostname",
234
+ "-p",
235
+ "--preview",
236
+ "--cache",
237
+ ];
238
+ const [group, sub] = positionals(args, values);
239
+ if (group === "pr" && sub === "merge") return MERGE_MSG;
240
+ if (group === "pr" && sub === "close")
241
+ return `Refused: close a task's PR with ${T.drop}, which also removes its worktree; nothing closes a PR from the shell.`;
242
+ if (
243
+ group === "pr" &&
244
+ sub === "edit" &&
245
+ optionValues(args, ["-B"], ["--base"]).length
246
+ )
247
+ return `Refused: retargeting a PR changes what its gates judged; a stack sets its own bases (${T.stack}), and anything else is Saad's call (${T.ask_saad}).`;
248
+ if (group !== "api") return null;
249
+ const endpoint = positionals(args, values)[1] ?? "";
250
+ const method = optionValues(args, ["-X"], ["--method"]).pop();
251
+ const fields = args.some((a) =>
252
+ /^(-f|-F|--field|--raw-field|--input)(=|$)|^-[fF]./.test(a)
253
+ );
254
+ const effective = (method ?? (fields ? "POST" : "GET")).toUpperCase();
255
+ return requestRefusal(endpoint, effective, args.join(" "));
256
+ }
257
+
258
+ function requestRefusal(
259
+ url: string,
260
+ method: string,
261
+ body: string
262
+ ): string | null {
263
+ if (PULL_MERGE.test(url) && method !== "GET" && method !== "HEAD")
264
+ return MERGE_MSG;
265
+ if (PULL.test(url) && (method === "PATCH" || method === "PUT"))
266
+ return MERGE_MSG;
267
+ if (/graphql/.test(url) && MERGE_MUTATION.test(body)) return MERGE_MSG;
268
+ return null;
269
+ }
270
+
271
+ function curlRefusal(args: string[]): string | null {
272
+ const method = optionValues(args, ["-X"], ["--request"]).pop();
273
+ const data = args.some((a) =>
274
+ /^(-d|-F|--data|--data-\w+|--json|--form|-T|--upload-file)(=|$)|^-[dFT]./.test(
275
+ a
276
+ )
277
+ );
278
+ const effective = (method ?? (data ? "POST" : "GET")).toUpperCase();
279
+ for (const url of args.filter((a) => /^https?:\/\//i.test(a))) {
280
+ const r = requestRefusal(url, effective, args.join(" "));
281
+ if (r) return r;
282
+ }
283
+ return null;
284
+ }
285
+
286
+ // What changes a repository's files, history or branches.
287
+ const GIT_WRITES = new Set([
288
+ "commit",
289
+ "reset",
290
+ "checkout",
291
+ "switch",
292
+ "restore",
293
+ "rebase",
294
+ "merge",
295
+ "pull",
296
+ "cherry-pick",
297
+ "revert",
298
+ "am",
299
+ "apply",
300
+ "stash",
301
+ "clean",
302
+ "rm",
303
+ "mv",
304
+ ]);
305
+
306
+ async function gitRefusal(
307
+ args: string[],
308
+ ctx: ShellContext,
309
+ cwd: string
310
+ ): Promise<string | null> {
311
+ let dir = cwd;
312
+ let i = 0;
313
+ for (; i < args.length; i++) {
314
+ const a = args[i];
315
+ if (a === "-C" || a === "--git-dir" || a === "--work-tree") {
316
+ dir = path.resolve(dir, args[++i] ?? "");
317
+ continue;
318
+ }
319
+ const eq = /^--(git-dir|work-tree)=(.*)$/.exec(a);
320
+ if (eq) {
321
+ dir = path.resolve(dir, eq[2]);
322
+ continue;
323
+ }
324
+ if (a === "-c" || a === "--namespace" || a === "--exec-path") {
325
+ i++;
326
+ continue;
327
+ }
328
+ if (a.startsWith("-")) continue;
329
+ break;
330
+ }
331
+ const sub = args[i];
332
+ if (sub === "push")
333
+ return `Refused: ${T.quick_pr} pushes a quick change's branch and opens its PR, and tasks push their own; nothing pushes from the orchestrator's shell.`;
334
+ if (!GIT_WRITES.has(sub)) return null;
335
+ if (has(dir))
336
+ return `Refused: git ${sub} in a folder only the shell knows (${show(dir)}); name the folder.`;
337
+ const why = await ctx.writeRefusal(dir);
338
+ return why ? `Refused: git ${sub} would change ${dir}. ${why}` : null;
339
+ }
340
+
341
+ // Commands whose every non-option argument is a file they change, and
342
+ // those whose last one is.
343
+ const WRITES_ALL = new Set([
344
+ "rm",
345
+ "rmdir",
346
+ "unlink",
347
+ "touch",
348
+ "truncate",
349
+ "mkdir",
350
+ "tee",
351
+ "shred",
352
+ "mv",
353
+ "chmod",
354
+ "chown",
355
+ "chgrp",
356
+ ]);
357
+ const WRITES_LAST = new Set(["cp", "ln", "install", "rsync", "ditto"]);
358
+
359
+ function writtenFiles(inv: Invocation): string[] {
360
+ const { name, args } = inv;
361
+ const files = [...inv.cmd.writes];
362
+ if (WRITES_ALL.has(name)) {
363
+ const pos = positionals(args, ["-s", "-m", "--size", "--mode"]);
364
+ files.push(...(/^ch(mod|own|grp)$/.test(name) ? pos.slice(1) : pos));
365
+ }
366
+ if (WRITES_LAST.has(name)) {
367
+ const target = optionValues(args, ["-t"], ["--target-directory"]);
368
+ files.push(...target);
369
+ const pos = positionals(args, ["-t", "--target-directory"]);
370
+ if (!target.length && pos.length > 1) files.push(pos[pos.length - 1]);
371
+ }
372
+ if (name === "dd")
373
+ files.push(
374
+ ...args.filter((a) => a.startsWith("of=")).map((a) => a.slice(3))
375
+ );
376
+ if (name === "sed" && args.some((a) => /^(-i|--in-place)/.test(a))) {
377
+ const script = args.some((a) => a === "-e" || a === "-f") ? 0 : 1;
378
+ files.push(...positionals(args, ["-e", "-f"]).slice(script));
379
+ }
380
+ return files;
381
+ }
382
+
383
+ const SQL_WRITE =
384
+ /\b(insert|update|delete|drop|alter|attach|create|vacuum|reindex)\b|load_extension|writefile|^\s*\.(restore|import|load|shell|system|save|clone)/im;
385
+ const SQLITE_VALUES = [
386
+ "-cmd",
387
+ "-init",
388
+ "-separator",
389
+ "-newline",
390
+ "-nullvalue",
391
+ "-vfs",
392
+ "-mmap",
393
+ "-maxsize",
394
+ "-lookaside",
395
+ "-pagecache",
396
+ "-heap",
397
+ "-escape",
398
+ ];
399
+
400
+ async function sqliteRefusal(
401
+ inv: Invocation,
402
+ ctx: ShellContext,
403
+ cwd: string,
404
+ raw: string
405
+ ): Promise<string | null> {
406
+ const [db, ...sql] = positionals(inv.args, SQLITE_VALUES);
407
+ if (!db || has(db)) return null;
408
+ const file = db.replace(/^file:/, "").replace(/\?.*$/, "");
409
+ if (!(await ctx.isAgentOsState(path.resolve(cwd, file)))) return null;
410
+ const fix = `open it with -readonly and only read (SELECT, PRAGMA, .schema, .tables); change AgentOS through your tools (${T.note}, ${T.rule_add}, ${T.drop}, ${T.stop} and the rest).`;
411
+ if (!inv.args.some((a) => a === "-readonly" || a === "--readonly"))
412
+ return `Refused: sqlite3 on AgentOS's own state; ${fix}`;
413
+ if (inv.args.some((a) => a === "-init" || a === "--init"))
414
+ return `Refused: sqlite3 -init on AgentOS's state runs SQL this can't check; ${fix}`;
415
+ const text = [
416
+ ...sql,
417
+ ...optionValues(inv.args, [], ["-cmd"]),
418
+ inv.cmd.stdin,
419
+ // SQL piped in from another command is somewhere in the line.
420
+ ...(sql.length ? [] : [raw]),
421
+ ].join("\n");
422
+ return SQL_WRITE.test(text)
423
+ ? `Refused: that SQL would change AgentOS's state; ${fix}`
424
+ : null;
425
+ }
426
+
427
+ const SERVER_PATTERN = /node|tsx|server|agent-?os|tmux|next/i;
428
+ const STOP_MSG = `Refused: never stop or restart AgentOS or its tmux; that ends every session it runs, this one included. Stop a session with ${T.stop} or finish it with ${T.done}, and if AgentOS itself needs a restart, ${T.ask_saad}.`;
429
+
430
+ async function stopRefusal(
431
+ inv: Invocation,
432
+ ctx: ShellContext
433
+ ): Promise<string | null> {
434
+ const { name, args } = inv;
435
+ if (name === "tmux" && args.some((a) => /^kill/.test(a))) return STOP_MSG;
436
+ if (
437
+ (name === "pkill" || name === "killall") &&
438
+ positionals(args, ["-u", "-U", "-g", "-G", "-t", "-s", "-F"]).some(
439
+ (a) => has(a) || SERVER_PATTERN.test(a)
440
+ )
441
+ )
442
+ return STOP_MSG;
443
+ if (name === "launchctl" || name === "sm" || name === "serviceman") {
444
+ const verb = positionals(args)[0] ?? "";
445
+ if (
446
+ !/^(list|ls|print|status|logs?|blame)$/.test(verb) &&
447
+ args.some((a) => /agent-?os/i.test(a))
448
+ )
449
+ return STOP_MSG;
450
+ }
451
+ if (name === "kill") {
452
+ const targets = positionals(args, ["-s", "-n"]).concat(
453
+ // After --, a negative number is a process group.
454
+ args.includes("--")
455
+ ? []
456
+ : args.filter((a) => /^-\d+$/.test(a) && args.indexOf(a) > 0)
457
+ );
458
+ if (args.includes("-l") || args.includes("-L")) return null;
459
+ if (!targets.length) return null;
460
+ for (const t of targets) {
461
+ if (t.startsWith("%")) continue;
462
+ if (has(t) || !/^\d+$/.test(t))
463
+ return `Refused: kill a process by its number, read first, so it's never AgentOS's (${show(t)} is only known to the shell). ${STOP_MSG.slice(9)}`;
464
+ if (Number(t) <= 1) return STOP_MSG;
465
+ }
466
+ const guarded = await ctx.protectedPids();
467
+ if (targets.some((t) => guarded.has(Number(t)))) return STOP_MSG;
468
+ }
469
+ return null;
470
+ }
471
+
472
+ const SECRET_MSG = `Refused: orchestrators never read secrets (env files, keys, credential stores, tokenvault). If a step needs one, ${T.ask_saad}.`;
473
+
474
+ function secretRefusal(inv: Invocation, cwd: string): string | null {
475
+ if (
476
+ (inv.name === "tv" || inv.name === "tokenvault") &&
477
+ /^(get|dump)$/.test(positionals(inv.args)[0] ?? "")
478
+ )
479
+ return SECRET_MSG;
480
+ for (const w of [...inv.args, ...inv.cmd.reads]) {
481
+ for (const p of [
482
+ w,
483
+ ...(w.includes("=") ? [w.slice(w.indexOf("=") + 1)] : []),
484
+ ]) {
485
+ if (!p || has(p) || /\s|:\/\//.test(p)) continue;
486
+ const pathish =
487
+ /\//.test(p) ||
488
+ p.startsWith(".") ||
489
+ fs.existsSync(path.resolve(cwd, p));
490
+ if (pathish && isSecretPath(p)) return SECRET_MSG;
491
+ }
492
+ }
493
+ return null;
494
+ }
495
+
496
+ // Why this command line is refused, or null.
497
+ export async function shellRefusal(
498
+ command: unknown,
499
+ ctx: ShellContext
500
+ ): Promise<string | null> {
501
+ if (typeof command !== "string" || !command.trim())
502
+ return "No command to check.";
503
+ const parsed = parseShell(command);
504
+ if (typeof parsed === "string") return `Refused: ${parsed}.`;
505
+ let cwd = ctx.cwd;
506
+ for (const cmd of parsed) {
507
+ const invs = invocations(cmd);
508
+ if (typeof invs === "string") return `Refused: ${invs}.`;
509
+ for (const inv of invs) {
510
+ if (inv.name === "cd" || inv.name === "pushd") {
511
+ const to = positionals(inv.args)[0];
512
+ // Somewhere only the shell knows: a git write after it is refused.
513
+ if (!to) cwd = os.homedir();
514
+ else cwd = has(to) || to === "-" ? UNKNOWN : path.resolve(cwd, to);
515
+ continue;
516
+ }
517
+ const why =
518
+ secretRefusal(inv, cwd) ??
519
+ (inv.name === "gh" ? ghRefusal(inv.args) : null) ??
520
+ (inv.name === "curl" ? curlRefusal(inv.args) : null) ??
521
+ (inv.name === "git" ? await gitRefusal(inv.args, ctx, cwd) : null) ??
522
+ (inv.name === "sqlite3"
523
+ ? await sqliteRefusal(inv, ctx, cwd, command)
524
+ : null) ??
525
+ (await stopRefusal(inv, ctx));
526
+ if (why) return why;
527
+ for (const f of writtenFiles(inv)) {
528
+ if (has(f) || f === "/dev/null" || f.startsWith("/dev/fd/")) continue;
529
+ if (has(cwd) && !f.startsWith("/")) continue;
530
+ const r = await ctx.writeRefusal(path.resolve(cwd, f));
531
+ if (r) return `Refused: ${inv.name} would write ${f}. ${r}`;
532
+ }
533
+ }
534
+ }
535
+ return null;
536
+ }