tau-all-agent 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 (95) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +45 -0
  3. package/extensions/answer.ts +601 -0
  4. package/extensions/branch-term.ts +405 -0
  5. package/extensions/btw.ts +444 -0
  6. package/extensions/ghostty.ts +301 -0
  7. package/extensions/git-diff-stats.ts +277 -0
  8. package/extensions/git-pr-status.ts +286 -0
  9. package/extensions/insights.ts +2367 -0
  10. package/extensions/interlude.ts +144 -0
  11. package/extensions/loop.ts +529 -0
  12. package/extensions/memory.ts +1889 -0
  13. package/extensions/notify.ts +161 -0
  14. package/extensions/openai-fast.ts +227 -0
  15. package/extensions/openai-verbosity.ts +223 -0
  16. package/extensions/review.ts +4347 -0
  17. package/extensions/sandbox/index.ts +2578 -0
  18. package/extensions/telegram/README.md +81 -0
  19. package/extensions/telegram/daemon.mjs +1942 -0
  20. package/extensions/telegram/index.ts +1073 -0
  21. package/extensions/telegram/message-text.d.mts +1 -0
  22. package/extensions/telegram/message-text.mjs +16 -0
  23. package/extensions/usage/anthropic.ts +198 -0
  24. package/extensions/usage/github-copilot.ts +204 -0
  25. package/extensions/usage/google-gemini-cli.ts +232 -0
  26. package/extensions/usage/index.ts +2310 -0
  27. package/extensions/usage/minimax.ts +208 -0
  28. package/extensions/usage/openai-codex.ts +180 -0
  29. package/extensions/usage/openrouter.ts +168 -0
  30. package/extensions/usage/providers.ts +26 -0
  31. package/extensions/usage/shared.ts +156 -0
  32. package/extensions/usage/types.ts +53 -0
  33. package/extensions/usage/zai.ts +186 -0
  34. package/extensions/websearch/README.md +68 -0
  35. package/extensions/websearch/browser/chromium.ts +330 -0
  36. package/extensions/websearch/browser/discovery.ts +75 -0
  37. package/extensions/websearch/browser/firefox.ts +150 -0
  38. package/extensions/websearch/browser/sqlite.ts +38 -0
  39. package/extensions/websearch/config.ts +76 -0
  40. package/extensions/websearch/index.ts +312 -0
  41. package/extensions/websearch/normalize.ts +155 -0
  42. package/extensions/websearch/providers/anthropic.pi.ts +139 -0
  43. package/extensions/websearch/providers/gemini.browser.ts +185 -0
  44. package/extensions/websearch/providers/gemini.pi.ts +108 -0
  45. package/extensions/websearch/providers/openai-codex.browser.ts +77 -0
  46. package/extensions/websearch/providers/openai-codex.pi.ts +49 -0
  47. package/extensions/websearch/providers/openai-codex.shared.ts +123 -0
  48. package/extensions/websearch/providers/pi-model.shared.ts +86 -0
  49. package/extensions/websearch/providers/search-prompt.shared.ts +17 -0
  50. package/extensions/websearch/providers/shared.ts +76 -0
  51. package/extensions/websearch/types.ts +52 -0
  52. package/extensions/worktree.ts +2223 -0
  53. package/package.json +76 -0
  54. package/skills/browser-tools/SKILL.md +253 -0
  55. package/skills/browser-tools/scripts/browser-content.js +100 -0
  56. package/skills/browser-tools/scripts/browser-cookies.js +33 -0
  57. package/skills/browser-tools/scripts/browser-dismiss-cookies.js +455 -0
  58. package/skills/browser-tools/scripts/browser-eval.js +75 -0
  59. package/skills/browser-tools/scripts/browser-logs-tail.js +89 -0
  60. package/skills/browser-tools/scripts/browser-nav.js +28 -0
  61. package/skills/browser-tools/scripts/browser-net-summary.js +115 -0
  62. package/skills/browser-tools/scripts/browser-pick.js +133 -0
  63. package/skills/browser-tools/scripts/browser-screenshot.js +18 -0
  64. package/skills/browser-tools/scripts/browser-start.js +281 -0
  65. package/skills/browser-tools/scripts/browser-watch.js +321 -0
  66. package/skills/browser-tools/scripts/utils.js +60 -0
  67. package/skills/git-clean-history/SKILL.md +45 -0
  68. package/skills/git-commit/SKILL.md +55 -0
  69. package/skills/homeassistant-ops/SKILL.md +184 -0
  70. package/skills/homeassistant-ops/references/api.md +64 -0
  71. package/skills/homeassistant-ops/references/id_conventions.md +71 -0
  72. package/skills/homeassistant-ops/references/playbook.md +150 -0
  73. package/skills/homeassistant-ops/scripts/ha_ops.js +2942 -0
  74. package/skills/openscad/SKILL.md +149 -0
  75. package/skills/openscad/examples/parametric_box.scad +92 -0
  76. package/skills/openscad/examples/phone_stand.scad +95 -0
  77. package/skills/openscad/scripts/common.sh +49 -0
  78. package/skills/openscad/scripts/export-stl.sh +56 -0
  79. package/skills/openscad/scripts/extract-params.sh +147 -0
  80. package/skills/openscad/scripts/multi-preview.sh +68 -0
  81. package/skills/openscad/scripts/preview.sh +74 -0
  82. package/skills/openscad/scripts/render-with-params.sh +91 -0
  83. package/skills/openscad/scripts/validate.sh +46 -0
  84. package/skills/oracle/SKILL.md +64 -0
  85. package/skills/oracle/scripts/oracle +249 -0
  86. package/skills/oracle/scripts/oracle-bundle +227 -0
  87. package/skills/sentry/SKILL.md +198 -0
  88. package/skills/sentry/lib/auth.js +99 -0
  89. package/skills/sentry/scripts/fetch-event.js +325 -0
  90. package/skills/sentry/scripts/fetch-issue.js +368 -0
  91. package/skills/sentry/scripts/list-issues.js +245 -0
  92. package/skills/sentry/scripts/search-events.js +291 -0
  93. package/skills/sentry/scripts/search-logs.js +234 -0
  94. package/skills/update-changelog/SKILL.md +135 -0
  95. package/skills/web-design/SKILL.md +117 -0
@@ -0,0 +1,2578 @@
1
+ /**
2
+ * Sandbox Extension - OS-level sandboxing for bash commands
3
+ *
4
+ * Uses @anthropic-ai/sandbox-runtime to enforce filesystem and network
5
+ * restrictions on bash commands at the OS level (sandbox-exec on macOS,
6
+ * bubblewrap on Linux).
7
+ *
8
+ * Config files (merged, project takes precedence):
9
+ * - ~/.pi/agent/sandbox.json (global)
10
+ * - <cwd>/.pi/sandbox.json (project-local)
11
+ *
12
+ * If `--sandbox-config <path>` is provided, that file replaces the global/project
13
+ * config files for the session. Relative paths resolve from the session cwd.
14
+ *
15
+ * Note: list fields are overridden (replaced), not concatenated.
16
+ *
17
+ * Example .pi/sandbox.json:
18
+ * ```json
19
+ * {
20
+ * "enabled": true,
21
+ * "mode": "interactive",
22
+ * "network": {
23
+ * "allowedDomains": ["github.com", "*.github.com"],
24
+ * "deniedDomains": []
25
+ * },
26
+ * "filesystem": {
27
+ * "denyRead": ["~/.ssh", "~/.aws"],
28
+ * "allowWrite": [".", "/tmp"],
29
+ * "denyWrite": [".env"],
30
+ * "allowGitCommonDir": true
31
+ * }
32
+ * }
33
+ * ```
34
+ *
35
+ * Usage:
36
+ * - `pi -e ./sandbox` - sandbox enabled with default/config settings
37
+ * - `pi -e ./sandbox --no-sandbox` - disable sandboxing
38
+ * - `pi -e ./sandbox --sandbox-config ./sandbox.json` - use a custom sandbox config file
39
+ * - `/sandbox` - show command help
40
+ *
41
+ * Setup for source checkouts:
42
+ * - Run `npm install` from the repository root.
43
+ *
44
+ * macOS also requires: ripgrep
45
+ * Linux also requires: bubblewrap, socat, ripgrep
46
+ */
47
+
48
+ import { spawn, spawnSync } from "node:child_process";
49
+ import { existsSync, readFileSync } from "node:fs";
50
+ import { homedir } from "node:os";
51
+ import { join, resolve } from "node:path";
52
+ import {
53
+ SandboxManager,
54
+ type SandboxAskCallback,
55
+ type SandboxRuntimeConfig,
56
+ } from "@anthropic-ai/sandbox-runtime";
57
+ // Deep import for parity with sandbox-runtime's own path normalization and glob matching.
58
+ // The public package exports do not expose these helpers, but using the same internals keeps
59
+ // the extension's policy checks aligned with the runtime's actual sandbox behavior.
60
+ import {
61
+ containsGlobChars,
62
+ globToRegex,
63
+ normalizePathForSandbox,
64
+ } from "@anthropic-ai/sandbox-runtime/dist/sandbox/sandbox-utils.js";
65
+ import type { ExtensionAPI, ExtensionContext } from "@mariozechner/pi-coding-agent";
66
+ import { type BashOperations, createBashTool } from "@mariozechner/pi-coding-agent";
67
+
68
+ // --- Constants ---
69
+
70
+ const DEFAULT_PROMPT_MODE: PromptMode = "interactive";
71
+
72
+ const DEFAULT_CONFIG: SandboxConfig = {
73
+ enabled: true,
74
+ mode: DEFAULT_PROMPT_MODE,
75
+ network: {
76
+ allowedDomains: [
77
+ "npmjs.org",
78
+ "*.npmjs.org",
79
+ "registry.npmjs.org",
80
+ "registry.yarnpkg.com",
81
+ "pypi.org",
82
+ "*.pypi.org",
83
+ "github.com",
84
+ "*.github.com",
85
+ "api.github.com",
86
+ "raw.githubusercontent.com",
87
+ ],
88
+ deniedDomains: [],
89
+ },
90
+ filesystem: {
91
+ denyRead: ["~/.ssh", "~/.aws", "~/.gnupg"],
92
+ allowWrite: [".", "/tmp"],
93
+ denyWrite: [".env", ".env.*", "*.pem", "*.key"],
94
+ allowGitCommonDir: false,
95
+ },
96
+ };
97
+
98
+ const STATUS_KEY = "sandbox";
99
+ const SANDBOX_EVENT_LIMIT = 50;
100
+ const METADATA_TRAVERSAL_PROCESSES = new Set(["find", "ls", "fd", "fdfind"]);
101
+ const GIT_FILESYSTEM_PATHS_CACHE = new Map<string, GitFilesystemPaths | null>();
102
+
103
+ // --- Types ---
104
+
105
+ type PromptMode = "interactive" | "non-interactive";
106
+
107
+ type SandboxBypassReason = "no-sandbox-flag" | "config-disabled" | "missing-dependencies";
108
+ type SandboxBlockedReason = "unsupported-platform" | "init-failed";
109
+
110
+ type SandboxRunMode = "sandbox" | "user-disabled" | SandboxBypassReason | SandboxBlockedReason;
111
+
112
+ type SandboxState =
113
+ | { status: "pending" }
114
+ | { status: "active"; runtimeConfig: SandboxRuntimeConfig }
115
+ | { status: "suspended"; runtimeConfig: SandboxRuntimeConfig }
116
+ | { status: "bypassed"; reason: SandboxBypassReason }
117
+ | { status: "blocked"; reason: SandboxBlockedReason };
118
+
119
+ type SandboxConfig = Omit<SandboxRuntimeConfig, "filesystem"> & {
120
+ enabled?: boolean;
121
+ mode?: PromptMode;
122
+ filesystem: SandboxRuntimeConfig["filesystem"] & {
123
+ allowGitCommonDir?: boolean;
124
+ };
125
+ };
126
+
127
+ type SandboxEventKind = "filesystem" | "network" | "init" | "runtime";
128
+ type SandboxEventReason =
129
+ | "explicit-deny-read"
130
+ | "explicit-deny-write"
131
+ | "explicit-deny-domain"
132
+ | "missing-allow-write"
133
+ | "missing-allowed-domain"
134
+ | "missing-dependencies"
135
+ | "unsupported-platform"
136
+ | "init-failed"
137
+ | "already-approved-still-failed"
138
+ | "unknown";
139
+ type SandboxEventOutcome = "blocked" | "allowed";
140
+ type SandboxConfigPathStatus = "loaded" | "parse-error";
141
+ type SandboxConfigPathLabel = "Global" | "Project" | "Override";
142
+
143
+ type PromptStatus = "completed" | "error";
144
+ type UiLevel = "info" | "warning" | "error";
145
+
146
+ type ListOp = "add" | "remove";
147
+ type NetworkList = "allow" | "deny";
148
+ type FilesystemList = "deny-read" | "allow-write" | "deny-write";
149
+
150
+ type FilesystemViolationKind = "read" | "write" | "unknown";
151
+ type FilesystemReadAccess = "metadata" | "data" | "unknown";
152
+ type FilesystemViolationResolutionKind = "allow-retry" | "allow-adapt" | "deny";
153
+
154
+ interface SandboxEvent {
155
+ timestamp: number;
156
+ kind: SandboxEventKind;
157
+ outcome: SandboxEventOutcome;
158
+ reason: SandboxEventReason;
159
+ target?: string;
160
+ command?: string;
161
+ cwd?: string;
162
+ summary: string;
163
+ suggestedCommand?: string;
164
+ }
165
+
166
+ interface SandboxConfigPath {
167
+ label: SandboxConfigPathLabel;
168
+ path: string;
169
+ status: SandboxConfigPathStatus;
170
+ }
171
+
172
+ interface LoadedSandboxConfig {
173
+ config: SandboxConfig;
174
+ paths: SandboxConfigPath[];
175
+ }
176
+
177
+ interface GitFilesystemPaths {
178
+ gitDir: string;
179
+ gitCommonDir: string;
180
+ }
181
+
182
+ type SandboxConfigLoadErrorKind = "not-found" | "read-failed" | "parse-error";
183
+
184
+ class SandboxConfigLoadError extends Error {
185
+ readonly kind: SandboxConfigLoadErrorKind;
186
+ readonly path: string;
187
+
188
+ constructor(kind: SandboxConfigLoadErrorKind, path: string, detail?: string) {
189
+ super(formatSandboxConfigLoadErrorMessage(kind, path, detail));
190
+ this.name = "SandboxConfigLoadError";
191
+ this.kind = kind;
192
+ this.path = path;
193
+ }
194
+ }
195
+
196
+ interface FilesystemViolation {
197
+ kind: FilesystemViolationKind;
198
+ path?: string;
199
+ processName?: string;
200
+ readAccess?: FilesystemReadAccess;
201
+ }
202
+
203
+ type FilesystemViolationResolution =
204
+ | {
205
+ kind: "allow-retry";
206
+ message: string;
207
+ retrySuccessMessage: string;
208
+ retryFailureMessage: string;
209
+ retrySkippedMessage: string;
210
+ }
211
+ | { kind: "allow-adapt"; message: string }
212
+ | { kind: "deny"; message: string };
213
+
214
+ // --- Helpers ---
215
+
216
+ async function withPromptSignal<T>(pi: ExtensionAPI, run: () => Promise<T>): Promise<T> {
217
+ pi.events.emit("ui:prompt_start", { source: "sandbox" });
218
+
219
+ let status: PromptStatus = "completed";
220
+ try {
221
+ return await run();
222
+ } catch (error) {
223
+ status = "error";
224
+ throw error;
225
+ } finally {
226
+ pi.events.emit("ui:prompt_end", { source: "sandbox", status });
227
+ }
228
+ }
229
+
230
+ function normalizePromptMode(value: unknown): PromptMode {
231
+ return value === "non-interactive" ? "non-interactive" : "interactive";
232
+ }
233
+
234
+ function setSandboxStatus(
235
+ ctx: ExtensionContext,
236
+ enabled: boolean,
237
+ runtimeConfig?: SandboxRuntimeConfig,
238
+ promptMode: PromptMode = DEFAULT_PROMPT_MODE,
239
+ ): void {
240
+ if (!ctx.hasUI) return;
241
+
242
+ if (!enabled || !runtimeConfig) {
243
+ ctx.ui.setStatus(STATUS_KEY, undefined);
244
+ return;
245
+ }
246
+
247
+ const networkCount = runtimeConfig.network.allowedDomains.length;
248
+ const writeCount = runtimeConfig.filesystem.allowWrite.length;
249
+ const text = `sandbox (${promptMode}, ${networkCount} domains, ${writeCount} write paths)`;
250
+ ctx.ui.setStatus(STATUS_KEY, ctx.ui.theme.fg("dim", text));
251
+ }
252
+
253
+ function notify(ctx: ExtensionContext, text: string, level: UiLevel = "info"): void {
254
+ if (ctx.hasUI) {
255
+ ctx.ui.notify(text, level);
256
+ return;
257
+ }
258
+
259
+ if (level === "error" || level === "warning") console.error(text);
260
+ else console.log(text);
261
+ }
262
+
263
+ function showHelp(ctx: ExtensionContext): void {
264
+ const lines = [
265
+ "Usage:",
266
+ " /sandbox enable|on",
267
+ " /sandbox disable|off",
268
+ " /sandbox show",
269
+ " /sandbox doctor",
270
+ " /sandbox mode <interactive|non-interactive>",
271
+ " /sandbox network <allow|deny> <add|remove> <domain>",
272
+ " /sandbox filesystem <deny-read|allow-write|deny-write> <add|remove> <path>",
273
+ "",
274
+ "Startup flags:",
275
+ " --no-sandbox",
276
+ " --sandbox-config <path>",
277
+ ];
278
+ notify(ctx, lines.join("\n"), "info");
279
+ }
280
+
281
+ function parseCommandArgs(args?: string): string[] {
282
+ if (!args?.trim()) return [];
283
+
284
+ const input = args.trim();
285
+ const tokens: string[] = [];
286
+ const tokenPattern = /"((?:\\.|[^"\\])*)"|'([^']*)'|((?:\\.|[^\s])+)/g;
287
+
288
+ for (const match of input.matchAll(tokenPattern)) {
289
+ if (match[1] !== undefined) {
290
+ tokens.push(match[1].replace(/\\(.)/g, "$1"));
291
+ } else if (match[2] !== undefined) {
292
+ tokens.push(match[2]);
293
+ } else if (match[3] !== undefined) {
294
+ tokens.push(match[3].replace(/\\(.)/g, "$1"));
295
+ }
296
+ }
297
+
298
+ return tokens;
299
+ }
300
+
301
+ function normalizeSubcommand(token?: string): string | undefined {
302
+ switch (token?.toLowerCase()) {
303
+ case "on":
304
+ return "enable";
305
+ case "off":
306
+ return "disable";
307
+ default:
308
+ return token?.toLowerCase();
309
+ }
310
+ }
311
+
312
+ type CommandCompletionOption = {
313
+ value: string;
314
+ label?: string;
315
+ description?: string;
316
+ };
317
+
318
+ const SANDBOX_TOP_LEVEL_COMPLETIONS: CommandCompletionOption[] = [
319
+ { value: "enable", label: "enable" },
320
+ { value: "on", label: "on" },
321
+ { value: "disable", label: "disable" },
322
+ { value: "off", label: "off" },
323
+ { value: "show", label: "show" },
324
+ { value: "doctor", label: "doctor" },
325
+ { value: "mode ", label: "mode" },
326
+ { value: "network ", label: "network" },
327
+ { value: "filesystem ", label: "filesystem" },
328
+ { value: "help", label: "help" },
329
+ ];
330
+
331
+ const SANDBOX_MODE_COMPLETIONS: CommandCompletionOption[] = [
332
+ { value: "interactive", label: "interactive" },
333
+ { value: "non-interactive", label: "non-interactive" },
334
+ ];
335
+
336
+ const SANDBOX_NETWORK_LIST_COMPLETIONS: CommandCompletionOption[] = [
337
+ { value: "allow ", label: "allow" },
338
+ { value: "deny ", label: "deny" },
339
+ ];
340
+
341
+ const SANDBOX_FILESYSTEM_LIST_COMPLETIONS: CommandCompletionOption[] = [
342
+ { value: "deny-read ", label: "deny-read" },
343
+ { value: "allow-write ", label: "allow-write" },
344
+ { value: "deny-write ", label: "deny-write" },
345
+ ];
346
+
347
+ const SANDBOX_LIST_OPERATION_COMPLETIONS: CommandCompletionOption[] = [
348
+ { value: "add ", label: "add" },
349
+ { value: "remove ", label: "remove" },
350
+ ];
351
+
352
+ function normalizeCompletionFilter(value: string): string {
353
+ return value.trim().replace(/^['"]/, "").toLowerCase();
354
+ }
355
+
356
+ function getCommandCompletions(
357
+ base: string,
358
+ partial: string,
359
+ options: CommandCompletionOption[],
360
+ ): Array<{ value: string; label: string; description?: string }> | null {
361
+ const normalizedPartial = normalizeCompletionFilter(partial);
362
+ const matches = options.filter((option) => {
363
+ const label = option.label ?? option.value.trimEnd();
364
+ return label.toLowerCase().startsWith(normalizedPartial);
365
+ });
366
+ if (matches.length === 0) return null;
367
+
368
+ return matches.map((option) => ({
369
+ value: `${base}${option.value}`,
370
+ label: option.label ?? option.value.trimEnd(),
371
+ ...(option.description ? { description: option.description } : {}),
372
+ }));
373
+ }
374
+
375
+ function getStringValueCompletions(
376
+ base: string,
377
+ partial: string,
378
+ values: string[],
379
+ ): Array<{ value: string; label: string }> | null {
380
+ const normalizedPartial = normalizeCompletionFilter(partial);
381
+ const matches = Array.from(new Set(values)).filter((value) =>
382
+ value.toLowerCase().startsWith(normalizedPartial),
383
+ );
384
+ if (matches.length === 0) return null;
385
+
386
+ return matches.map((value) => ({
387
+ value: `${base}${escapeSlashCommandArg(value)}`,
388
+ label: value,
389
+ }));
390
+ }
391
+
392
+ function getSandboxArgumentCompletions(
393
+ prefix: string,
394
+ runtimeConfig: SandboxRuntimeConfig | null,
395
+ ): Array<{ value: string; label: string; description?: string }> | null {
396
+ const endsWithSpace = /\s$/.test(prefix);
397
+ const tokens = parseCommandArgs(prefix);
398
+
399
+ if (tokens.length === 0) {
400
+ return getCommandCompletions("", "", SANDBOX_TOP_LEVEL_COMPLETIONS);
401
+ }
402
+
403
+ if (tokens.length === 1 && !endsWithSpace) {
404
+ return getCommandCompletions("", tokens[0] ?? "", SANDBOX_TOP_LEVEL_COMPLETIONS);
405
+ }
406
+
407
+ const subcommand = normalizeSubcommand(tokens[0]);
408
+ if (!subcommand) return null;
409
+
410
+ if (subcommand === "mode") {
411
+ if (tokens.length === 1 && endsWithSpace) {
412
+ return getCommandCompletions("mode ", "", SANDBOX_MODE_COMPLETIONS);
413
+ }
414
+ if (tokens.length === 2 && !endsWithSpace) {
415
+ return getCommandCompletions("mode ", tokens[1] ?? "", SANDBOX_MODE_COMPLETIONS);
416
+ }
417
+ return null;
418
+ }
419
+
420
+ if (subcommand === "network") {
421
+ if (tokens.length === 1 && endsWithSpace) {
422
+ return getCommandCompletions("network ", "", SANDBOX_NETWORK_LIST_COMPLETIONS);
423
+ }
424
+ if (tokens.length === 2 && !endsWithSpace) {
425
+ return getCommandCompletions("network ", tokens[1] ?? "", SANDBOX_NETWORK_LIST_COMPLETIONS);
426
+ }
427
+
428
+ const list = tokens[1]?.toLowerCase();
429
+ if (list !== "allow" && list !== "deny") return null;
430
+
431
+ const listBase = `network ${list} `;
432
+ if (tokens.length === 2 && endsWithSpace) {
433
+ return getCommandCompletions(listBase, "", SANDBOX_LIST_OPERATION_COMPLETIONS);
434
+ }
435
+ if (tokens.length === 3 && !endsWithSpace) {
436
+ return getCommandCompletions(listBase, tokens[2] ?? "", SANDBOX_LIST_OPERATION_COMPLETIONS);
437
+ }
438
+
439
+ if (tokens[2]?.toLowerCase() !== "remove") return null;
440
+
441
+ const values =
442
+ list === "allow"
443
+ ? (runtimeConfig?.network.allowedDomains ?? [])
444
+ : (runtimeConfig?.network.deniedDomains ?? []);
445
+ const valueBase = `${listBase}remove `;
446
+ if (tokens.length === 3 && endsWithSpace) {
447
+ return getStringValueCompletions(valueBase, "", values);
448
+ }
449
+ if (tokens.length === 4 && !endsWithSpace) {
450
+ return getStringValueCompletions(valueBase, tokens[3] ?? "", values);
451
+ }
452
+ return null;
453
+ }
454
+
455
+ if (subcommand === "filesystem") {
456
+ if (tokens.length === 1 && endsWithSpace) {
457
+ return getCommandCompletions("filesystem ", "", SANDBOX_FILESYSTEM_LIST_COMPLETIONS);
458
+ }
459
+ if (tokens.length === 2 && !endsWithSpace) {
460
+ return getCommandCompletions(
461
+ "filesystem ",
462
+ tokens[1] ?? "",
463
+ SANDBOX_FILESYSTEM_LIST_COMPLETIONS,
464
+ );
465
+ }
466
+
467
+ const list = tokens[1]?.toLowerCase() as FilesystemList | undefined;
468
+ if (list !== "deny-read" && list !== "allow-write" && list !== "deny-write") {
469
+ return null;
470
+ }
471
+
472
+ const listBase = `filesystem ${list} `;
473
+ if (tokens.length === 2 && endsWithSpace) {
474
+ return getCommandCompletions(listBase, "", SANDBOX_LIST_OPERATION_COMPLETIONS);
475
+ }
476
+ if (tokens.length === 3 && !endsWithSpace) {
477
+ return getCommandCompletions(listBase, tokens[2] ?? "", SANDBOX_LIST_OPERATION_COMPLETIONS);
478
+ }
479
+
480
+ if (tokens[2]?.toLowerCase() !== "remove") return null;
481
+
482
+ const values =
483
+ list === "deny-read"
484
+ ? (runtimeConfig?.filesystem.denyRead ?? [])
485
+ : list === "allow-write"
486
+ ? (runtimeConfig?.filesystem.allowWrite ?? [])
487
+ : (runtimeConfig?.filesystem.denyWrite ?? []);
488
+ const valueBase = `${listBase}remove `;
489
+ if (tokens.length === 3 && endsWithSpace) {
490
+ return getStringValueCompletions(valueBase, "", values);
491
+ }
492
+ if (tokens.length === 4 && !endsWithSpace) {
493
+ return getStringValueCompletions(valueBase, tokens[3] ?? "", values);
494
+ }
495
+ return null;
496
+ }
497
+
498
+ return null;
499
+ }
500
+
501
+ function getStringFlag(pi: ExtensionAPI, name: string): string | undefined {
502
+ const value = pi.getFlag(name);
503
+ if (typeof value !== "string") return undefined;
504
+
505
+ const trimmed = value.trim();
506
+ return trimmed.length > 0 ? trimmed : undefined;
507
+ }
508
+
509
+ function expandPath(value: string, cwd?: string): string {
510
+ const expanded =
511
+ value === "~" ? homedir() : value.startsWith("~/") ? join(homedir(), value.slice(2)) : value;
512
+ return cwd && !expanded.startsWith("/") ? resolve(cwd, expanded) : expanded;
513
+ }
514
+
515
+ function formatSandboxConfigLoadErrorMessage(
516
+ kind: SandboxConfigLoadErrorKind,
517
+ path: string,
518
+ detail?: string,
519
+ ): string {
520
+ if (kind === "not-found") {
521
+ return `Sandbox override config not found: ${path}`;
522
+ }
523
+
524
+ if (kind === "parse-error") {
525
+ return `Could not parse sandbox override config ${path}: ${detail ?? "invalid JSON"}`;
526
+ }
527
+
528
+ return `Could not read sandbox override config ${path}: ${detail ?? "unknown error"}`;
529
+ }
530
+
531
+ function coerceStringArray(value: unknown, fallback: string[], field: string): string[] {
532
+ if (!Array.isArray(value)) {
533
+ console.error(`Warning: Expected ${field} to be a string[]; using defaults.`);
534
+ return [...fallback];
535
+ }
536
+
537
+ const cleaned = value
538
+ .filter((item): item is string => typeof item === "string")
539
+ .map((item) => item.trim())
540
+ .filter((item) => item.length > 0);
541
+
542
+ const droppedCount = value.length - cleaned.length;
543
+ if (droppedCount > 0) {
544
+ console.error(`Warning: Ignoring ${droppedCount} invalid values in ${field}.`);
545
+ }
546
+
547
+ return cleaned;
548
+ }
549
+
550
+ function finalizeConfig(config: SandboxConfig): SandboxConfig {
551
+ return {
552
+ ...config,
553
+ enabled: typeof config.enabled === "boolean" ? config.enabled : DEFAULT_CONFIG.enabled,
554
+ mode: normalizePromptMode(config.mode),
555
+ network: {
556
+ ...config.network,
557
+ allowedDomains: coerceStringArray(
558
+ config.network?.allowedDomains,
559
+ DEFAULT_CONFIG.network.allowedDomains,
560
+ "network.allowedDomains",
561
+ ),
562
+ deniedDomains: coerceStringArray(
563
+ config.network?.deniedDomains,
564
+ DEFAULT_CONFIG.network.deniedDomains,
565
+ "network.deniedDomains",
566
+ ),
567
+ },
568
+ filesystem: {
569
+ ...config.filesystem,
570
+ denyRead: coerceStringArray(
571
+ config.filesystem?.denyRead,
572
+ DEFAULT_CONFIG.filesystem.denyRead,
573
+ "filesystem.denyRead",
574
+ ),
575
+ allowWrite: coerceStringArray(
576
+ config.filesystem?.allowWrite,
577
+ DEFAULT_CONFIG.filesystem.allowWrite,
578
+ "filesystem.allowWrite",
579
+ ),
580
+ denyWrite: coerceStringArray(
581
+ config.filesystem?.denyWrite,
582
+ DEFAULT_CONFIG.filesystem.denyWrite,
583
+ "filesystem.denyWrite",
584
+ ),
585
+ allowGitCommonDir:
586
+ typeof config.filesystem?.allowGitCommonDir === "boolean"
587
+ ? config.filesystem.allowGitCommonDir
588
+ : DEFAULT_CONFIG.filesystem.allowGitCommonDir,
589
+ },
590
+ };
591
+ }
592
+
593
+ function loadOverrideConfig(cwd: string, overrideConfigPath: string): LoadedSandboxConfig {
594
+ const resolvedPath = expandPath(overrideConfigPath, cwd);
595
+
596
+ let source: string;
597
+ try {
598
+ source = readFileSync(resolvedPath, "utf-8");
599
+ } catch (error) {
600
+ const code =
601
+ error instanceof Error && "code" in error && typeof error.code === "string"
602
+ ? error.code
603
+ : undefined;
604
+ if (code === "ENOENT") {
605
+ throw new SandboxConfigLoadError("not-found", resolvedPath);
606
+ }
607
+
608
+ throw new SandboxConfigLoadError(
609
+ "read-failed",
610
+ resolvedPath,
611
+ error instanceof Error ? error.message : `${error}`,
612
+ );
613
+ }
614
+
615
+ let overrideConfig: Partial<SandboxConfig>;
616
+ try {
617
+ overrideConfig = JSON.parse(source);
618
+ } catch (error) {
619
+ throw new SandboxConfigLoadError(
620
+ "parse-error",
621
+ resolvedPath,
622
+ error instanceof Error ? error.message : `${error}`,
623
+ );
624
+ }
625
+
626
+ return {
627
+ config: finalizeConfig(deepMerge(DEFAULT_CONFIG, overrideConfig)),
628
+ paths: [{ label: "Override", path: resolvedPath, status: "loaded" }],
629
+ };
630
+ }
631
+
632
+ function loadConfig(cwd: string, overrideConfigPath?: string): LoadedSandboxConfig {
633
+ if (overrideConfigPath) {
634
+ return loadOverrideConfig(cwd, overrideConfigPath);
635
+ }
636
+
637
+ const projectConfigPath = join(cwd, ".pi", "sandbox.json");
638
+ const globalConfigPath = join(homedir(), ".pi", "agent", "sandbox.json");
639
+
640
+ let globalConfig: Partial<SandboxConfig> = {};
641
+ let projectConfig: Partial<SandboxConfig> = {};
642
+ const paths: SandboxConfigPath[] = [];
643
+
644
+ if (existsSync(globalConfigPath)) {
645
+ try {
646
+ globalConfig = JSON.parse(readFileSync(globalConfigPath, "utf-8"));
647
+ paths.push({ label: "Global", path: globalConfigPath, status: "loaded" });
648
+ } catch (e) {
649
+ paths.push({ label: "Global", path: globalConfigPath, status: "parse-error" });
650
+ console.error(`Warning: Could not parse ${globalConfigPath}: ${e}`);
651
+ }
652
+ }
653
+
654
+ if (existsSync(projectConfigPath)) {
655
+ try {
656
+ projectConfig = JSON.parse(readFileSync(projectConfigPath, "utf-8"));
657
+ paths.push({ label: "Project", path: projectConfigPath, status: "loaded" });
658
+ } catch (e) {
659
+ paths.push({ label: "Project", path: projectConfigPath, status: "parse-error" });
660
+ console.error(`Warning: Could not parse ${projectConfigPath}: ${e}`);
661
+ }
662
+ }
663
+
664
+ const merged = deepMerge(deepMerge(DEFAULT_CONFIG, globalConfig), projectConfig);
665
+
666
+ return {
667
+ config: finalizeConfig(merged),
668
+ paths,
669
+ };
670
+ }
671
+
672
+ function isPlainObject(value: unknown): value is Record<string, unknown> {
673
+ return typeof value === "object" && value !== null && !Array.isArray(value);
674
+ }
675
+
676
+ function deepMerge(base: SandboxConfig, overrides: Partial<SandboxConfig>): SandboxConfig {
677
+ const result: SandboxConfig = { ...base };
678
+
679
+ if (overrides.enabled !== undefined) result.enabled = overrides.enabled;
680
+ if (overrides.mode !== undefined) {
681
+ result.mode = normalizePromptMode(overrides.mode);
682
+ }
683
+ if (isPlainObject(overrides.network)) {
684
+ result.network = {
685
+ ...base.network,
686
+ ...(overrides.network as Partial<SandboxRuntimeConfig["network"]>),
687
+ };
688
+ }
689
+ if (isPlainObject(overrides.filesystem)) {
690
+ result.filesystem = {
691
+ ...base.filesystem,
692
+ ...(overrides.filesystem as Partial<SandboxConfig["filesystem"]>),
693
+ };
694
+ }
695
+ if (overrides.ignoreViolations !== undefined) {
696
+ result.ignoreViolations = overrides.ignoreViolations;
697
+ }
698
+ if (overrides.enableWeakerNestedSandbox !== undefined) {
699
+ result.enableWeakerNestedSandbox = overrides.enableWeakerNestedSandbox;
700
+ }
701
+ if (overrides.enableWeakerNetworkIsolation !== undefined) {
702
+ result.enableWeakerNetworkIsolation = overrides.enableWeakerNetworkIsolation;
703
+ }
704
+
705
+ return result;
706
+ }
707
+
708
+ function toRuntimeConfig(config: SandboxConfig): SandboxRuntimeConfig {
709
+ const { allowGitCommonDir: _allowGitCommonDir, ...filesystem } = config.filesystem;
710
+
711
+ return {
712
+ network: config.network,
713
+ filesystem,
714
+ ignoreViolations: config.ignoreViolations,
715
+ enableWeakerNestedSandbox: config.enableWeakerNestedSandbox,
716
+ enableWeakerNetworkIsolation: config.enableWeakerNetworkIsolation,
717
+ };
718
+ }
719
+
720
+ function cloneRuntimeConfig(config: SandboxRuntimeConfig): SandboxRuntimeConfig {
721
+ return structuredClone(config);
722
+ }
723
+
724
+ function normalizeSandboxPath(value: string, cwd?: string): string {
725
+ return normalizePathForSandbox(expandPath(value, cwd));
726
+ }
727
+
728
+ function matchesSandboxRule(path: string, rule: string, cwd?: string): boolean {
729
+ const normalizedPath = normalizeSandboxPath(path);
730
+ const normalizedRule = normalizeSandboxPath(rule, cwd);
731
+
732
+ if (containsGlobChars(rule)) {
733
+ return new RegExp(globToRegex(normalizedRule)).test(normalizedPath);
734
+ }
735
+
736
+ if (normalizedPath === normalizedRule) return true;
737
+
738
+ const prefix = normalizedRule.endsWith("/") ? normalizedRule : `${normalizedRule}/`;
739
+ return normalizedPath.startsWith(prefix);
740
+ }
741
+
742
+ function inferSandboxRuleMatch(path: string, rules: string[], cwd?: string): string | null {
743
+ for (const rule of rules) {
744
+ if (matchesSandboxRule(path, rule, cwd)) return rule;
745
+ }
746
+
747
+ return null;
748
+ }
749
+
750
+ function isSandboxWritablePath(
751
+ runtimeConfig: SandboxRuntimeConfig,
752
+ path: string,
753
+ cwd?: string,
754
+ ): boolean {
755
+ if (!inferSandboxRuleMatch(path, runtimeConfig.filesystem.allowWrite, cwd)) return false;
756
+ return inferSandboxRuleMatch(path, runtimeConfig.filesystem.denyWrite, cwd) === null;
757
+ }
758
+
759
+ function resolveGitFilesystemPaths(cwd: string): GitFilesystemPaths | null {
760
+ if (GIT_FILESYSTEM_PATHS_CACHE.has(cwd)) {
761
+ return GIT_FILESYSTEM_PATHS_CACHE.get(cwd) ?? null;
762
+ }
763
+
764
+ const result = spawnSync(
765
+ "git",
766
+ ["rev-parse", "--path-format=absolute", "--git-dir", "--git-common-dir"],
767
+ {
768
+ cwd,
769
+ encoding: "utf-8",
770
+ stdio: ["ignore", "pipe", "ignore"],
771
+ },
772
+ );
773
+
774
+ if (result.status !== 0) {
775
+ GIT_FILESYSTEM_PATHS_CACHE.set(cwd, null);
776
+ return null;
777
+ }
778
+
779
+ const [gitDir, gitCommonDir] = result.stdout
780
+ .split("\n")
781
+ .map((line) => line.trim())
782
+ .filter(Boolean);
783
+
784
+ if (!gitDir || !gitCommonDir) {
785
+ GIT_FILESYSTEM_PATHS_CACHE.set(cwd, null);
786
+ return null;
787
+ }
788
+
789
+ const gitPaths = { gitDir, gitCommonDir };
790
+ GIT_FILESYSTEM_PATHS_CACHE.set(cwd, gitPaths);
791
+ return gitPaths;
792
+ }
793
+
794
+ function maybeAddGitMetadataWritePath(
795
+ runtimeConfig: SandboxRuntimeConfig,
796
+ path: string,
797
+ cwd?: string,
798
+ ): SandboxRuntimeConfig | null {
799
+ if (isSandboxWritablePath(runtimeConfig, path, cwd)) return null;
800
+ if (inferSandboxRuleMatch(path, runtimeConfig.filesystem.denyWrite, cwd)) return null;
801
+
802
+ const nextConfig = cloneRuntimeConfig(runtimeConfig);
803
+ if (!mutateStringList(nextConfig.filesystem.allowWrite, "add", path)) return null;
804
+ return nextConfig;
805
+ }
806
+
807
+ function extractSandboxViolationLines(output: string): string[] {
808
+ // sandbox-runtime annotateStderrWithSandboxFailures wraps violations in this tag.
809
+ const match = output.match(/<sandbox_violations>([\s\S]*?)<\/sandbox_violations>/i);
810
+ if (!match?.[1]) return [];
811
+
812
+ return match[1]
813
+ .split("\n")
814
+ .map((line) => line.trim())
815
+ .filter(Boolean);
816
+ }
817
+
818
+ function stripSandboxViolationAnnotations(text: string): string {
819
+ return text
820
+ .replace(/\n?<sandbox_violations>[\s\S]*?<\/sandbox_violations>\n?/gi, "\n")
821
+ .replace(/^\n+|\n+$/g, "");
822
+ }
823
+
824
+ function extractAppendedSandboxAnnotation(
825
+ original: string,
826
+ annotated: string,
827
+ skipViolationLines = 0,
828
+ ): string {
829
+ if (annotated === original) return "";
830
+
831
+ if (annotated.startsWith(original)) {
832
+ return stripSandboxViolationAnnotations(annotated.slice(original.length));
833
+ }
834
+
835
+ const violationLines = extractSandboxViolationLines(annotated);
836
+ const newViolationLines =
837
+ skipViolationLines > 0
838
+ ? violationLines.slice(Math.min(skipViolationLines, violationLines.length))
839
+ : violationLines;
840
+ if (newViolationLines.length === 0) return "";
841
+
842
+ // Filesystem and network sandbox violations are summarized elsewhere via compact
843
+ // extension messages, so suppress the verbose synthetic annotation block.
844
+ return "";
845
+ }
846
+
847
+ function sanitizeExtractedPath(path: string): string | undefined {
848
+ const trimmed = path.trim();
849
+ if (!trimmed) return undefined;
850
+
851
+ const withoutDelimiter = trimmed.replace(/:+$/g, "");
852
+ return withoutDelimiter.length > 0 ? withoutDelimiter : undefined;
853
+ }
854
+
855
+ function extractPathLikeValueFromLine(line: string): string | undefined {
856
+ const sandboxViolationMatch = line.match(/\bfile-(?:read|write)[^\s]*\s+((?:~\/|\/).+)$/i);
857
+ if (sandboxViolationMatch?.[1]) return sanitizeExtractedPath(sandboxViolationMatch[1]);
858
+
859
+ const operationNotPermittedMatch = line.match(
860
+ /^(?:[^:\n]+:\s+)*((?:~\/|\/).+?):\s+Operation not permitted$/i,
861
+ );
862
+ if (operationNotPermittedMatch?.[1]) return sanitizeExtractedPath(operationNotPermittedMatch[1]);
863
+
864
+ const quotedPathMatch = line.match(/["']((?:~\/|\/)[^"']+)["']/);
865
+ if (quotedPathMatch?.[1]) return sanitizeExtractedPath(quotedPathMatch[1]);
866
+
867
+ const rawPathMatch = line.match(/((?:~\/|\/)[^\s,)]+)/);
868
+ if (rawPathMatch?.[1]) return sanitizeExtractedPath(rawPathMatch[1]);
869
+
870
+ return undefined;
871
+ }
872
+
873
+ function extractPathLikeValue(text: string): string | undefined {
874
+ const lines = text
875
+ .split("\n")
876
+ .map((line) => line.trim())
877
+ .filter(Boolean);
878
+
879
+ for (let index = lines.length - 1; index >= 0; index -= 1) {
880
+ const path = extractPathLikeValueFromLine(lines[index]);
881
+ if (path) return path;
882
+ }
883
+
884
+ return undefined;
885
+ }
886
+
887
+ function extractViolationProcessName(line: string): string | undefined {
888
+ const match = line.match(/^([^\s(]+)\(/);
889
+ return match?.[1]?.trim() || undefined;
890
+ }
891
+
892
+ function detectFilesystemViolationFromLine(line: string): FilesystemViolation | null {
893
+ // Runtime emits concrete op variants (e.g. file-write-create/unlink, file-read-data).
894
+ const lower = line.toLowerCase();
895
+ const path = extractPathLikeValue(line);
896
+ const processName = extractViolationProcessName(line);
897
+
898
+ if (lower.includes("file-write")) {
899
+ return { kind: "write", path, processName };
900
+ }
901
+
902
+ if (lower.includes("file-read-metadata")) {
903
+ return { kind: "read", path, processName, readAccess: "metadata" };
904
+ }
905
+
906
+ if (lower.includes("file-read-data")) {
907
+ return { kind: "read", path, processName, readAccess: "data" };
908
+ }
909
+
910
+ if (lower.includes("file-read")) {
911
+ return { kind: "read", path, processName, readAccess: "unknown" };
912
+ }
913
+
914
+ return null;
915
+ }
916
+
917
+ function detectFilesystemViolations(
918
+ output: string,
919
+ fallbackOutput: string = output,
920
+ skipViolationLines = 0,
921
+ ): FilesystemViolation[] {
922
+ const violations: FilesystemViolation[] = [];
923
+ const allViolationLines = extractSandboxViolationLines(output);
924
+ const violationLines =
925
+ skipViolationLines > 0
926
+ ? allViolationLines.slice(Math.min(skipViolationLines, allViolationLines.length))
927
+ : allViolationLines;
928
+
929
+ for (let index = violationLines.length - 1; index >= 0; index -= 1) {
930
+ const violation = detectFilesystemViolationFromLine(violationLines[index]);
931
+ if (violation) violations.push(violation);
932
+ }
933
+
934
+ if (violations.length > 0) return violations;
935
+
936
+ const hasEperm = /\bEPERM\b/i.test(fallbackOutput);
937
+ const hasOperationNotPermitted = /(?:^|\n)[^\n]*Operation not permitted(?:$|\n)/i.test(
938
+ fallbackOutput,
939
+ );
940
+ if (hasEperm || hasOperationNotPermitted) {
941
+ const path = extractPathLikeValue(fallbackOutput);
942
+ if (path) violations.push({ kind: "unknown", path });
943
+ }
944
+
945
+ return violations;
946
+ }
947
+
948
+ function isMetadataTraversalViolation(
949
+ runtimeConfig: SandboxRuntimeConfig | null,
950
+ violation: FilesystemViolation,
951
+ cwd?: string,
952
+ ): boolean {
953
+ if (!runtimeConfig || violation.kind !== "read" || violation.readAccess !== "metadata")
954
+ return false;
955
+ if (!violation.path || !METADATA_TRAVERSAL_PROCESSES.has(violation.processName ?? ""))
956
+ return false;
957
+ return inferSandboxRuleMatch(violation.path, runtimeConfig.filesystem.denyRead, cwd) !== null;
958
+ }
959
+
960
+ function getMetadataTraversalPaths(options: {
961
+ runtimeConfig: SandboxRuntimeConfig | null;
962
+ output: string;
963
+ cwd?: string;
964
+ skipViolationLines?: number;
965
+ }): string[] | null {
966
+ const { runtimeConfig, output, cwd, skipViolationLines = 0 } = options;
967
+ if (!runtimeConfig) return null;
968
+
969
+ const allViolationLines = extractSandboxViolationLines(output);
970
+ const violationLines =
971
+ skipViolationLines > 0
972
+ ? allViolationLines.slice(Math.min(skipViolationLines, allViolationLines.length))
973
+ : allViolationLines;
974
+ if (violationLines.length === 0) return null;
975
+
976
+ const skippedPaths: string[] = [];
977
+ for (const line of violationLines) {
978
+ const violation = detectFilesystemViolationFromLine(line);
979
+ if (!violation || !isMetadataTraversalViolation(runtimeConfig, violation, cwd)) {
980
+ return null;
981
+ }
982
+ if (violation.path && !skippedPaths.includes(violation.path)) {
983
+ skippedPaths.push(violation.path);
984
+ }
985
+ }
986
+
987
+ return skippedPaths.length > 0 ? skippedPaths : null;
988
+ }
989
+
990
+ function formatMetadataTraversalNotice(paths: string[]): string {
991
+ if (paths.length === 0) return "";
992
+
993
+ const visiblePaths = paths.slice(0, 3).join(", ");
994
+ const suffix = paths.length > 3 ? ", ..." : "";
995
+ const label = paths.length === 1 ? "path" : "paths";
996
+ return `[sandbox] Continued after skipping protected ${label}: ${visiblePaths}${suffix}`;
997
+ }
998
+
999
+ function escapeSlashCommandArg(value: string): string {
1000
+ if (/^[a-zA-Z0-9_./:@%+\-~]+$/.test(value)) return value;
1001
+ return JSON.stringify(value);
1002
+ }
1003
+
1004
+ interface FilesystemAllowAction {
1005
+ list: FilesystemList;
1006
+ op: ListOp;
1007
+ value: string;
1008
+ }
1009
+
1010
+ function buildFilesystemAllowAction(
1011
+ runtimeConfig: SandboxRuntimeConfig,
1012
+ violation: FilesystemViolation,
1013
+ cwd?: string,
1014
+ ): FilesystemAllowAction | null {
1015
+ if (!violation.path) return null;
1016
+
1017
+ if (violation.kind === "read") {
1018
+ const matchedRule = inferSandboxRuleMatch(
1019
+ violation.path,
1020
+ runtimeConfig.filesystem.denyRead,
1021
+ cwd,
1022
+ );
1023
+ return { list: "deny-read", op: "remove", value: matchedRule ?? violation.path };
1024
+ }
1025
+
1026
+ if (violation.kind === "write") {
1027
+ const matchedDeny = inferSandboxRuleMatch(
1028
+ violation.path,
1029
+ runtimeConfig.filesystem.denyWrite,
1030
+ cwd,
1031
+ );
1032
+ if (matchedDeny) {
1033
+ return { list: "deny-write", op: "remove", value: matchedDeny };
1034
+ }
1035
+
1036
+ return { list: "allow-write", op: "add", value: violation.path };
1037
+ }
1038
+
1039
+ const matchedDenyWrite = inferSandboxRuleMatch(
1040
+ violation.path,
1041
+ runtimeConfig.filesystem.denyWrite,
1042
+ cwd,
1043
+ );
1044
+ if (matchedDenyWrite) {
1045
+ return { list: "deny-write", op: "remove", value: matchedDenyWrite };
1046
+ }
1047
+
1048
+ const matchedDenyRead = inferSandboxRuleMatch(
1049
+ violation.path,
1050
+ runtimeConfig.filesystem.denyRead,
1051
+ cwd,
1052
+ );
1053
+ if (matchedDenyRead) {
1054
+ return { list: "deny-read", op: "remove", value: matchedDenyRead };
1055
+ }
1056
+
1057
+ return { list: "allow-write", op: "add", value: violation.path };
1058
+ }
1059
+
1060
+ function buildFilesystemAllowCommand(action: FilesystemAllowAction): string {
1061
+ return `/sandbox filesystem ${action.list} ${action.op} ${escapeSlashCommandArg(action.value)}`;
1062
+ }
1063
+
1064
+ function getFilesystemListValues(
1065
+ runtimeConfig: SandboxRuntimeConfig,
1066
+ list: FilesystemList,
1067
+ ): string[] {
1068
+ if (list === "deny-read") return runtimeConfig.filesystem.denyRead;
1069
+ if (list === "allow-write") return runtimeConfig.filesystem.allowWrite;
1070
+ return runtimeConfig.filesystem.denyWrite;
1071
+ }
1072
+
1073
+ function applyFilesystemAllowAction(
1074
+ runtimeConfig: SandboxRuntimeConfig,
1075
+ action: FilesystemAllowAction,
1076
+ ): boolean {
1077
+ const values = getFilesystemListValues(runtimeConfig, action.list);
1078
+ return mutateStringList(values, action.op, action.value);
1079
+ }
1080
+
1081
+ function isFilesystemAllowActionAlreadyApplied(
1082
+ runtimeConfig: SandboxRuntimeConfig,
1083
+ action: FilesystemAllowAction,
1084
+ ): boolean {
1085
+ const values = getFilesystemListValues(runtimeConfig, action.list);
1086
+ return action.op === "add" ? values.includes(action.value) : !values.includes(action.value);
1087
+ }
1088
+
1089
+ function describeFilesystemViolationTarget(violation: FilesystemViolation): string {
1090
+ if (violation.kind === "read") {
1091
+ if (violation.path) return `read from ${violation.path}`;
1092
+ return "read";
1093
+ }
1094
+
1095
+ if (violation.kind === "write") {
1096
+ if (violation.path) return `write to ${violation.path}`;
1097
+ return "write";
1098
+ }
1099
+
1100
+ if (violation.path) return `access to ${violation.path}`;
1101
+ return "access";
1102
+ }
1103
+
1104
+ function formatFilesystemViolationSummary(violation: FilesystemViolation): string {
1105
+ if (violation.kind === "read") {
1106
+ if (violation.path) return `[sandbox] Blocked filesystem read: ${violation.path}`;
1107
+ return "[sandbox] Blocked filesystem read.";
1108
+ }
1109
+
1110
+ if (violation.kind === "write") {
1111
+ if (violation.path) return `[sandbox] Blocked filesystem write: ${violation.path}`;
1112
+ return "[sandbox] Blocked filesystem write.";
1113
+ }
1114
+
1115
+ if (violation.path) return `[sandbox] Blocked filesystem access: ${violation.path}`;
1116
+ return "[sandbox] Blocked filesystem access (EPERM).";
1117
+ }
1118
+
1119
+ const FILESYSTEM_ALLOW_RETRY_OPTION = "Allow and retry now";
1120
+ const FILESYSTEM_ALLOW_ADAPT_OPTION = "Allow but adapt for side-effects";
1121
+ const FILESYSTEM_DENY_OPTION = "Deny";
1122
+
1123
+ function getFilesystemPromptOptions(
1124
+ _violation: FilesystemViolation,
1125
+ autoRetryAvailable: boolean,
1126
+ ): string[] {
1127
+ if (!autoRetryAvailable) {
1128
+ return [FILESYSTEM_ALLOW_ADAPT_OPTION, FILESYSTEM_DENY_OPTION];
1129
+ }
1130
+
1131
+ return [FILESYSTEM_ALLOW_RETRY_OPTION, FILESYSTEM_ALLOW_ADAPT_OPTION, FILESYSTEM_DENY_OPTION];
1132
+ }
1133
+
1134
+ function parseFilesystemPromptSelection(
1135
+ selection: string | undefined,
1136
+ autoRetryAvailable: boolean,
1137
+ ): FilesystemViolationResolutionKind {
1138
+ if (selection === FILESYSTEM_ALLOW_ADAPT_OPTION) return "allow-adapt";
1139
+ if (selection === FILESYSTEM_ALLOW_RETRY_OPTION && autoRetryAvailable) return "allow-retry";
1140
+ return "deny";
1141
+ }
1142
+
1143
+ function formatFilesystemAllowRetryMessage(_target: string): string {
1144
+ return "\nSandbox blocked access.\n\nGranting access and retrying the command per user request...\n\n";
1145
+ }
1146
+
1147
+ function formatFilesystemAllowAdaptMessage(_target: string): string {
1148
+ return "\nSandbox blocked access.\n\nAccess granted for this session. Retry the command manually if appropriate.";
1149
+ }
1150
+
1151
+ function formatFilesystemDeniedMessage(_target: string): string {
1152
+ return "\nSandbox blocked access.\n\nAccess remains denied for this session.";
1153
+ }
1154
+
1155
+ function formatFilesystemAlreadyAllowedMessage(_target: string): string {
1156
+ return "\nSandbox blocked access again after permission had already been granted. The remaining failure may be unrelated to sandbox policy.";
1157
+ }
1158
+
1159
+ function formatFilesystemRetrySucceededMessage(_target: string): string {
1160
+ return "";
1161
+ }
1162
+
1163
+ function formatFilesystemRetryFailedMessage(_target: string): string {
1164
+ return "\nAccess granted and command retried per user request, but the command still exited non-zero. The sandbox block was resolved; the remaining failure may be unrelated.";
1165
+ }
1166
+
1167
+ function formatFilesystemRetrySkippedMessage(_target: string): string {
1168
+ return "\nAccess granted for this session, but automatic retry was skipped because the timeout was exhausted. Retry the command manually if needed.";
1169
+ }
1170
+
1171
+ function appendOutputPostamble(postamble: string, addition: string, output: string): string {
1172
+ if (!addition) return postamble;
1173
+
1174
+ const needsSeparator =
1175
+ postamble.length > 0 ? !postamble.endsWith("\n") : output.length > 0 && !output.endsWith("\n");
1176
+
1177
+ return `${postamble}${needsSeparator ? "\n" : ""}${addition}`;
1178
+ }
1179
+
1180
+ function ensureTrailingNewline(text: string): string {
1181
+ if (!text || text.endsWith("\n")) return text;
1182
+ return `${text}\n`;
1183
+ }
1184
+
1185
+ async function handleFilesystemViolation(options: {
1186
+ pi: ExtensionAPI;
1187
+ ctx: ExtensionContext | null;
1188
+ promptMode: PromptMode;
1189
+ runtimeConfig: SandboxRuntimeConfig;
1190
+ output: string;
1191
+ rawOutput: string;
1192
+ command: string;
1193
+ cwd?: string;
1194
+ pendingPrompts?: Map<string, Promise<FilesystemViolationResolution | null>>;
1195
+ applyRuntimeConfigForSession?: (
1196
+ ctx: ExtensionContext,
1197
+ runtimeConfig: SandboxRuntimeConfig,
1198
+ ) => void;
1199
+ existingViolationCount?: number;
1200
+ recordEvent?: (event: SandboxEvent) => void;
1201
+ autoRetryAvailable?: boolean;
1202
+ }): Promise<FilesystemViolationResolution | null> {
1203
+ const {
1204
+ pi,
1205
+ ctx,
1206
+ promptMode,
1207
+ runtimeConfig,
1208
+ output,
1209
+ rawOutput,
1210
+ command,
1211
+ cwd,
1212
+ pendingPrompts,
1213
+ applyRuntimeConfigForSession,
1214
+ existingViolationCount,
1215
+ recordEvent,
1216
+ autoRetryAvailable = true,
1217
+ } = options;
1218
+ const violations = detectFilesystemViolations(output, rawOutput, existingViolationCount ?? 0);
1219
+ if (violations.length === 0) return null;
1220
+
1221
+ const violation =
1222
+ violations.find((candidate) => {
1223
+ const candidateAction = buildFilesystemAllowAction(runtimeConfig, candidate, cwd);
1224
+ if (!candidateAction) return false;
1225
+ return !isFilesystemAllowActionAlreadyApplied(runtimeConfig, candidateAction);
1226
+ }) ?? violations[0];
1227
+
1228
+ const summary = formatFilesystemViolationSummary(violation);
1229
+ const target = describeFilesystemViolationTarget(violation);
1230
+ const allowAction = buildFilesystemAllowAction(runtimeConfig, violation, cwd);
1231
+ const allowCommand = allowAction ? buildFilesystemAllowCommand(allowAction) : null;
1232
+ const alreadyApproved = allowAction
1233
+ ? isFilesystemAllowActionAlreadyApplied(runtimeConfig, allowAction)
1234
+ : false;
1235
+ const eventReason = classifyFilesystemEventReason(runtimeConfig, violation, cwd, alreadyApproved);
1236
+ const blockedSuggestedCommand = alreadyApproved ? undefined : (allowCommand ?? undefined);
1237
+
1238
+ const recordFilesystemEvent = (outcome: SandboxEventOutcome): void => {
1239
+ recordEvent?.({
1240
+ timestamp: Date.now(),
1241
+ kind: "filesystem",
1242
+ outcome,
1243
+ reason: eventReason,
1244
+ target: violation.path,
1245
+ command,
1246
+ cwd,
1247
+ summary: describeFilesystemEventSummary(eventReason, violation, outcome),
1248
+ suggestedCommand: outcome === "blocked" ? blockedSuggestedCommand : undefined,
1249
+ });
1250
+ };
1251
+
1252
+ if (promptMode === "non-interactive" || !ctx?.hasUI) {
1253
+ recordFilesystemEvent("blocked");
1254
+ if (!allowCommand) return { kind: "deny", message: summary };
1255
+ return {
1256
+ kind: "deny",
1257
+ message: `${summary}\n[sandbox] To temporarily allow for this session, run: ${allowCommand}`,
1258
+ };
1259
+ }
1260
+
1261
+ if (!allowAction || !allowCommand) {
1262
+ recordFilesystemEvent("blocked");
1263
+ return { kind: "deny", message: summary };
1264
+ }
1265
+
1266
+ if (alreadyApproved) {
1267
+ recordFilesystemEvent("blocked");
1268
+ return { kind: "allow-adapt", message: formatFilesystemAlreadyAllowedMessage(target) };
1269
+ }
1270
+
1271
+ const promptKey = `${allowCommand}:${autoRetryAvailable ? "retry" : "adapt"}`;
1272
+ const existingPrompt = pendingPrompts?.get(promptKey);
1273
+ if (existingPrompt) return existingPrompt;
1274
+
1275
+ const promptTask: Promise<FilesystemViolationResolution | null> = (async () => {
1276
+ try {
1277
+ const selection = await withPromptSignal(pi, () =>
1278
+ ctx.ui.select(
1279
+ `Sandbox blocked filesystem ${target}`,
1280
+ getFilesystemPromptOptions(violation, autoRetryAvailable),
1281
+ ),
1282
+ );
1283
+ const decision = parseFilesystemPromptSelection(selection, autoRetryAvailable);
1284
+ if (decision === "deny") {
1285
+ recordFilesystemEvent("blocked");
1286
+ return { kind: "deny", message: formatFilesystemDeniedMessage(target) };
1287
+ }
1288
+
1289
+ const nextConfig = cloneRuntimeConfig(runtimeConfig);
1290
+ const changed = applyFilesystemAllowAction(nextConfig, allowAction);
1291
+ if (changed) {
1292
+ applyRuntimeConfigForSession?.(ctx, nextConfig);
1293
+ }
1294
+
1295
+ recordFilesystemEvent("allowed");
1296
+
1297
+ if (decision === "allow-retry") {
1298
+ return {
1299
+ kind: "allow-retry",
1300
+ message: formatFilesystemAllowRetryMessage(target),
1301
+ retrySuccessMessage: formatFilesystemRetrySucceededMessage(target),
1302
+ retryFailureMessage: formatFilesystemRetryFailedMessage(target),
1303
+ retrySkippedMessage: formatFilesystemRetrySkippedMessage(target),
1304
+ };
1305
+ }
1306
+
1307
+ return {
1308
+ kind: "allow-adapt",
1309
+ message: changed
1310
+ ? formatFilesystemAllowAdaptMessage(target)
1311
+ : formatFilesystemAlreadyAllowedMessage(target),
1312
+ };
1313
+ } catch {
1314
+ return null;
1315
+ }
1316
+ })();
1317
+
1318
+ if (!pendingPrompts) return promptTask;
1319
+
1320
+ pendingPrompts.set(promptKey, promptTask);
1321
+ try {
1322
+ return await promptTask;
1323
+ } finally {
1324
+ pendingPrompts.delete(promptKey);
1325
+ }
1326
+ }
1327
+
1328
+ interface SandboxedBashOpsOptions {
1329
+ pi: ExtensionAPI;
1330
+ getContext: () => ExtensionContext | null;
1331
+ getSandboxConfig: () => SandboxConfig | null;
1332
+ getRuntimeConfig: () => SandboxRuntimeConfig | null;
1333
+ getPromptMode: () => PromptMode;
1334
+ applyRuntimeConfigForSession: (
1335
+ ctx: ExtensionContext,
1336
+ runtimeConfig: SandboxRuntimeConfig,
1337
+ ) => void;
1338
+ recordEvent?: (event: SandboxEvent) => void;
1339
+ }
1340
+
1341
+ interface BashAttemptResult {
1342
+ exitCode: number | null;
1343
+ combinedOutput: string;
1344
+ interruptedByFilesystemViolation: boolean;
1345
+ }
1346
+
1347
+ interface ProcessedSandboxAttempt {
1348
+ exitCode: number | null;
1349
+ postamble: string;
1350
+ resolution: FilesystemViolationResolution | null;
1351
+ }
1352
+
1353
+ interface PreparedSandboxAttempt {
1354
+ attempt: BashAttemptResult;
1355
+ existingViolationCount: number;
1356
+ }
1357
+
1358
+ function killProcessGroup(
1359
+ child: ReturnType<typeof spawn>,
1360
+ signal: NodeJS.Signals = "SIGKILL",
1361
+ ): void {
1362
+ if (!child.pid) return;
1363
+
1364
+ try {
1365
+ process.kill(-child.pid, signal);
1366
+ } catch {
1367
+ try {
1368
+ child.kill(signal);
1369
+ } catch {
1370
+ // Process likely already exited.
1371
+ }
1372
+ }
1373
+ }
1374
+
1375
+ function safeCleanupAfterCommand(): void {
1376
+ try {
1377
+ SandboxManager.cleanupAfterCommand();
1378
+ } catch {
1379
+ // Ignore cleanup errors.
1380
+ }
1381
+ }
1382
+
1383
+ function maybeAllowGitMetadataWriteForSession(options: {
1384
+ ctx: ExtensionContext | null;
1385
+ cwd: string;
1386
+ runtimeConfig: SandboxRuntimeConfig | null;
1387
+ allowGitCommonDir: boolean;
1388
+ applyRuntimeConfigForSession: (
1389
+ ctx: ExtensionContext,
1390
+ runtimeConfig: SandboxRuntimeConfig,
1391
+ ) => void;
1392
+ }): void {
1393
+ const { ctx, cwd, runtimeConfig, allowGitCommonDir, applyRuntimeConfigForSession } = options;
1394
+ if (!ctx || !runtimeConfig) return;
1395
+ if (!isSandboxWritablePath(runtimeConfig, cwd, cwd)) return;
1396
+
1397
+ const gitPaths = resolveGitFilesystemPaths(cwd);
1398
+ if (!gitPaths) return;
1399
+
1400
+ let nextConfig = maybeAddGitMetadataWritePath(runtimeConfig, gitPaths.gitDir, cwd);
1401
+ if (allowGitCommonDir && gitPaths.gitCommonDir !== gitPaths.gitDir) {
1402
+ nextConfig =
1403
+ maybeAddGitMetadataWritePath(nextConfig ?? runtimeConfig, gitPaths.gitCommonDir, cwd) ??
1404
+ nextConfig;
1405
+ }
1406
+
1407
+ if (!nextConfig) return;
1408
+ applyRuntimeConfigForSession(ctx, nextConfig);
1409
+ }
1410
+
1411
+ function createSandboxedBashOps(options: SandboxedBashOpsOptions): BashOperations {
1412
+ const {
1413
+ pi,
1414
+ getContext,
1415
+ getSandboxConfig,
1416
+ getRuntimeConfig,
1417
+ getPromptMode,
1418
+ applyRuntimeConfigForSession,
1419
+ recordEvent,
1420
+ } = options;
1421
+ const pendingFilesystemPrompts = new Map<string, Promise<FilesystemViolationResolution | null>>();
1422
+
1423
+ let executionQueue: Promise<void> = Promise.resolve();
1424
+
1425
+ function runSerially<T>(task: () => Promise<T>): Promise<T> {
1426
+ const run = executionQueue.then(task, task);
1427
+ executionQueue = run.then(
1428
+ () => undefined,
1429
+ () => undefined,
1430
+ );
1431
+ return run;
1432
+ }
1433
+
1434
+ async function runSandboxAttempt(
1435
+ command: string,
1436
+ wrappedCommand: string,
1437
+ cwd: string,
1438
+ onData: (data: Buffer) => void,
1439
+ existingViolationCount: number,
1440
+ signal?: AbortSignal,
1441
+ timeout?: number,
1442
+ env?: NodeJS.ProcessEnv,
1443
+ ): Promise<BashAttemptResult> {
1444
+ return new Promise((resolve, reject) => {
1445
+ const child = spawn("bash", ["-c", wrappedCommand], {
1446
+ cwd,
1447
+ env,
1448
+ detached: true,
1449
+ stdio: ["ignore", "pipe", "pipe"],
1450
+ });
1451
+
1452
+ const chunks: Buffer[] = [];
1453
+ let timedOut = false;
1454
+ let interruptedByFilesystemViolation = false;
1455
+ let seenViolationCount = existingViolationCount;
1456
+ let timeoutHandle: NodeJS.Timeout | undefined;
1457
+ let timeoutEscalationHandle: NodeJS.Timeout | undefined;
1458
+ let filesystemStopEscalationHandle: NodeJS.Timeout | undefined;
1459
+
1460
+ const stopForFilesystemViolation = (): void => {
1461
+ if (interruptedByFilesystemViolation) return;
1462
+
1463
+ interruptedByFilesystemViolation = true;
1464
+ killProcessGroup(child, "SIGTERM");
1465
+ filesystemStopEscalationHandle = setTimeout(() => {
1466
+ killProcessGroup(child, "SIGKILL");
1467
+ }, 500);
1468
+ };
1469
+
1470
+ // sandbox-runtime only provides live filesystem violation events on macOS.
1471
+ // Upstream documents Linux violation monitoring as future work via
1472
+ // automatic strace-based detection integrated with the violation store,
1473
+ // but there is no Linux implementation yet:
1474
+ // https://github.com/anthropic-experimental/sandbox-runtime#known-limitations-and-future-work
1475
+ const unsubscribeViolations =
1476
+ process.platform !== "darwin"
1477
+ ? () => undefined
1478
+ : SandboxManager.getSandboxViolationStore().subscribe(() => {
1479
+ const violations =
1480
+ SandboxManager.getSandboxViolationStore().getViolationsForCommand(command);
1481
+ if (violations.length <= seenViolationCount) return;
1482
+
1483
+ const newViolations = violations.slice(seenViolationCount);
1484
+ seenViolationCount = violations.length;
1485
+
1486
+ const runtimeConfig = getRuntimeConfig();
1487
+ const shouldStop = newViolations.some((violation) => {
1488
+ const filesystemViolation = detectFilesystemViolationFromLine(violation.line);
1489
+ if (!filesystemViolation) return false;
1490
+ return !isMetadataTraversalViolation(runtimeConfig, filesystemViolation, cwd);
1491
+ });
1492
+ if (shouldStop) {
1493
+ stopForFilesystemViolation();
1494
+ }
1495
+ });
1496
+
1497
+ if (timeout !== undefined && timeout > 0) {
1498
+ timeoutHandle = setTimeout(() => {
1499
+ timedOut = true;
1500
+ killProcessGroup(child, "SIGTERM");
1501
+ timeoutEscalationHandle = setTimeout(() => {
1502
+ killProcessGroup(child, "SIGKILL");
1503
+ }, 2000);
1504
+ }, timeout * 1000);
1505
+ }
1506
+
1507
+ child.stdout?.on("data", (data) => {
1508
+ chunks.push(data);
1509
+ onData(data);
1510
+ });
1511
+ child.stderr?.on("data", (data) => {
1512
+ chunks.push(data);
1513
+ onData(data);
1514
+ });
1515
+
1516
+ const onAbort = () => {
1517
+ killProcessGroup(child, "SIGKILL");
1518
+ };
1519
+
1520
+ child.on("error", (err) => {
1521
+ if (timeoutHandle) clearTimeout(timeoutHandle);
1522
+ if (timeoutEscalationHandle) clearTimeout(timeoutEscalationHandle);
1523
+ if (filesystemStopEscalationHandle) clearTimeout(filesystemStopEscalationHandle);
1524
+ unsubscribeViolations();
1525
+ signal?.removeEventListener("abort", onAbort);
1526
+ killProcessGroup(child, "SIGKILL");
1527
+ reject(err);
1528
+ });
1529
+
1530
+ if (signal) {
1531
+ if (signal.aborted) onAbort();
1532
+ else signal.addEventListener("abort", onAbort, { once: true });
1533
+ }
1534
+
1535
+ child.on("close", (code) => {
1536
+ if (timeoutHandle) clearTimeout(timeoutHandle);
1537
+ if (timeoutEscalationHandle) clearTimeout(timeoutEscalationHandle);
1538
+ if (filesystemStopEscalationHandle) clearTimeout(filesystemStopEscalationHandle);
1539
+ unsubscribeViolations();
1540
+ signal?.removeEventListener("abort", onAbort);
1541
+
1542
+ if (signal?.aborted) {
1543
+ reject(new Error("aborted"));
1544
+ return;
1545
+ }
1546
+
1547
+ if (timedOut) {
1548
+ reject(new Error(`timeout:${timeout}`));
1549
+ return;
1550
+ }
1551
+
1552
+ resolve({
1553
+ exitCode: interruptedByFilesystemViolation && code === null ? 1 : code,
1554
+ combinedOutput: Buffer.concat(chunks).toString("utf-8"),
1555
+ interruptedByFilesystemViolation,
1556
+ });
1557
+ });
1558
+ });
1559
+ }
1560
+
1561
+ function getRemainingTimeout(timeout: number | undefined, startedAt: number): number | undefined {
1562
+ if (timeout === undefined) return undefined;
1563
+
1564
+ const remainingMs = timeout * 1000 - (Date.now() - startedAt);
1565
+ return Math.max(0, remainingMs / 1000);
1566
+ }
1567
+
1568
+ function reportPostProcessingError(error: unknown): void {
1569
+ const message = `[sandbox] Post-processing error: ${error instanceof Error ? error.message : error}`;
1570
+ const ctx = getContext();
1571
+ if (ctx) notify(ctx, message, "warning");
1572
+ else console.warn(message);
1573
+ }
1574
+
1575
+ async function prepareAndRunSandboxAttempt(options: {
1576
+ command: string;
1577
+ cwd: string;
1578
+ onData: (data: Buffer) => void;
1579
+ signal?: AbortSignal;
1580
+ timeout?: number;
1581
+ env?: NodeJS.ProcessEnv;
1582
+ }): Promise<PreparedSandboxAttempt> {
1583
+ const { command, cwd, onData, signal, timeout, env } = options;
1584
+
1585
+ maybeAllowGitMetadataWriteForSession({
1586
+ ctx: getContext(),
1587
+ cwd,
1588
+ runtimeConfig: getRuntimeConfig(),
1589
+ allowGitCommonDir: getSandboxConfig()?.filesystem.allowGitCommonDir === true,
1590
+ applyRuntimeConfigForSession,
1591
+ });
1592
+
1593
+ const wrappedCommand = await SandboxManager.wrapWithSandbox(command);
1594
+ const existingViolationCount =
1595
+ SandboxManager.getSandboxViolationStore().getViolationsForCommand(command).length;
1596
+
1597
+ try {
1598
+ const attempt = await runSandboxAttempt(
1599
+ command,
1600
+ wrappedCommand,
1601
+ cwd,
1602
+ onData,
1603
+ existingViolationCount,
1604
+ signal,
1605
+ timeout,
1606
+ env,
1607
+ );
1608
+ return { attempt, existingViolationCount };
1609
+ } catch (err) {
1610
+ safeCleanupAfterCommand();
1611
+ throw err;
1612
+ }
1613
+ }
1614
+
1615
+ async function processSandboxAttempt(options: {
1616
+ attempt: BashAttemptResult;
1617
+ command: string;
1618
+ cwd: string;
1619
+ existingViolationCount: number;
1620
+ autoRetryAvailable: boolean;
1621
+ }): Promise<ProcessedSandboxAttempt> {
1622
+ const { attempt, command, cwd, existingViolationCount, autoRetryAvailable } = options;
1623
+ const commandSucceeded = attempt.exitCode === 0 && !attempt.interruptedByFilesystemViolation;
1624
+ if (commandSucceeded) {
1625
+ return { exitCode: attempt.exitCode, postamble: "", resolution: null };
1626
+ }
1627
+
1628
+ const annotatedOutput = SandboxManager.annotateStderrWithSandboxFailures(
1629
+ command,
1630
+ attempt.combinedOutput,
1631
+ );
1632
+ const runtimeConfig = getRuntimeConfig();
1633
+ const metadataTraversalPaths = getMetadataTraversalPaths({
1634
+ runtimeConfig,
1635
+ output: annotatedOutput,
1636
+ cwd,
1637
+ skipViolationLines: existingViolationCount,
1638
+ });
1639
+ const effectiveExitCode = metadataTraversalPaths ? 0 : attempt.exitCode;
1640
+ let postamble = extractAppendedSandboxAnnotation(
1641
+ attempt.combinedOutput,
1642
+ annotatedOutput,
1643
+ existingViolationCount,
1644
+ );
1645
+ let resolution: FilesystemViolationResolution | null = null;
1646
+
1647
+ if (metadataTraversalPaths) {
1648
+ const notice = formatMetadataTraversalNotice(metadataTraversalPaths);
1649
+ postamble = appendOutputPostamble(postamble, notice, attempt.combinedOutput);
1650
+ } else if (runtimeConfig) {
1651
+ resolution = await handleFilesystemViolation({
1652
+ pi,
1653
+ ctx: getContext(),
1654
+ promptMode: getPromptMode(),
1655
+ runtimeConfig,
1656
+ output: annotatedOutput,
1657
+ rawOutput: attempt.combinedOutput,
1658
+ command,
1659
+ cwd,
1660
+ pendingPrompts: pendingFilesystemPrompts,
1661
+ applyRuntimeConfigForSession,
1662
+ existingViolationCount,
1663
+ recordEvent,
1664
+ autoRetryAvailable,
1665
+ });
1666
+
1667
+ if (resolution) {
1668
+ postamble = appendOutputPostamble(postamble, resolution.message, attempt.combinedOutput);
1669
+ }
1670
+ }
1671
+
1672
+ return { exitCode: effectiveExitCode, postamble, resolution };
1673
+ }
1674
+
1675
+ return {
1676
+ async exec(command, cwd, { onData, signal, timeout, env }) {
1677
+ return runSerially(async () => {
1678
+ if (!existsSync(cwd)) {
1679
+ throw new Error(`Working directory does not exist: ${cwd}`);
1680
+ }
1681
+
1682
+ const startedAt = Date.now();
1683
+ const initialRun = await prepareAndRunSandboxAttempt({
1684
+ command,
1685
+ cwd,
1686
+ onData,
1687
+ signal,
1688
+ timeout,
1689
+ env,
1690
+ });
1691
+
1692
+ let processedAttempt: ProcessedSandboxAttempt;
1693
+ try {
1694
+ processedAttempt = await processSandboxAttempt({
1695
+ attempt: initialRun.attempt,
1696
+ command,
1697
+ cwd,
1698
+ existingViolationCount: initialRun.existingViolationCount,
1699
+ autoRetryAvailable: true,
1700
+ });
1701
+ } catch (postProcessError) {
1702
+ reportPostProcessingError(postProcessError);
1703
+ safeCleanupAfterCommand();
1704
+ return { exitCode: initialRun.attempt.exitCode };
1705
+ }
1706
+
1707
+ const retryResolution = processedAttempt.resolution;
1708
+ if (retryResolution?.kind !== "allow-retry") {
1709
+ if (processedAttempt.postamble) onData(Buffer.from(processedAttempt.postamble));
1710
+ safeCleanupAfterCommand();
1711
+ return { exitCode: processedAttempt.exitCode };
1712
+ }
1713
+
1714
+ if (processedAttempt.postamble) {
1715
+ onData(Buffer.from(ensureTrailingNewline(processedAttempt.postamble)));
1716
+ }
1717
+
1718
+ initialRun.attempt.combinedOutput = "";
1719
+ safeCleanupAfterCommand();
1720
+
1721
+ const retryTimeout = getRemainingTimeout(timeout, startedAt);
1722
+ if (retryTimeout !== undefined && retryTimeout <= 0) {
1723
+ onData(Buffer.from(retryResolution.retrySkippedMessage));
1724
+ return { exitCode: processedAttempt.exitCode };
1725
+ }
1726
+
1727
+ const retryRun = await prepareAndRunSandboxAttempt({
1728
+ command,
1729
+ cwd,
1730
+ onData,
1731
+ signal,
1732
+ timeout: retryTimeout,
1733
+ env,
1734
+ });
1735
+
1736
+ let processedRetry: ProcessedSandboxAttempt;
1737
+ try {
1738
+ processedRetry = await processSandboxAttempt({
1739
+ attempt: retryRun.attempt,
1740
+ command,
1741
+ cwd,
1742
+ existingViolationCount: retryRun.existingViolationCount,
1743
+ autoRetryAvailable: false,
1744
+ });
1745
+ } catch (postProcessError) {
1746
+ reportPostProcessingError(postProcessError);
1747
+ safeCleanupAfterCommand();
1748
+ return { exitCode: retryRun.attempt.exitCode };
1749
+ }
1750
+
1751
+ let retryPostamble = processedRetry.postamble;
1752
+ if (processedRetry.exitCode === 0) {
1753
+ retryPostamble = appendOutputPostamble(
1754
+ retryPostamble,
1755
+ retryResolution.retrySuccessMessage,
1756
+ retryRun.attempt.combinedOutput,
1757
+ );
1758
+ } else if (!processedRetry.resolution) {
1759
+ retryPostamble = appendOutputPostamble(
1760
+ retryPostamble,
1761
+ retryResolution.retryFailureMessage,
1762
+ retryRun.attempt.combinedOutput,
1763
+ );
1764
+ }
1765
+
1766
+ if (retryPostamble) onData(Buffer.from(retryPostamble));
1767
+ safeCleanupAfterCommand();
1768
+ return { exitCode: processedRetry.exitCode };
1769
+ });
1770
+ },
1771
+ };
1772
+ }
1773
+
1774
+ function getSandboxRunMode(state: SandboxState): SandboxRunMode {
1775
+ if (state.status === "active" || state.status === "pending") return "sandbox";
1776
+ if (state.status === "suspended") return "user-disabled";
1777
+ return state.reason;
1778
+ }
1779
+
1780
+ function getStateRuntimeConfig(state: SandboxState): SandboxRuntimeConfig | null {
1781
+ if (state.status === "active" || state.status === "suspended") return state.runtimeConfig;
1782
+ return null;
1783
+ }
1784
+
1785
+ function requireRuntimeConfig(
1786
+ ctx: ExtensionContext,
1787
+ state: SandboxState,
1788
+ ): SandboxRuntimeConfig | null {
1789
+ const runtimeConfig = getStateRuntimeConfig(state);
1790
+ if (!runtimeConfig) {
1791
+ notify(ctx, "Sandbox is not initialized", "info");
1792
+ return null;
1793
+ }
1794
+ return runtimeConfig;
1795
+ }
1796
+
1797
+ function mutateStringList(values: string[], op: ListOp, value: string): boolean {
1798
+ if (op === "add") {
1799
+ if (values.includes(value)) return false;
1800
+ values.push(value);
1801
+ return true;
1802
+ }
1803
+
1804
+ const index = values.indexOf(value);
1805
+ if (index === -1) return false;
1806
+ values.splice(index, 1);
1807
+ return true;
1808
+ }
1809
+
1810
+ function classifyFilesystemEventReason(
1811
+ runtimeConfig: SandboxRuntimeConfig,
1812
+ violation: FilesystemViolation,
1813
+ cwd?: string,
1814
+ alreadyApproved = false,
1815
+ ): SandboxEventReason {
1816
+ if (alreadyApproved) return "already-approved-still-failed";
1817
+
1818
+ if (violation.path) {
1819
+ if (inferSandboxRuleMatch(violation.path, runtimeConfig.filesystem.denyWrite, cwd)) {
1820
+ return "explicit-deny-write";
1821
+ }
1822
+ if (inferSandboxRuleMatch(violation.path, runtimeConfig.filesystem.denyRead, cwd)) {
1823
+ return "explicit-deny-read";
1824
+ }
1825
+ }
1826
+
1827
+ if (violation.kind === "write" || violation.kind === "unknown") return "missing-allow-write";
1828
+ return "unknown";
1829
+ }
1830
+
1831
+ function describeFilesystemEventSummary(
1832
+ reason: SandboxEventReason,
1833
+ violation: FilesystemViolation,
1834
+ outcome: SandboxEventOutcome,
1835
+ ): string {
1836
+ if (outcome === "allowed") {
1837
+ if (reason === "explicit-deny-read") return "user allowed filesystem read for this session";
1838
+ if (reason === "explicit-deny-write") return "user allowed filesystem write for this session";
1839
+ if (reason === "missing-allow-write") {
1840
+ return violation.kind === "unknown"
1841
+ ? "user allowed filesystem access for this session"
1842
+ : "user allowed filesystem write path for this session";
1843
+ }
1844
+ return "user allowed filesystem access for this session";
1845
+ }
1846
+
1847
+ if (reason === "explicit-deny-read") return "filesystem read matched a deny-read rule";
1848
+ if (reason === "explicit-deny-write") return "filesystem write matched a deny-write rule";
1849
+ if (reason === "already-approved-still-failed") {
1850
+ return "filesystem access was previously allowed for this session but is still failing";
1851
+ }
1852
+ if (reason === "missing-allow-write") {
1853
+ return violation.kind === "unknown"
1854
+ ? "filesystem access fell outside the current allow-write paths"
1855
+ : "filesystem write fell outside the current allow-write paths";
1856
+ }
1857
+ return "sandbox blocked filesystem access";
1858
+ }
1859
+
1860
+ function buildNetworkBlockCommand(reason: SandboxEventReason, host: string): string | undefined {
1861
+ if (reason === "explicit-deny-domain") {
1862
+ return `/sandbox network deny remove ${escapeSlashCommandArg(host)}`;
1863
+ }
1864
+ if (reason === "missing-allowed-domain") {
1865
+ return `/sandbox network allow add ${escapeSlashCommandArg(host)}`;
1866
+ }
1867
+ return undefined;
1868
+ }
1869
+
1870
+ function describeNetworkEventSummary(
1871
+ reason: SandboxEventReason,
1872
+ outcome: SandboxEventOutcome,
1873
+ ): string {
1874
+ if (outcome === "allowed") return "user allowed network domain for this session";
1875
+ if (reason === "explicit-deny-domain") return "network access matched a deny list entry";
1876
+ if (reason === "missing-allowed-domain")
1877
+ return "network access target is not in the allowed domain list";
1878
+ return "sandbox blocked network access";
1879
+ }
1880
+
1881
+ function formatSandboxEventTimestamp(timestamp: number): string {
1882
+ const date = new Date(timestamp);
1883
+ const hours = `${date.getHours()}`.padStart(2, "0");
1884
+ const minutes = `${date.getMinutes()}`.padStart(2, "0");
1885
+ const seconds = `${date.getSeconds()}`.padStart(2, "0");
1886
+ return `${hours}:${minutes}:${seconds}`;
1887
+ }
1888
+
1889
+ function describeSandboxRuntimeState(state: SandboxState, promptMode: PromptMode): string {
1890
+ if (state.status === "active") return `active (${promptMode})`;
1891
+ if (state.status === "pending") return "pending";
1892
+ if (state.status === "suspended") return `suspended (${promptMode})`;
1893
+ if (state.status === "bypassed") {
1894
+ if (state.reason === "no-sandbox-flag") return "bypassed (--no-sandbox)";
1895
+ if (state.reason === "config-disabled") return "bypassed (config disabled)";
1896
+ return "bypassed (missing dependencies)";
1897
+ }
1898
+ return state.reason === "unsupported-platform"
1899
+ ? "blocked (unsupported platform)"
1900
+ : "blocked (init failed)";
1901
+ }
1902
+
1903
+ function renderSandboxDoctorReport(options: {
1904
+ state: SandboxState;
1905
+ promptMode: PromptMode;
1906
+ configPaths: SandboxConfigPath[];
1907
+ events: SandboxEvent[];
1908
+ }): string {
1909
+ const { state, promptMode, configPaths, events } = options;
1910
+ const lines = ["Sandbox doctor", `- Runtime: ${describeSandboxRuntimeState(state, promptMode)}`];
1911
+
1912
+ if (configPaths.length === 0) {
1913
+ lines.push("- Config paths: (none loaded)");
1914
+ } else {
1915
+ lines.push("- Config paths:");
1916
+ for (const configPath of configPaths) {
1917
+ const status = configPath.status === "loaded" ? "loaded" : "parse error";
1918
+ lines.push(` - ${configPath.label}: ${configPath.path} (${status})`);
1919
+ }
1920
+ }
1921
+
1922
+ lines.push(`- Events: ${events.length}`);
1923
+
1924
+ if (events.length === 0) {
1925
+ lines.push("", "- No sandbox events recorded in this session.");
1926
+ return lines.join("\n");
1927
+ }
1928
+
1929
+ for (const event of [...events].reverse()) {
1930
+ lines.push(
1931
+ "",
1932
+ `- [${formatSandboxEventTimestamp(event.timestamp)}] [${event.kind}] [${event.outcome}] ${event.reason}`,
1933
+ ...(event.target ? [` Target: ${event.target}`] : []),
1934
+ ...(event.command ? [` Command: ${event.command}`] : []),
1935
+ ` Summary: ${event.summary}`,
1936
+ ...(event.suggestedCommand
1937
+ ? [" Suggested session fix:", ` ${event.suggestedCommand}`]
1938
+ : []),
1939
+ );
1940
+ }
1941
+
1942
+ return lines.join("\n");
1943
+ }
1944
+
1945
+ function getSandboxConfigParseErrors(paths: SandboxConfigPath[]): SandboxConfigPath[] {
1946
+ return paths.filter((configPath) => configPath.status === "parse-error");
1947
+ }
1948
+
1949
+ function notifySandboxConfigParseErrors(ctx: ExtensionContext, paths: SandboxConfigPath[]): void {
1950
+ const details = paths
1951
+ .map((configPath) => `${configPath.label.toLowerCase()} (${configPath.path})`)
1952
+ .join(", ");
1953
+ notify(ctx, `Could not parse sandbox config: ${details}`, "warning");
1954
+ }
1955
+
1956
+ function requestShutdownWithError(ctx: ExtensionContext, message: string): void {
1957
+ // Validate during session_start so we can use Pi's registered flag values and shutdown path.
1958
+ // This briefly renders startup UI, but Pi exits through its normal cleanup path instead of
1959
+ // leaving the terminal in a broken state.
1960
+ notify(ctx, message, "error");
1961
+ process.exitCode = 1;
1962
+ ctx.shutdown();
1963
+ }
1964
+
1965
+ function loadSandboxConfigForContext(
1966
+ ctx: ExtensionContext,
1967
+ cwd: string,
1968
+ overrideConfigPath: string | undefined,
1969
+ options: { exitOnError?: boolean } = {},
1970
+ ): LoadedSandboxConfig | null {
1971
+ const { exitOnError = false } = options;
1972
+
1973
+ try {
1974
+ return loadConfig(cwd, overrideConfigPath);
1975
+ } catch (error) {
1976
+ if (!(error instanceof SandboxConfigLoadError)) throw error;
1977
+
1978
+ if (exitOnError) {
1979
+ requestShutdownWithError(ctx, error.message);
1980
+ } else {
1981
+ notify(ctx, error.message, "error");
1982
+ }
1983
+ return null;
1984
+ }
1985
+ }
1986
+
1987
+ function getSandboxDependencyErrors(config: SandboxConfig): string[] {
1988
+ return SandboxManager.checkDependencies(config.ripgrep).errors;
1989
+ }
1990
+
1991
+ function formatMissingSandboxDependenciesWarning(errors: string[]): string {
1992
+ return `Sandbox disabled: ${errors.join("; ")}`;
1993
+ }
1994
+
1995
+ export default function (pi: ExtensionAPI) {
1996
+ pi.registerFlag("no-sandbox", {
1997
+ description: "Disable OS-level sandboxing for bash commands",
1998
+ type: "boolean",
1999
+ default: false,
2000
+ });
2001
+
2002
+ pi.registerFlag("sandbox-config", {
2003
+ description:
2004
+ "Use a custom sandbox config file for this session (replaces global/project sandbox.json files)",
2005
+ type: "string",
2006
+ });
2007
+
2008
+ let sessionCwd = process.cwd();
2009
+ let sandboxState: SandboxState = { status: "pending" };
2010
+ let sandboxConfig: SandboxConfig | null = null;
2011
+ let promptMode: PromptMode = DEFAULT_PROMPT_MODE;
2012
+ let sessionContext: ExtensionContext | null = null;
2013
+ let sandboxConfigPaths: SandboxConfigPath[] = [];
2014
+ let sandboxEvents: SandboxEvent[] = [];
2015
+
2016
+ const pendingNetworkApprovals = new Map<string, Promise<boolean>>();
2017
+
2018
+ function recordSandboxEvent(event: SandboxEvent): void {
2019
+ sandboxEvents.push(event);
2020
+ if (sandboxEvents.length > SANDBOX_EVENT_LIMIT) {
2021
+ sandboxEvents.splice(0, sandboxEvents.length - SANDBOX_EVENT_LIMIT);
2022
+ }
2023
+ }
2024
+
2025
+ function recordNetworkEvent(
2026
+ outcome: SandboxEventOutcome,
2027
+ reason: SandboxEventReason,
2028
+ host: string,
2029
+ port?: number,
2030
+ ): void {
2031
+ const target = port ? `${host}:${port}` : host;
2032
+ recordSandboxEvent({
2033
+ timestamp: Date.now(),
2034
+ kind: "network",
2035
+ outcome,
2036
+ reason,
2037
+ target,
2038
+ cwd: sessionCwd,
2039
+ summary: describeNetworkEventSummary(reason, outcome),
2040
+ suggestedCommand: outcome === "blocked" ? buildNetworkBlockCommand(reason, host) : undefined,
2041
+ });
2042
+ }
2043
+
2044
+ function recordRuntimeEvent(
2045
+ kind: SandboxEventKind,
2046
+ reason: SandboxEventReason,
2047
+ summary: string,
2048
+ ): void {
2049
+ recordSandboxEvent({
2050
+ timestamp: Date.now(),
2051
+ kind,
2052
+ outcome: "blocked",
2053
+ reason,
2054
+ cwd: sessionCwd,
2055
+ summary,
2056
+ });
2057
+ }
2058
+
2059
+ function applyRuntimeConfigForSession(
2060
+ ctx: ExtensionContext,
2061
+ runtimeConfig: SandboxRuntimeConfig,
2062
+ targetStatus: "active" | "suspended" = sandboxState.status === "suspended"
2063
+ ? "suspended"
2064
+ : "active",
2065
+ ): void {
2066
+ const nextConfig = cloneRuntimeConfig(runtimeConfig);
2067
+ sandboxState = { status: targetStatus, runtimeConfig: nextConfig };
2068
+ SandboxManager.updateConfig(nextConfig);
2069
+ setSandboxStatus(ctx, targetStatus === "active", nextConfig, promptMode);
2070
+ }
2071
+
2072
+ const createNetworkAskCallback = (): SandboxAskCallback => {
2073
+ return async ({ host, port }) => {
2074
+ const normalizedHost = host.toLowerCase();
2075
+ const key = normalizedHost;
2076
+
2077
+ const existingDecision = pendingNetworkApprovals.get(key);
2078
+ if (existingDecision) return existingDecision;
2079
+
2080
+ const decision = (async () => {
2081
+ try {
2082
+ const initialConfig = getStateRuntimeConfig(sandboxState);
2083
+ if (!initialConfig) return false;
2084
+
2085
+ if (initialConfig.network.allowedDomains.includes(normalizedHost)) return true;
2086
+ if (initialConfig.network.deniedDomains.includes(normalizedHost)) {
2087
+ recordNetworkEvent("blocked", "explicit-deny-domain", normalizedHost, port);
2088
+ return false;
2089
+ }
2090
+
2091
+ const suggestedCommand = buildNetworkBlockCommand(
2092
+ "missing-allowed-domain",
2093
+ normalizedHost,
2094
+ );
2095
+ const ctx = sessionContext;
2096
+ if (promptMode === "non-interactive" || !ctx || !ctx.hasUI) {
2097
+ recordNetworkEvent("blocked", "missing-allowed-domain", normalizedHost, port);
2098
+ const message = `Sandbox blocked network access to ${normalizedHost}. To temporarily allow for this session, run: ${suggestedCommand}`;
2099
+ if (ctx) notify(ctx, message, "warning");
2100
+ else console.warn(message);
2101
+ return false;
2102
+ }
2103
+
2104
+ const target = port ? `${normalizedHost}:${port}` : normalizedHost;
2105
+ const approved = await withPromptSignal(pi, () =>
2106
+ ctx.ui.confirm(
2107
+ `Sandbox blocked network access to ${target}`,
2108
+ "\nAllow for this session?",
2109
+ ),
2110
+ );
2111
+ if (!approved) {
2112
+ recordNetworkEvent("blocked", "missing-allowed-domain", normalizedHost, port);
2113
+ return false;
2114
+ }
2115
+
2116
+ const latestConfig = getStateRuntimeConfig(sandboxState);
2117
+ if (!latestConfig) return false;
2118
+ if (latestConfig.network.deniedDomains.includes(normalizedHost)) {
2119
+ recordNetworkEvent("blocked", "explicit-deny-domain", normalizedHost, port);
2120
+ notify(
2121
+ ctx,
2122
+ `Network access to ${normalizedHost} remains denied by current sandbox policy. Remove it from deny list to allow.`,
2123
+ "warning",
2124
+ );
2125
+ return false;
2126
+ }
2127
+ if (latestConfig.network.allowedDomains.includes(normalizedHost)) {
2128
+ recordNetworkEvent("allowed", "missing-allowed-domain", normalizedHost, port);
2129
+ return true;
2130
+ }
2131
+
2132
+ const nextConfig = cloneRuntimeConfig(latestConfig);
2133
+ const changed = mutateStringList(
2134
+ nextConfig.network.allowedDomains,
2135
+ "add",
2136
+ normalizedHost,
2137
+ );
2138
+ if (changed) {
2139
+ applyRuntimeConfigForSession(ctx, nextConfig);
2140
+ }
2141
+
2142
+ recordNetworkEvent("allowed", "missing-allowed-domain", normalizedHost, port);
2143
+ notify(ctx, `Allowed network domain for this session: ${normalizedHost}`, "info");
2144
+ return true;
2145
+ } catch (error) {
2146
+ const ctx = sessionContext;
2147
+ const message = `Sandbox permission prompt failed for ${normalizedHost}: ${error instanceof Error ? error.message : error}`;
2148
+ if (ctx) notify(ctx, message, "warning");
2149
+ else console.warn(message);
2150
+ return false;
2151
+ }
2152
+ })();
2153
+
2154
+ pendingNetworkApprovals.set(key, decision);
2155
+ try {
2156
+ return await decision;
2157
+ } finally {
2158
+ pendingNetworkApprovals.delete(key);
2159
+ }
2160
+ };
2161
+ };
2162
+
2163
+ const initializeSandboxRuntime = async (
2164
+ ctx: ExtensionContext,
2165
+ config: SandboxConfig,
2166
+ ): Promise<SandboxRuntimeConfig | null> => {
2167
+ promptMode = normalizePromptMode(config.mode);
2168
+
2169
+ const dependencyErrors = getSandboxDependencyErrors(config);
2170
+ if (dependencyErrors.length > 0) {
2171
+ promptMode = DEFAULT_PROMPT_MODE;
2172
+ pendingNetworkApprovals.clear();
2173
+ sandboxState = { status: "bypassed", reason: "missing-dependencies" };
2174
+ recordRuntimeEvent(
2175
+ "init",
2176
+ "missing-dependencies",
2177
+ `sandbox dependencies missing: ${dependencyErrors.join("; ")}`,
2178
+ );
2179
+ setSandboxStatus(ctx, false);
2180
+ notify(ctx, formatMissingSandboxDependenciesWarning(dependencyErrors), "warning");
2181
+ return null;
2182
+ }
2183
+
2184
+ const runtimeConfig = toRuntimeConfig(config);
2185
+
2186
+ try {
2187
+ await SandboxManager.initialize(runtimeConfig, createNetworkAskCallback(), true);
2188
+ const activeConfig = cloneRuntimeConfig(runtimeConfig);
2189
+ sandboxState = { status: "active", runtimeConfig: activeConfig };
2190
+ return activeConfig;
2191
+ } catch (err) {
2192
+ const errorMessage = err instanceof Error ? err.message : `${err}`;
2193
+ promptMode = DEFAULT_PROMPT_MODE;
2194
+ pendingNetworkApprovals.clear();
2195
+ sandboxState = { status: "blocked", reason: "init-failed" };
2196
+ recordRuntimeEvent("init", "init-failed", `sandbox initialization failed: ${errorMessage}`);
2197
+ setSandboxStatus(ctx, false);
2198
+ notify(ctx, `Sandbox initialization failed: ${errorMessage}`, "error");
2199
+ return null;
2200
+ }
2201
+ };
2202
+
2203
+ const sandboxedOps = createSandboxedBashOps({
2204
+ pi,
2205
+ getContext: () => sessionContext,
2206
+ getSandboxConfig: () => sandboxConfig,
2207
+ getRuntimeConfig: () => getStateRuntimeConfig(sandboxState),
2208
+ getPromptMode: () => promptMode,
2209
+ applyRuntimeConfigForSession,
2210
+ recordEvent: recordSandboxEvent,
2211
+ });
2212
+
2213
+ let localBashTool = createBashTool(sessionCwd);
2214
+ let sandboxedBashTool = createBashTool(sessionCwd, { operations: sandboxedOps });
2215
+
2216
+ const rebuildBashTools = (cwd: string): void => {
2217
+ sessionCwd = cwd;
2218
+ localBashTool = createBashTool(sessionCwd);
2219
+ sandboxedBashTool = createBashTool(sessionCwd, { operations: sandboxedOps });
2220
+ };
2221
+
2222
+ const resetRuntimeState = (): void => {
2223
+ sandboxState = { status: "pending" };
2224
+ sandboxConfig = null;
2225
+ promptMode = DEFAULT_PROMPT_MODE;
2226
+ sandboxConfigPaths = [];
2227
+ sandboxEvents = [];
2228
+ pendingNetworkApprovals.clear();
2229
+ };
2230
+
2231
+ const isSupportedPlatform = (): boolean =>
2232
+ process.platform === "darwin" || process.platform === "linux";
2233
+
2234
+ pi.registerTool({
2235
+ ...localBashTool,
2236
+ label: "bash (sandbox-aware)",
2237
+ async execute(id, params, signal, onUpdate, ctx) {
2238
+ if (sandboxState.status !== "active") {
2239
+ const allowsUnsandboxed =
2240
+ sandboxState.status === "bypassed" || sandboxState.status === "suspended";
2241
+ if (!allowsUnsandboxed) {
2242
+ const runMode = getSandboxRunMode(sandboxState);
2243
+
2244
+ let reason =
2245
+ "Sandbox is not active and unsandboxed execution is blocked. Fix sandbox setup and run /sandbox enable, or restart with --no-sandbox.";
2246
+ if (runMode === "unsupported-platform") {
2247
+ reason =
2248
+ "Sandbox is unsupported on this platform. Re-run with --no-sandbox to allow unsandboxed execution.";
2249
+ } else if (runMode === "init-failed") {
2250
+ reason =
2251
+ "Sandbox initialization failed. Run /sandbox enable to retry, or restart with --no-sandbox.";
2252
+ } else if (runMode === "sandbox") {
2253
+ reason =
2254
+ "Sandbox session initialization is incomplete. Retry after session startup or run /sandbox enable.";
2255
+ }
2256
+
2257
+ throw new Error(reason);
2258
+ }
2259
+ return localBashTool.execute(id, params, signal, onUpdate);
2260
+ }
2261
+
2262
+ if (!sessionContext) sessionContext = ctx;
2263
+ return sandboxedBashTool.execute(id, params, signal, onUpdate);
2264
+ },
2265
+ });
2266
+
2267
+ pi.on("session_start", async (_event, ctx) => {
2268
+ setSandboxStatus(ctx, false);
2269
+ sessionContext = ctx;
2270
+ resetRuntimeState();
2271
+ rebuildBashTools(ctx.cwd);
2272
+
2273
+ const noSandbox = pi.getFlag("no-sandbox") as boolean;
2274
+ if (noSandbox) {
2275
+ sandboxState = { status: "bypassed", reason: "no-sandbox-flag" };
2276
+ notify(ctx, "Sandbox disabled via --no-sandbox", "warning");
2277
+ return;
2278
+ }
2279
+
2280
+ const sandboxConfigOverride = getStringFlag(pi, "sandbox-config");
2281
+ const loadedConfig = loadSandboxConfigForContext(ctx, ctx.cwd, sandboxConfigOverride, {
2282
+ exitOnError: true,
2283
+ });
2284
+ if (!loadedConfig) return;
2285
+
2286
+ sandboxConfigPaths = loadedConfig.paths;
2287
+ const parseErrors = getSandboxConfigParseErrors(sandboxConfigPaths);
2288
+ if (parseErrors.length > 0) {
2289
+ notifySandboxConfigParseErrors(ctx, parseErrors);
2290
+ }
2291
+ const config = loadedConfig.config;
2292
+ sandboxConfig = config;
2293
+
2294
+ if (!config.enabled) {
2295
+ sandboxState = { status: "bypassed", reason: "config-disabled" };
2296
+ notify(ctx, "Sandbox disabled via config", "info");
2297
+ return;
2298
+ }
2299
+
2300
+ if (!isSupportedPlatform()) {
2301
+ sandboxState = { status: "blocked", reason: "unsupported-platform" };
2302
+ recordRuntimeEvent(
2303
+ "init",
2304
+ "unsupported-platform",
2305
+ `sandbox not supported on ${process.platform}`,
2306
+ );
2307
+ notify(ctx, `Sandbox not supported on ${process.platform}`, "warning");
2308
+ return;
2309
+ }
2310
+
2311
+ const runtimeConfig = await initializeSandboxRuntime(ctx, config);
2312
+ if (!runtimeConfig) return;
2313
+
2314
+ setSandboxStatus(ctx, true, runtimeConfig, promptMode);
2315
+ notify(ctx, "Sandbox initialized", "info");
2316
+ });
2317
+
2318
+ pi.on("session_shutdown", async (_event, ctx) => {
2319
+ setSandboxStatus(ctx, false);
2320
+ if (getStateRuntimeConfig(sandboxState)) {
2321
+ try {
2322
+ await SandboxManager.reset();
2323
+ } catch {
2324
+ // Ignore cleanup errors
2325
+ }
2326
+ }
2327
+
2328
+ resetRuntimeState();
2329
+ sessionContext = null;
2330
+ rebuildBashTools(process.cwd());
2331
+ });
2332
+
2333
+ pi.registerCommand("sandbox", {
2334
+ description: "Manage sandbox runtime overrides",
2335
+ getArgumentCompletions: (prefix) =>
2336
+ getSandboxArgumentCompletions(prefix, getStateRuntimeConfig(sandboxState)),
2337
+ handler: async (args, ctx) => {
2338
+ const tokens = parseCommandArgs(args);
2339
+ const subcommand = normalizeSubcommand(tokens[0]);
2340
+
2341
+ if (!subcommand || subcommand === "help") {
2342
+ showHelp(ctx);
2343
+ return;
2344
+ }
2345
+
2346
+ if (subcommand === "doctor") {
2347
+ if (tokens.length > 1) {
2348
+ notify(ctx, "Usage: /sandbox doctor", "warning");
2349
+ return;
2350
+ }
2351
+
2352
+ notify(
2353
+ ctx,
2354
+ renderSandboxDoctorReport({
2355
+ state: sandboxState,
2356
+ promptMode,
2357
+ configPaths: sandboxConfigPaths,
2358
+ events: sandboxEvents,
2359
+ }),
2360
+ "info",
2361
+ );
2362
+ return;
2363
+ }
2364
+
2365
+ if (subcommand === "enable") {
2366
+ if (tokens.length > 1) {
2367
+ notify(ctx, "Usage: /sandbox enable|on", "warning");
2368
+ return;
2369
+ }
2370
+
2371
+ if (sandboxState.status === "active") {
2372
+ notify(ctx, "Sandbox is already enabled", "info");
2373
+ return;
2374
+ }
2375
+
2376
+ let runtimeConfig: SandboxRuntimeConfig | null = null;
2377
+ let initializedNow = false;
2378
+
2379
+ if (sandboxState.status === "suspended") {
2380
+ runtimeConfig = sandboxState.runtimeConfig;
2381
+ } else {
2382
+ if (!isSupportedPlatform()) {
2383
+ sandboxState = { status: "blocked", reason: "unsupported-platform" };
2384
+ recordRuntimeEvent(
2385
+ "init",
2386
+ "unsupported-platform",
2387
+ `sandbox not supported on ${process.platform}`,
2388
+ );
2389
+ notify(ctx, `Sandbox not supported on ${process.platform}`, "warning");
2390
+ return;
2391
+ }
2392
+
2393
+ const sandboxConfigOverride = getStringFlag(pi, "sandbox-config");
2394
+ const loadedConfig = loadSandboxConfigForContext(ctx, ctx.cwd, sandboxConfigOverride);
2395
+ if (!loadedConfig) return;
2396
+
2397
+ sandboxConfigPaths = loadedConfig.paths;
2398
+ const parseErrors = getSandboxConfigParseErrors(sandboxConfigPaths);
2399
+ if (parseErrors.length > 0) {
2400
+ notifySandboxConfigParseErrors(ctx, parseErrors);
2401
+ }
2402
+ sandboxConfig = loadedConfig.config;
2403
+ runtimeConfig = await initializeSandboxRuntime(ctx, loadedConfig.config);
2404
+ if (!runtimeConfig) return;
2405
+ initializedNow = true;
2406
+ }
2407
+
2408
+ if (!runtimeConfig) return;
2409
+
2410
+ if (initializedNow) {
2411
+ setSandboxStatus(ctx, true, runtimeConfig, promptMode);
2412
+ } else {
2413
+ applyRuntimeConfigForSession(ctx, runtimeConfig, "active");
2414
+ }
2415
+
2416
+ notify(ctx, "Sandbox enabled", "info");
2417
+ return;
2418
+ }
2419
+
2420
+ if (subcommand === "disable") {
2421
+ if (tokens.length > 1) {
2422
+ notify(ctx, "Usage: /sandbox disable|off", "warning");
2423
+ return;
2424
+ }
2425
+
2426
+ if (sandboxState.status !== "active") {
2427
+ notify(ctx, `Sandbox is not active (mode: ${getSandboxRunMode(sandboxState)})`, "info");
2428
+ return;
2429
+ }
2430
+
2431
+ sandboxState = { status: "suspended", runtimeConfig: sandboxState.runtimeConfig };
2432
+ setSandboxStatus(ctx, false);
2433
+ notify(ctx, "Sandbox disabled", "info");
2434
+ return;
2435
+ }
2436
+
2437
+ if (subcommand === "show") {
2438
+ if (tokens.length > 1) {
2439
+ notify(ctx, "Usage: /sandbox show", "warning");
2440
+ return;
2441
+ }
2442
+
2443
+ if (sandboxState.status !== "active") {
2444
+ notify(ctx, `Sandbox is disabled (mode: ${getSandboxRunMode(sandboxState)})`, "info");
2445
+ return;
2446
+ }
2447
+
2448
+ const runtimeConfig = sandboxState.runtimeConfig;
2449
+ const lines = [
2450
+ "Sandbox Configuration (session):",
2451
+ ` State: enabled`,
2452
+ ` Mode: ${promptMode}`,
2453
+ ` Runtime state: ${getSandboxRunMode(sandboxState)}`,
2454
+ "",
2455
+ " Network:",
2456
+ ` Allowed: ${runtimeConfig.network.allowedDomains.join(", ") || "(none)"}`,
2457
+ ` Denied: ${runtimeConfig.network.deniedDomains.join(", ") || "(none)"}`,
2458
+ "",
2459
+ " Filesystem:",
2460
+ ` Deny Read: ${runtimeConfig.filesystem.denyRead.join(", ") || "(none)"}`,
2461
+ ` Allow Write: ${runtimeConfig.filesystem.allowWrite.join(", ") || "(none)"}`,
2462
+ ` Deny Write: ${runtimeConfig.filesystem.denyWrite.join(", ") || "(none)"}`,
2463
+ ` allowGitCommonDir: ${sandboxConfig?.filesystem.allowGitCommonDir ? "true" : "false"}`,
2464
+ "",
2465
+ " Advanced:",
2466
+ ` ignoreViolations: ${runtimeConfig.ignoreViolations ? "configured" : "(none)"}`,
2467
+ ` enableWeakerNestedSandbox: ${runtimeConfig.enableWeakerNestedSandbox ? "true" : "false"}`,
2468
+ ` enableWeakerNetworkIsolation: ${runtimeConfig.enableWeakerNetworkIsolation ? "true" : "false"}`,
2469
+ ];
2470
+
2471
+ notify(ctx, lines.join("\n"), "info");
2472
+ return;
2473
+ }
2474
+
2475
+ if (subcommand === "mode") {
2476
+ if (tokens.length !== 2) {
2477
+ notify(ctx, "Usage: /sandbox mode <interactive|non-interactive>", "warning");
2478
+ return;
2479
+ }
2480
+
2481
+ const modeToken = tokens[1].toLowerCase();
2482
+ if (modeToken !== "interactive" && modeToken !== "non-interactive") {
2483
+ notify(ctx, "Usage: /sandbox mode <interactive|non-interactive>", "warning");
2484
+ return;
2485
+ }
2486
+
2487
+ promptMode = normalizePromptMode(modeToken);
2488
+
2489
+ if (sandboxState.status === "active") {
2490
+ setSandboxStatus(ctx, true, sandboxState.runtimeConfig, promptMode);
2491
+ }
2492
+
2493
+ notify(ctx, `Sandbox mode set to ${promptMode}`, "info");
2494
+ return;
2495
+ }
2496
+
2497
+ if (subcommand === "network") {
2498
+ const runtimeConfig = requireRuntimeConfig(ctx, sandboxState);
2499
+ if (!runtimeConfig) return;
2500
+
2501
+ const list = tokens[1]?.toLowerCase() as NetworkList | undefined;
2502
+ const op = tokens[2]?.toLowerCase() as ListOp | undefined;
2503
+ const domain = tokens[3]?.trim() ?? "";
2504
+
2505
+ if (
2506
+ (list !== "allow" && list !== "deny") ||
2507
+ (op !== "add" && op !== "remove") ||
2508
+ tokens.length !== 4 ||
2509
+ !domain ||
2510
+ /\s/.test(domain)
2511
+ ) {
2512
+ notify(ctx, "Usage: /sandbox network <allow|deny> <add|remove> <domain>", "warning");
2513
+ return;
2514
+ }
2515
+
2516
+ const nextConfig = cloneRuntimeConfig(runtimeConfig);
2517
+ const values =
2518
+ list === "allow" ? nextConfig.network.allowedDomains : nextConfig.network.deniedDomains;
2519
+ const changed = mutateStringList(values, op, domain);
2520
+ if (!changed) {
2521
+ notify(
2522
+ ctx,
2523
+ `No change: network ${list} list already ${op === "add" ? "contains" : "omits"} ${domain}`,
2524
+ );
2525
+ return;
2526
+ }
2527
+
2528
+ applyRuntimeConfigForSession(ctx, nextConfig);
2529
+ notify(ctx, `Updated network ${list} list (${op}: ${domain})`, "info");
2530
+ return;
2531
+ }
2532
+
2533
+ if (subcommand === "filesystem") {
2534
+ const runtimeConfig = requireRuntimeConfig(ctx, sandboxState);
2535
+ if (!runtimeConfig) return;
2536
+
2537
+ const list = tokens[1]?.toLowerCase() as FilesystemList | undefined;
2538
+ const op = tokens[2]?.toLowerCase() as ListOp | undefined;
2539
+ const targetPath = tokens.slice(3).join(" ").trim();
2540
+
2541
+ if (
2542
+ (list !== "deny-read" && list !== "allow-write" && list !== "deny-write") ||
2543
+ (op !== "add" && op !== "remove") ||
2544
+ !targetPath
2545
+ ) {
2546
+ notify(
2547
+ ctx,
2548
+ "Usage: /sandbox filesystem <deny-read|allow-write|deny-write> <add|remove> <path>",
2549
+ "warning",
2550
+ );
2551
+ return;
2552
+ }
2553
+
2554
+ const nextConfig = cloneRuntimeConfig(runtimeConfig);
2555
+ const values =
2556
+ list === "deny-read"
2557
+ ? nextConfig.filesystem.denyRead
2558
+ : list === "allow-write"
2559
+ ? nextConfig.filesystem.allowWrite
2560
+ : nextConfig.filesystem.denyWrite;
2561
+ const changed = mutateStringList(values, op, targetPath);
2562
+ if (!changed) {
2563
+ notify(
2564
+ ctx,
2565
+ `No change: filesystem ${list} list already ${op === "add" ? "contains" : "omits"} ${targetPath}`,
2566
+ );
2567
+ return;
2568
+ }
2569
+
2570
+ applyRuntimeConfigForSession(ctx, nextConfig);
2571
+ notify(ctx, `Updated filesystem ${list} list (${op}: ${targetPath})`, "info");
2572
+ return;
2573
+ }
2574
+
2575
+ notify(ctx, `Unknown subcommand: ${subcommand}. Use /sandbox for help`, "error");
2576
+ },
2577
+ });
2578
+ }