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