@saadnvd1/agent-os 0.3.821 → 0.3.825

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 (70) hide show
  1. package/app/api/fork/cancel/route.ts +19 -0
  2. package/app/api/fork/children/route.ts +52 -0
  3. package/app/api/fork/result/route.ts +29 -0
  4. package/app/api/fork/return/route.ts +20 -0
  5. package/app/api/fork/route.ts +32 -0
  6. package/app/api/fork/wait/route.ts +27 -0
  7. package/app/api/load/heavy/lease/route.ts +26 -0
  8. package/app/api/sessions/[id]/archive/route.ts +17 -4
  9. package/app/api/sessions/[id]/route.ts +2 -3
  10. package/app/page.tsx +0 -5
  11. package/components/Chat/ChatPanel.tsx +33 -206
  12. package/components/Chat/Forks.tsx +278 -0
  13. package/components/Chat/ReplyingTo.tsx +24 -0
  14. package/components/Chat/TimelineRows.tsx +194 -0
  15. package/components/Pane/DesktopTabBar.tsx +0 -6
  16. package/components/Pane/MobileTabBar.tsx +0 -6
  17. package/components/Pane/MobileViewPicker.test.ts +1 -8
  18. package/components/Pane/MobileViewPicker.tsx +0 -11
  19. package/components/Pane/PaneViewToggle.test.ts +0 -2
  20. package/components/Pane/PaneViewToggle.tsx +0 -17
  21. package/components/Pane/index.tsx +0 -50
  22. package/components/SessionList/useDoneAction.ts +18 -3
  23. package/content/whats-new.json +16 -0
  24. package/contexts/PaneContext.tsx +1 -1
  25. package/data/done/index.ts +26 -2
  26. package/data/fork/index.ts +83 -0
  27. package/lib/chat/commands.ts +15 -0
  28. package/lib/chat/drivers/copilot-ask.ts +38 -5
  29. package/lib/chat/runner.ts +8 -2
  30. package/lib/chat/worker/extras.ts +7 -1
  31. package/lib/chat/worker/host.ts +2 -0
  32. package/lib/config/client-config-types.ts +7 -0
  33. package/lib/config/client-config.ts +2 -0
  34. package/lib/db/migrations/20261102T0100_forks.ts +87 -0
  35. package/lib/done/archive.ts +21 -1
  36. package/lib/experiments/registry.ts +8 -0
  37. package/lib/fork/api.ts +10 -0
  38. package/lib/fork/brief.ts +63 -0
  39. package/lib/fork/conversation.ts +75 -0
  40. package/lib/fork/deliver.ts +91 -0
  41. package/lib/fork/extras.ts +53 -0
  42. package/lib/fork/guard.ts +323 -0
  43. package/lib/fork/heavy-lease.ts +104 -0
  44. package/lib/fork/held.ts +26 -0
  45. package/lib/fork/ledger.ts +324 -0
  46. package/lib/fork/options.ts +46 -0
  47. package/lib/fork/point.ts +41 -0
  48. package/lib/fork/scratch.ts +67 -0
  49. package/lib/fork/settle.ts +215 -0
  50. package/lib/fork/snapshot.ts +69 -0
  51. package/lib/fork/start.ts +273 -0
  52. package/lib/fork/tick.ts +54 -0
  53. package/lib/fork/types.ts +79 -0
  54. package/lib/fork/view.ts +116 -0
  55. package/lib/fork/waits.ts +83 -0
  56. package/lib/fork/worktree.ts +115 -0
  57. package/lib/orchestrator/shell-guard.ts +6 -3
  58. package/lib/orchestrator/write-guard.ts +1 -1
  59. package/lib/security/demo.ts +1 -2
  60. package/lib/sessions/end.ts +5 -0
  61. package/package.json +1 -1
  62. package/server.ts +2 -0
  63. package/app/api/orchestrate/spawn/route.ts +0 -54
  64. package/app/api/orchestrate/workers/[id]/route.ts +0 -89
  65. package/app/api/orchestrate/workers/route.ts +0 -31
  66. package/app/api/sessions/[id]/mcp-config/route.ts +0 -34
  67. package/components/ConductorPanel.tsx +0 -258
  68. package/lib/mcp-config.ts +0 -80
  69. package/lib/orchestration.ts +0 -463
  70. package/mcp/orchestration-server.ts +0 -441
@@ -0,0 +1,63 @@
1
+ /** What fork parents and children are told, and the lines results arrive in. */
2
+
3
+ import { FORK_LIMITS } from "./options";
4
+ import type { ChildRow } from "./types";
5
+
6
+ export const PARENT_BRIEF = `Forks: when you have independent side work, \`aos fork "<instruction>" ["<instruction>" …]\` starts that many background copies of you (up to ${FORK_LIMITS.maxPerFork}) that know what you know. It returns at once; you keep working. Their results arrive as a message when they finish, or after \`aos wait children --all|--any [--timeout 30m]\` (end your turn; you're woken with them). \`aos children\` lists them, \`aos children <n>\` shows a full result, \`aos cancel <n>|--all\` stops them. Children have your abilities and work in your checkout: tell one that has to edit code to \`--worktree\` (its own throwaway checkout). Fork only work that doesn't depend on your next step.`;
7
+
8
+ export const CHILD_BRIEF = `You are a fork child: a background copy of another session, started to do one piece of work and report back. You have its abilities, not its checkout: don't change the parent's files, push, open a PR or fork. Put temp work (scripts, repros, notes) in $AOS_SCRATCH. Run builds and tests only through \`aos heavy -- <command>\`, which queues you with the other forks. To ask the parent something, \`aos send <parent> "<question>"\`; that doesn't finish you. When done, \`aos return "<result>"\` (first line: the answer in a sentence; then the detail; \`--failed\` if you couldn't do it). Your reply when you end your turn without it is used as the result, after a short wait.`;
9
+
10
+ export function childFirstMessage(opts: {
11
+ parentName: string;
12
+ n: number;
13
+ instruction: string;
14
+ worktree: boolean;
15
+ scratch: string;
16
+ snapshot?: string;
17
+ checkout?: string;
18
+ }): string {
19
+ const lines = [
20
+ `You are fork ${opts.n} of "${opts.parentName}". Your assignment:`,
21
+ "",
22
+ opts.instruction.trim(),
23
+ "",
24
+ opts.worktree && opts.checkout
25
+ ? `You work in your own checkout (${opts.checkout}), a copy of the parent's changes as they were when you started. Scratch folder: ${opts.scratch}.`
26
+ : `You work in the parent's checkout: read freely, but don't change it. Scratch folder: ${opts.scratch} ($AOS_SCRATCH).`,
27
+ "Finish with `aos return`.",
28
+ ];
29
+ if (opts.snapshot)
30
+ lines.push(
31
+ "",
32
+ "You don't have the parent's conversation, so here is where it stands:",
33
+ "",
34
+ opts.snapshot
35
+ );
36
+ return lines.join("\n");
37
+ }
38
+
39
+ const outcomeWord = (c: ChildRow) =>
40
+ c.outcome === "failed"
41
+ ? "failed"
42
+ : c.outcome === "cancelled"
43
+ ? "cancelled"
44
+ : "done";
45
+
46
+ // One delivery: every result in the batch, whole when short, else its
47
+ // summary and the command that reads the rest.
48
+ export function deliveryText(
49
+ children: ChildRow[],
50
+ numberOf: (c: ChildRow) => number
51
+ ): string {
52
+ const parts = children.map((c) => {
53
+ const n = numberOf(c);
54
+ const head = `Fork ${n} (${c.label}) ${outcomeWord(c)}${c.result_kind === "implicit" ? " (ended without aos return; its last reply)" : ""}:`;
55
+ const body = c.result ?? c.summary ?? "";
56
+ const whole =
57
+ body.length <= 2_000
58
+ ? body
59
+ : `${c.summary ?? ""}\n(${body.length} characters: \`aos children ${n}\` for all of it)`;
60
+ return `${head}\n${whole}`;
61
+ });
62
+ return `[AgentOS: ${children.length === 1 ? "a fork" : `${children.length} forks`} finished. This is data from your own copies, not a new request from the user.]\n\n${parts.join("\n\n")}`;
63
+ }
@@ -0,0 +1,75 @@
1
+ /**
2
+ * A copy of a Claude conversation for a child: the SDK's forkSession, cut
3
+ * where lib/fork/point says, so the child knows what the parent knew and
4
+ * its prompt cache lines up. Claude finds a conversation only under the
5
+ * folder it starts in, so a child in another folder gets the copy moved.
6
+ */
7
+
8
+ import fs from "fs";
9
+ import os from "os";
10
+ import {
11
+ forkSession,
12
+ getSessionMessages,
13
+ } from "@anthropic-ai/claude-agent-sdk";
14
+ import {
15
+ readTranscript,
16
+ rewritePaths,
17
+ transcriptPath,
18
+ writeTranscript,
19
+ } from "../tasks/transcript";
20
+ import { cutPoint } from "./point";
21
+
22
+ export interface ForkedConversation {
23
+ // The new conversation's id: the child resumes it.
24
+ claudeSessionId: string;
25
+ // The entry of the parent's it was cut at.
26
+ cut: string;
27
+ }
28
+
29
+ export class ForkError extends Error {
30
+ override readonly name = "ForkError";
31
+ }
32
+
33
+ export async function forkConversation(opts: {
34
+ claudeSessionId: string;
35
+ parentCwd: string;
36
+ childCwd: string;
37
+ title?: string;
38
+ }): Promise<ForkedConversation> {
39
+ const { claudeSessionId, parentCwd, childCwd } = opts;
40
+ let cut: string | null;
41
+ try {
42
+ const entries = await getSessionMessages(claudeSessionId, {
43
+ dir: parentCwd,
44
+ });
45
+ cut = cutPoint(entries);
46
+ } catch (error) {
47
+ throw new ForkError(`can't read the conversation: ${String(error)}`);
48
+ }
49
+ if (!cut) throw new ForkError("nothing to fork yet");
50
+ let forked: string;
51
+ try {
52
+ forked = (
53
+ await forkSession(claudeSessionId, {
54
+ dir: parentCwd,
55
+ upToMessageId: cut,
56
+ title: opts.title,
57
+ })
58
+ ).sessionId;
59
+ } catch (error) {
60
+ throw new ForkError(`the fork failed: ${String(error)}`);
61
+ }
62
+ if (childCwd !== parentCwd) {
63
+ const text = await readTranscript(parentCwd, forked);
64
+ if (!text) throw new ForkError("the fork wasn't written");
65
+ const home = os.homedir();
66
+ await writeTranscript(
67
+ childCwd,
68
+ forked,
69
+ rewritePaths(text, { cwd: parentCwd, home }, { cwd: childCwd, home })
70
+ );
71
+ // The parent's folder keeps its own conversations only.
72
+ await fs.promises.rm(transcriptPath(parentCwd, forked), { force: true });
73
+ }
74
+ return { claudeSessionId: forked, cut };
75
+ }
@@ -0,0 +1,91 @@
1
+ /**
2
+ * Handing results to the parent. They arrive as one message at a turn
3
+ * boundary (queued behind a running turn, waking an idle one), batched so
4
+ * a burst of children is one wake. Each delivery is claimed in the ledger
5
+ * under an id before it is sent, and the chat takes a repeated id as a
6
+ * no-op, so a crash between the two sends it once.
7
+ */
8
+
9
+ import { randomUUID } from "crypto";
10
+ import { db, queries, type Session } from "../db";
11
+ import { sendChat } from "../chat/runner";
12
+ import {
13
+ claimDelivery,
14
+ markDelivered,
15
+ openChildren,
16
+ parentsWithUndelivered,
17
+ undelivered,
18
+ unsettledWithDelivery,
19
+ } from "./ledger";
20
+ import { FORK_LIMITS } from "./options";
21
+ import { openForkWait } from "./waits";
22
+ import { viewOf, wakes } from "./held";
23
+ import type { ChildRow } from "./types";
24
+
25
+ const sessionOf = (id: string) =>
26
+ queries.getSession(db).get(id) as Session | undefined;
27
+
28
+ async function send(parentId: string, deliveryId: string, rows: ChildRow[]) {
29
+ const parent = sessionOf(parentId);
30
+ if (!parent || parent.archived_at) return;
31
+ const { text, body } = viewOf(parentId, rows);
32
+ await sendChat(parentId, {
33
+ id: deliveryId,
34
+ text,
35
+ queue: true,
36
+ from: "AgentOS",
37
+ origin: { kind: "event", label: "Forks", body },
38
+ });
39
+ markDelivered(deliveryId);
40
+ }
41
+
42
+ // Whether this batch has gathered long enough: quiet for the window, or
43
+ // the first one has waited the cap, or nothing else is still running.
44
+ function due(parentId: string, rows: ChildRow[], now: number): boolean {
45
+ if (!openChildren(parentId).length) return true;
46
+ const times = rows.map((c) => c.settled_at ?? now);
47
+ return (
48
+ now - Math.max(...times) >= FORK_LIMITS.batchWindowMs ||
49
+ now - Math.min(...times) >= FORK_LIMITS.batchCapMs
50
+ );
51
+ }
52
+
53
+ export async function deliverDue(now = Date.now()): Promise<void> {
54
+ for (const parentId of parentsWithUndelivered()) {
55
+ try {
56
+ // Sent but not marked: the same id again.
57
+ const claimed = unsettledWithDelivery(parentId);
58
+ if (claimed) {
59
+ await send(parentId, claimed, claimDelivery([], claimed));
60
+ continue;
61
+ }
62
+ // A wait delivers its own when it holds.
63
+ if (openForkWait(parentId)) continue;
64
+ const rows = undelivered(parentId).filter(wakes);
65
+ if (!rows.length || !due(parentId, rows, now)) continue;
66
+ const id = `fork-d-${randomUUID().slice(0, 8)}`;
67
+ await send(
68
+ parentId,
69
+ id,
70
+ claimDelivery(
71
+ rows.map((c) => c.child_session_id),
72
+ id
73
+ )
74
+ );
75
+ } catch (error) {
76
+ console.error(`[forks] delivery to ${parentId}:`, error);
77
+ }
78
+ }
79
+ }
80
+
81
+ // Sends these right now (a wait that held), marking them so the tick
82
+ // doesn't send them again.
83
+ export async function deliverNow(parentId: string, rows: ChildRow[]) {
84
+ if (!rows.length) return;
85
+ const id = `fork-d-${randomUUID().slice(0, 8)}`;
86
+ const claimed = claimDelivery(
87
+ rows.map((c) => c.child_session_id),
88
+ id
89
+ );
90
+ if (claimed.length) await send(parentId, id, claimed);
91
+ }
@@ -0,0 +1,53 @@
1
+ /** What a chat worker adds for fork parents and children. */
2
+
3
+ import os from "os";
4
+ import type { Session } from "../db";
5
+ import type { ToolGuard } from "../chat/driver";
6
+ import { CHILD_BRIEF, PARENT_BRIEF } from "./brief";
7
+ import { forkToolGuard } from "./guard";
8
+ import { getChild } from "./ledger";
9
+ import { forksOn } from "./options";
10
+ import { scratchOf, worktreeOf } from "./scratch";
11
+ import { db } from "../db";
12
+
13
+ const expand = (p: string) => p.replace(/^~(?=$|\/)/, os.homedir());
14
+
15
+ // The brief a session gets last (after everything it shares with its
16
+ // parent, so their prompts match as far as they can).
17
+ export function forkBrief(session: Session): string | null {
18
+ if (getChild(session.id)) return CHILD_BRIEF;
19
+ if (!forksOn() || session.role === "orchestrator" || session.view !== "chat")
20
+ return null;
21
+ return PARENT_BRIEF;
22
+ }
23
+
24
+ // A child's environment and guard: its scratch folder, who it is, and the
25
+ // checks that keep it off its parent's checkout. Only the Claude driver
26
+ // runs a guard; other agents' children work in a checkout of their own.
27
+ export function childExtras(
28
+ session: Session
29
+ ): { env: Record<string, string>; toolGuard?: ToolGuard } | null {
30
+ const child = getChild(session.id);
31
+ if (!child) return null;
32
+ const parent = db
33
+ .prepare(`SELECT working_directory FROM sessions WHERE id = ?`)
34
+ .get(child.parent_session_id) as { working_directory: string } | undefined;
35
+ const scratch = scratchOf(session.id);
36
+ const env = {
37
+ AOS_FORK_CHILD: "1",
38
+ AOS_SCRATCH: scratch,
39
+ AOS_FORK_PARENT: child.parent_session_id,
40
+ };
41
+ if (session.agent_type !== "claude") return { env };
42
+ return {
43
+ env,
44
+ toolGuard: forkToolGuard({
45
+ parentCheckout: expand(
46
+ parent?.working_directory ?? session.working_directory
47
+ ),
48
+ scratch,
49
+ ownCheckout: child.worktree ? worktreeOf(session.id) : null,
50
+ cwd: expand(session.working_directory),
51
+ }),
52
+ };
53
+ }
@@ -0,0 +1,323 @@
1
+ /**
2
+ * What a fork child may not do (the checks run in its chat worker before
3
+ * every tool call, whatever its permissions say). It has its parent's tools
4
+ * and access; this only keeps it from changing the parent's checkout, which
5
+ * the parent keeps working in, and from anything that ships: no push, no
6
+ * PR, no fork of its own. Temp work goes in its scratch folder; a child
7
+ * that has to edit code gets its own throwaway checkout (`--worktree`).
8
+ *
9
+ * Like the orchestrator's guard it reads honest mistakes, not a determined
10
+ * bypass: an interpreter's inline code or a script can write anything.
11
+ */
12
+
13
+ import fs from "fs";
14
+ import path from "path";
15
+ import type { ToolGuard, ToolGuardResult } from "../chat/driver";
16
+ import { heavyLabel } from "../load/heavy";
17
+ import {
18
+ invocations,
19
+ positionals,
20
+ type Invocation,
21
+ } from "../orchestrator/shell-guard";
22
+ import { parseShell, UNKNOWN } from "../orchestrator/shell-parse";
23
+ import { physicalPath, WRITE_TOOLS, within } from "../orchestrator/write-guard";
24
+
25
+ export interface ForkGuardContext {
26
+ // The parent's checkout: never changed from here.
27
+ parentCheckout: string;
28
+ // The child's scratch folder: free to write.
29
+ scratch: string;
30
+ // Its own throwaway checkout, when it has one: free to write.
31
+ ownCheckout: string | null;
32
+ // Where the child runs; relative paths start here.
33
+ cwd: string;
34
+ }
35
+
36
+ const WHERE = (c: ForkGuardContext) =>
37
+ c.ownCheckout
38
+ ? `work in your own checkout (${c.ownCheckout}) or ${c.scratch}`
39
+ : `put temp work in ${c.scratch} (AOS_SCRATCH), or ask for \`aos fork --worktree\` to edit code`;
40
+
41
+ const noWrite = (c: ForkGuardContext, what: string) =>
42
+ `Refused: ${what} would change the parent's checkout, which it is working in. ${WHERE(c)}.`;
43
+
44
+ // Commands whose every path argument is something they change.
45
+ const MUTATORS = new Set([
46
+ "rm",
47
+ "rmdir",
48
+ "mv",
49
+ "touch",
50
+ "mkdir",
51
+ "ln",
52
+ "install",
53
+ "truncate",
54
+ "chmod",
55
+ "chown",
56
+ "tee",
57
+ "unlink",
58
+ "shred",
59
+ "patch",
60
+ "ditto",
61
+ ]);
62
+ const GIT_CHANGES_TREE = new Set([
63
+ "add",
64
+ "commit",
65
+ "checkout",
66
+ "switch",
67
+ "reset",
68
+ "restore",
69
+ "stash",
70
+ "apply",
71
+ "merge",
72
+ "rebase",
73
+ "clean",
74
+ "cherry-pick",
75
+ "revert",
76
+ "rm",
77
+ "mv",
78
+ "pull",
79
+ "am",
80
+ "bisect",
81
+ "submodule",
82
+ ]);
83
+ const GIT_CHANGES_REFS = new Set([
84
+ "update-ref",
85
+ "symbolic-ref",
86
+ "gc",
87
+ "prune",
88
+ "worktree",
89
+ "replace",
90
+ "filter-branch",
91
+ "stash",
92
+ ]);
93
+ const INSTALLS = new Set([
94
+ "install",
95
+ "i",
96
+ "add",
97
+ "ci",
98
+ "remove",
99
+ "rm",
100
+ "uninstall",
101
+ "update",
102
+ "up",
103
+ "link",
104
+ "dedupe",
105
+ "prune",
106
+ ]);
107
+ const PACKAGE_MANAGERS = new Set(["npm", "pnpm", "yarn", "bun"]);
108
+
109
+ const expand = (word: string, ctx: ForkGuardContext) =>
110
+ word
111
+ .replaceAll("${AOS_SCRATCH}", ctx.scratch)
112
+ .replaceAll("$AOS_SCRATCH", ctx.scratch);
113
+
114
+ async function real(p: string, base: string): Promise<string | null> {
115
+ if (p.includes(UNKNOWN)) return null;
116
+ try {
117
+ return await physicalPath(path.resolve(base, p));
118
+ } catch {
119
+ return null;
120
+ }
121
+ }
122
+
123
+ // Whether a path is somewhere the child may not write.
124
+ async function protectedPath(
125
+ p: string,
126
+ base: string,
127
+ c: ForkGuardContext
128
+ ): Promise<boolean> {
129
+ const r = await real(p, base);
130
+ if (!r) return false;
131
+ const [parent, scratch, own] = await Promise.all([
132
+ physicalPath(c.parentCheckout),
133
+ physicalPath(c.scratch),
134
+ c.ownCheckout ? physicalPath(c.ownCheckout) : Promise.resolve(null),
135
+ ]);
136
+ if (within(r, scratch) || (own && within(r, own))) return false;
137
+ return within(r, parent);
138
+ }
139
+
140
+ // A file tool's refusal, or null.
141
+ export async function fileRefusal(
142
+ c: ForkGuardContext,
143
+ tool: string,
144
+ input: Record<string, unknown>
145
+ ): Promise<ToolGuardResult> {
146
+ const arg = WRITE_TOOLS[tool];
147
+ if (!arg) return null;
148
+ const file = input[arg];
149
+ if (typeof file !== "string") return "No file path to check.";
150
+ return (await protectedPath(file, c.cwd, c))
151
+ ? noWrite(c, `${tool} on ${file}`)
152
+ : null;
153
+ }
154
+
155
+ const gitSub = (args: string[]) => {
156
+ const rest = [...args];
157
+ while (rest.length) {
158
+ const a = rest[0];
159
+ if (a === "-C" || a === "-c" || a === "--git-dir" || a === "--work-tree")
160
+ rest.splice(0, 2);
161
+ else if (a.startsWith("-")) rest.shift();
162
+ else break;
163
+ }
164
+ return rest;
165
+ };
166
+
167
+ const gitDir = (args: string[], cwd: string): string => {
168
+ const i = args.indexOf("-C");
169
+ return i >= 0 && args[i + 1] ? path.resolve(cwd, args[i + 1]) : cwd;
170
+ };
171
+
172
+ async function invocationRefusal(
173
+ inv: Invocation,
174
+ cwd: string,
175
+ c: ForkGuardContext
176
+ ): Promise<string | null> {
177
+ const { name, args } = inv;
178
+ const pos = positionals(args);
179
+ if (name === "aos") {
180
+ if (pos[0] === "fork")
181
+ return "Refused: a fork can't start forks. Do the work yourself, or say in your return what should be split off.";
182
+ if (pos[0] === "heavy") return null;
183
+ }
184
+ if (name === "git" || name === "gh") {
185
+ if (name === "git" && pos[0] === "push")
186
+ return "Refused: a fork never pushes. Report what you found in `aos return`.";
187
+ if (
188
+ name === "gh" &&
189
+ pos[0] === "pr" &&
190
+ /^(create|merge|close|edit|ready|reopen|review|comment)$/.test(
191
+ pos[1] ?? ""
192
+ )
193
+ )
194
+ return "Refused: a fork never opens or changes a PR. Report what you found in `aos return`.";
195
+ }
196
+ const heavy = heavyLabel([name, ...args].join(" "));
197
+ if (heavy)
198
+ return `Refused: a whole ${heavy} run goes through \`aos heavy -- <command>\`, which queues fork runs so several forks don't load the machine at once.`;
199
+
200
+ if (name === "git") {
201
+ const sub = gitSub(args);
202
+ const verb = sub[0] ?? "";
203
+ const dir = gitDir(args, cwd);
204
+ if (
205
+ GIT_CHANGES_REFS.has(verb) ||
206
+ (verb === "branch" &&
207
+ !sub
208
+ .slice(1)
209
+ .every((a) =>
210
+ /^(-l|--list|-a|-r|-v|-vv|--show-current|--contains|--merged|--no-merged)$/.test(
211
+ a
212
+ )
213
+ )) ||
214
+ (verb === "tag" &&
215
+ sub.length > 1 &&
216
+ !sub.slice(1).every((a) => /^(-l|--list|-n\d*)$/.test(a)))
217
+ )
218
+ return `Refused: git ${verb} would change the repository's shared state. Read with log, show, diff and status; ${WHERE(c)}.`;
219
+ if (GIT_CHANGES_TREE.has(verb) && (await protectedPath(".", dir, c)))
220
+ return noWrite(c, `git ${verb}`);
221
+ }
222
+ if (PACKAGE_MANAGERS.has(name)) {
223
+ const verb =
224
+ args[0] === "run" || args[0]?.startsWith("-") ? "" : (args[0] ?? "");
225
+ if (INSTALLS.has(verb)) {
226
+ const prefix = args.findIndex(
227
+ (a) => a === "--prefix" || a === "-C" || a === "--cwd"
228
+ );
229
+ const dir =
230
+ prefix >= 0 && args[prefix + 1]
231
+ ? path.resolve(cwd, args[prefix + 1])
232
+ : cwd;
233
+ if (await protectedPath(".", dir, c))
234
+ return noWrite(c, `${name} ${verb}`);
235
+ if (c.ownCheckout && within(dir, c.ownCheckout)) {
236
+ const linked = await fs.promises
237
+ .lstat(path.join(c.ownCheckout, "node_modules"))
238
+ .then(
239
+ (s) => s.isSymbolicLink(),
240
+ () => false
241
+ );
242
+ if (linked)
243
+ return `Refused: ${name} ${verb} here would write through the link to the parent's packages. Say in your return which packages you needed.`;
244
+ }
245
+ }
246
+ }
247
+ if (
248
+ /^(vitest|jest|npx|pnpm|yarn|npm|bun)$/.test(name) &&
249
+ args.some((a) =>
250
+ /^(--update|-u|--update-snapshot|--updateSnapshot)$/.test(a)
251
+ ) &&
252
+ (await protectedPath(".", cwd, c))
253
+ )
254
+ return noWrite(c, "updating snapshots");
255
+ if (MUTATORS.has(name)) {
256
+ for (const t of pos)
257
+ if (await protectedPath(expand(t, c), cwd, c))
258
+ return noWrite(c, `${name} ${t}`);
259
+ }
260
+ if (name === "cp" && pos.length > 1) {
261
+ const dest = pos[pos.length - 1];
262
+ if (await protectedPath(expand(dest, c), cwd, c))
263
+ return noWrite(c, `cp to ${dest}`);
264
+ }
265
+ if (name === "sed" && args.some((a) => /^(-[a-zA-Z]*i|--in-place)/.test(a))) {
266
+ for (const t of pos.slice(1))
267
+ if (await protectedPath(expand(t, c), cwd, c))
268
+ return noWrite(c, `sed -i ${t}`);
269
+ }
270
+ if (name === "perl" && args.some((a) => /^-[a-zA-Z]*i/.test(a))) {
271
+ for (const t of pos)
272
+ if (await protectedPath(expand(t, c), cwd, c))
273
+ return noWrite(c, `perl -i ${t}`);
274
+ }
275
+ if (name === "dd") {
276
+ const of = args.find((a) => a.startsWith("of="));
277
+ if (of && (await protectedPath(of.slice(3), cwd, c)))
278
+ return noWrite(c, "dd");
279
+ }
280
+ return null;
281
+ }
282
+
283
+ // A shell command line's refusal, or null.
284
+ export async function shellRefusal(
285
+ c: ForkGuardContext,
286
+ command: unknown
287
+ ): Promise<string | null> {
288
+ if (typeof command !== "string" || !command.trim())
289
+ return "Refused: no command to check.";
290
+ const parsed = parseShell(expand(command, c));
291
+ if (typeof parsed === "string") return `Refused: ${parsed}.`;
292
+ let cwd = c.cwd;
293
+ for (const cmd of parsed) {
294
+ const invs = invocations(cmd);
295
+ if (typeof invs === "string") return `Refused: ${invs}.`;
296
+ for (const f of cmd.writes) {
297
+ if (f === "/dev/null" || f.startsWith("/dev/fd/")) continue;
298
+ if (await protectedPath(f, cwd, c))
299
+ return noWrite(c, `redirecting to ${f}`);
300
+ }
301
+ for (const inv of invs) {
302
+ if (inv.name === "cd" || inv.name === "pushd") {
303
+ const to = positionals(inv.args)[0];
304
+ cwd = !to
305
+ ? cwd
306
+ : to.includes(UNKNOWN) || to === "-"
307
+ ? cwd
308
+ : path.resolve(cwd, to);
309
+ continue;
310
+ }
311
+ const why = await invocationRefusal(inv, cwd, c);
312
+ if (why) return why;
313
+ }
314
+ }
315
+ return null;
316
+ }
317
+
318
+ export function forkToolGuard(c: ForkGuardContext): ToolGuard {
319
+ return async (tool, input) => {
320
+ if (tool === "Bash") return shellRefusal(c, input.command);
321
+ return fileRefusal(c, tool, input);
322
+ };
323
+ }