tau-coding-agent 0.1.4 → 0.1.6

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 (51) hide show
  1. package/README.md +1 -1
  2. package/extensions/answer.ts +59 -47
  3. package/extensions/branch-term.ts +8 -13
  4. package/extensions/btw.ts +77 -6
  5. package/extensions/ghostty.ts +64 -11
  6. package/extensions/insights.ts +42 -4
  7. package/extensions/loop.ts +51 -12
  8. package/extensions/memory.ts +35 -17
  9. package/extensions/notify.ts +24 -5
  10. package/extensions/openai-fast.ts +6 -4
  11. package/extensions/openai-verbosity.ts +6 -4
  12. package/extensions/review/fix.ts +239 -0
  13. package/extensions/review/git.ts +517 -0
  14. package/extensions/review/index.ts +232 -0
  15. package/extensions/review/message-queue.ts +249 -0
  16. package/extensions/review/models.ts +589 -0
  17. package/extensions/review/prompts.ts +337 -0
  18. package/extensions/review/renderers/inline.ts +126 -0
  19. package/extensions/review/request.ts +504 -0
  20. package/extensions/review/review.ts +1305 -0
  21. package/extensions/review/runner.ts +522 -0
  22. package/extensions/review/runtime.ts +275 -0
  23. package/extensions/review/schema.ts +160 -0
  24. package/extensions/review/submit-review-tool.ts +62 -0
  25. package/extensions/review/submit-triage-tool.ts +60 -0
  26. package/extensions/review/triage.ts +746 -0
  27. package/extensions/sandbox/index.ts +976 -185
  28. package/extensions/sandbox/macos-sandbox-shell.mjs +202 -0
  29. package/extensions/{interlude.ts → stash.ts} +13 -11
  30. package/extensions/tool-display-mode.ts +2 -3
  31. package/extensions/usage/anthropic.ts +5 -6
  32. package/extensions/usage/github-copilot.ts +30 -9
  33. package/extensions/usage/index.ts +9 -5
  34. package/extensions/usage/openai-codex.ts +2 -2
  35. package/extensions/websearch/README.md +1 -1
  36. package/extensions/websearch/browser/chromium.ts +42 -31
  37. package/extensions/websearch/browser/discovery.ts +11 -9
  38. package/extensions/websearch/browser/firefox.ts +10 -7
  39. package/extensions/websearch/browser/fs.ts +10 -0
  40. package/extensions/websearch/browser/sqlite.ts +22 -15
  41. package/extensions/websearch/config.ts +2 -2
  42. package/extensions/websearch/index.ts +6 -6
  43. package/extensions/websearch/providers/pi-model.shared.ts +19 -1
  44. package/extensions/worktree.ts +33 -33
  45. package/package.json +4 -4
  46. package/skills/git-clean-history/SKILL.md +1 -0
  47. package/skills/git-commit/SKILL.md +2 -2
  48. package/skills/oracle/SKILL.md +3 -3
  49. package/skills/oracle/scripts/oracle +82 -61
  50. package/themes/tau-dark.json +2 -1
  51. package/extensions/review.ts +0 -4420
@@ -21,7 +21,12 @@
21
21
  * "mode": "interactive",
22
22
  * "network": {
23
23
  * "allowedDomains": ["github.com", "*.github.com"],
24
- * "deniedDomains": []
24
+ * "deniedDomains": [],
25
+ * "allowMachLookup": [
26
+ * "com.apple.dnssd.service",
27
+ * "com.apple.SystemConfiguration.configd",
28
+ * "com.apple.SystemConfiguration.DNSConfiguration"
29
+ * ]
25
30
  * },
26
31
  * "filesystem": {
27
32
  * "denyRead": ["~/.ssh", "~/.aws"],
@@ -50,6 +55,7 @@ import { spawn, spawnSync } from "node:child_process";
50
55
  import { existsSync, readFileSync } from "node:fs";
51
56
  import { homedir, tmpdir } from "node:os";
52
57
  import { join, resolve } from "node:path";
58
+ import { fileURLToPath } from "node:url";
53
59
  import {
54
60
  SandboxManager,
55
61
  type SandboxAskCallback,
@@ -63,12 +69,22 @@ import {
63
69
  globToRegex,
64
70
  normalizePathForSandbox,
65
71
  } from "@anthropic-ai/sandbox-runtime/dist/sandbox/sandbox-utils.js";
66
- import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
67
- import { type BashOperations, createBashTool } from "@earendil-works/pi-coding-agent";
72
+ import {
73
+ CONFIG_DIR_NAME,
74
+ createBashTool,
75
+ getAgentDir,
76
+ type BashOperations,
77
+ type ExtensionAPI,
78
+ type ExtensionContext,
79
+ } from "@earendil-works/pi-coding-agent";
68
80
 
69
81
  // --- Constants ---
70
82
 
71
83
  const DEFAULT_PROMPT_MODE: PromptMode = "interactive";
84
+ const IS_MACOS = process.platform === "darwin";
85
+ const MACOS_SANDBOX_SHELL = fileURLToPath(new URL("./macos-sandbox-shell.mjs", import.meta.url));
86
+ const MAX_TIMEOUT_MS = 2_147_483_647;
87
+ const MAX_TIMEOUT_SECONDS = MAX_TIMEOUT_MS / 1000;
72
88
 
73
89
  const DEFAULT_CONFIG: SandboxConfig = {
74
90
  enabled: true,
@@ -79,30 +95,43 @@ const DEFAULT_CONFIG: SandboxConfig = {
79
95
  "127.0.0.1",
80
96
  "npmjs.org",
81
97
  "*.npmjs.org",
82
- "registry.npmjs.org",
98
+ "npmjs.com",
99
+ "*.npmjs.com",
83
100
  "registry.yarnpkg.com",
101
+ "nodejs.org",
102
+ "*.nodejs.org",
84
103
  "pypi.org",
85
104
  "*.pypi.org",
86
105
  "pythonhosted.org",
87
106
  "*.pythonhosted.org",
88
107
  "crates.io",
89
108
  "*.crates.io",
109
+ "rustup.rs",
110
+ "*.rust-lang.org",
111
+ "repo.maven.apache.org",
112
+ "gradle.org",
113
+ "*.gradle.org",
90
114
  "proxy.golang.org",
91
115
  "sum.golang.org",
92
116
  "go.dev",
93
117
  "golang.org",
94
118
  "rubygems.org",
95
119
  "*.rubygems.org",
120
+ "nuget.org",
121
+ "*.nuget.org",
96
122
  "github.com",
97
123
  "*.github.com",
98
124
  "githubusercontent.com",
99
125
  "*.githubusercontent.com",
100
- "api.github.com",
101
- "raw.githubusercontent.com",
126
+ "gitlab.com",
127
+ "*.gitlab.com",
128
+ "bitbucket.org",
129
+ "*.bitbucket.org",
102
130
  "ghcr.io",
103
131
  "docker.io",
104
132
  "*.docker.io",
105
- "production.cloudflare.docker.com",
133
+ "docker.com",
134
+ "*.docker.com",
106
135
  "sentry.io",
107
136
  "*.sentry.io",
108
137
  "datadoghq.com",
@@ -117,6 +146,8 @@ const DEFAULT_CONFIG: SandboxConfig = {
117
146
  "*.openai.com",
118
147
  "chatgpt.com",
119
148
  "*.chatgpt.com",
149
+ "openrouter.ai",
150
+ "*.openrouter.ai",
120
151
  "google.com",
121
152
  "*.google.com",
122
153
  "googleapis.com",
@@ -129,26 +160,87 @@ const DEFAULT_CONFIG: SandboxConfig = {
129
160
  "*.doist.com",
130
161
  ],
131
162
  deniedDomains: [],
163
+ allowUnixSockets: ["$SSH_AUTH_SOCK"],
132
164
  allowLocalBinding: true,
165
+ allowMachLookup: [
166
+ "com.apple.dnssd.service",
167
+ "com.apple.SystemConfiguration.configd",
168
+ "com.apple.SystemConfiguration.DNSConfiguration",
169
+ ],
133
170
  },
134
171
  filesystem: {
135
172
  denyRead: ["~/.ssh", "~/.aws", "~/.gnupg"],
136
- allowWrite: [".", "~/.cache", "~/.npm"],
137
- denyWrite: [".env", ".env.*", "*.pem", "*.key"],
173
+ allowRead: ["~/.ssh/config", "~/.ssh/known_hosts", "~/.ssh/*.pub"],
174
+ allowWrite: [
175
+ ".",
176
+ "~/.cache",
177
+ "~/Library/Caches",
178
+ join(getAgentDir(), "*.lock"),
179
+ "~/**/__pycache__",
180
+ "~/**/__pycache__/*",
181
+ "~/.npm",
182
+ "~/.rustup",
183
+ "~/.cargo/registry",
184
+ "~/.cargo/git",
185
+ "~/.cargo/.package-cache",
186
+ "~/.cargo/.package-cache-mutate",
187
+ "~/.cargo/.global-cache",
188
+ "~/go/pkg",
189
+ "~/.m2/repository",
190
+ "~/.m2/wrapper/dists",
191
+ "~/.gradle",
192
+ "~/Library/Application Support/kotlin",
193
+ "~/.android",
194
+ "~/.bundle/cache",
195
+ "~/.gem/cache",
196
+ "~/.gem/specs",
197
+ "~/.nuget/packages",
198
+ "~/.local/share/NuGet/*-cache",
199
+ "~/.local/share/NuGet/*-cache/**",
200
+ ],
201
+ denyWrite: [
202
+ ".env",
203
+ ".env.*",
204
+ "*.pem",
205
+ "*.key",
206
+ "~/.gradle/gradle.properties",
207
+ "~/.gradle/init.gradle",
208
+ "~/.gradle/init.gradle.kts",
209
+ "~/.gradle/init.d",
210
+ "~/.android/adbkey",
211
+ ],
138
212
  allowTempDirs: true,
139
213
  allowGitConfig: true,
140
214
  allowGitCommonDir: true,
141
215
  },
216
+ ignoreViolations: {
217
+ "*": ["/__pycache__", "mach-lookup com.apple.usymptomsd"],
218
+ },
142
219
  };
143
220
 
144
221
  const STATUS_KEY = "sandbox";
145
222
  const SANDBOX_EVENT_LIMIT = 50;
146
- const METADATA_TRAVERSAL_PROCESSES = new Set(["find", "ls", "fd", "fdfind"]);
223
+ const READ_TRAVERSAL_PROCESSES = new Set(["find", "ls", "fd", "fdfind"]);
147
224
  const GIT_FILESYSTEM_PATHS_CACHE = new Map<string, GitFilesystemPaths | null>();
225
+ const ENV_PATH_REFERENCE_PATTERN = /\$(?:\{([A-Za-z_][A-Za-z0-9_]*)\}|([A-Za-z_][A-Za-z0-9_]*))/g;
148
226
 
149
227
  // --- Types ---
150
228
 
151
229
  type PromptMode = "interactive" | "non-interactive";
230
+ type ListOp = "add" | "remove";
231
+ type SandboxEventOutcome = "blocked" | "allowed";
232
+ type ViolationResolutionKind = "allow-retry" | "allow-adapt" | "deny";
233
+
234
+ type ViolationResolution =
235
+ | {
236
+ kind: "allow-retry";
237
+ message: string;
238
+ retrySuccessMessage: string;
239
+ retryFailureMessage: string;
240
+ retrySkippedMessage: string;
241
+ }
242
+ | { kind: "allow-adapt"; message: string }
243
+ | { kind: "deny"; message: string };
152
244
 
153
245
  type SandboxBypassReason = "no-sandbox-flag" | "config-disabled" | "missing-dependencies";
154
246
  type SandboxBlockedReason = "unsupported-platform" | "init-failed";
@@ -158,7 +250,7 @@ type SandboxRunMode = "sandbox" | "user-disabled" | SandboxBypassReason | Sandbo
158
250
  type SandboxState =
159
251
  | { status: "pending" }
160
252
  | { status: "active"; runtimeConfig: SandboxRuntimeConfig }
161
- | { status: "suspended"; runtimeConfig: SandboxRuntimeConfig }
253
+ | { status: "suspended" }
162
254
  | { status: "bypassed"; reason: SandboxBypassReason }
163
255
  | { status: "blocked"; reason: SandboxBlockedReason };
164
256
 
@@ -171,33 +263,32 @@ type SandboxConfig = Omit<SandboxRuntimeConfig, "filesystem"> & {
171
263
  };
172
264
  };
173
265
 
174
- type SandboxEventKind = "filesystem" | "network" | "init" | "runtime";
266
+ type SandboxEventKind = "filesystem" | "network" | "mach-lookup" | "init" | "runtime";
175
267
  type SandboxEventReason =
176
268
  | "explicit-deny-read"
177
269
  | "explicit-deny-write"
178
270
  | "explicit-deny-domain"
179
271
  | "missing-allow-write"
180
272
  | "missing-allowed-domain"
273
+ | "missing-mach-lookup"
181
274
  | "missing-dependencies"
182
275
  | "unsupported-platform"
183
276
  | "init-failed"
277
+ | "runtime-protected-write"
184
278
  | "already-approved-still-failed"
185
279
  | "unknown";
186
- type SandboxEventOutcome = "blocked" | "allowed";
187
- type SandboxConfigPathStatus = "loaded" | "parse-error";
280
+ type SandboxConfigPathStatus = "loaded" | "parse-error" | "skipped-untrusted";
188
281
  type SandboxConfigPathLabel = "Global" | "Project" | "Override";
189
282
 
190
283
  type PromptStatus = "completed" | "error";
191
284
  type UiLevel = "info" | "warning" | "error";
192
285
 
193
- type ListOp = "add" | "remove";
194
286
  type NetworkList = "allow" | "deny";
195
287
  type FilesystemList = "deny-read" | "allow-write" | "deny-write";
196
288
 
197
289
  type FilesystemViolationKind = "read" | "write" | "unknown";
198
290
  type FilesystemReadAccess = "metadata" | "data" | "unknown";
199
- type FilesystemViolationResolutionKind = "allow-retry" | "allow-adapt" | "deny";
200
-
291
+ type FilesystemWriteAccess = "unlink" | "unknown";
201
292
  interface SandboxEvent {
202
293
  timestamp: number;
203
294
  kind: SandboxEventKind;
@@ -245,18 +336,12 @@ interface FilesystemViolation {
245
336
  path?: string;
246
337
  processName?: string;
247
338
  readAccess?: FilesystemReadAccess;
339
+ writeAccess?: FilesystemWriteAccess;
248
340
  }
249
341
 
250
- type FilesystemViolationResolution =
251
- | {
252
- kind: "allow-retry";
253
- message: string;
254
- retrySuccessMessage: string;
255
- retryFailureMessage: string;
256
- retrySkippedMessage: string;
257
- }
258
- | { kind: "allow-adapt"; message: string }
259
- | { kind: "deny"; message: string };
342
+ interface MachLookupViolation {
343
+ service: string;
344
+ }
260
345
 
261
346
  // --- Helpers ---
262
347
 
@@ -307,6 +392,19 @@ function notify(ctx: ExtensionContext, text: string, level: UiLevel = "info"): v
307
392
  else console.log(text);
308
393
  }
309
394
 
395
+ function announceSandboxState(pi: ExtensionAPI, ctx: ExtensionContext, enabled: boolean): void {
396
+ const text = `Sandbox ${enabled ? "enabled" : "disabled"}`;
397
+ notify(ctx, text, "info");
398
+ pi.sendMessage(
399
+ {
400
+ customType: "sandbox-state",
401
+ content: text,
402
+ display: false,
403
+ },
404
+ { triggerTurn: false },
405
+ );
406
+ }
407
+
310
408
  function showHelp(ctx: ExtensionContext): void {
311
409
  const lines = [
312
410
  "Usage:",
@@ -316,6 +414,12 @@ function showHelp(ctx: ExtensionContext): void {
316
414
  " /sandbox doctor",
317
415
  " /sandbox mode <interactive|non-interactive>",
318
416
  " /sandbox network <allow|deny> <add|remove> <domain>",
417
+ ...(IS_MACOS
418
+ ? [
419
+ " /sandbox mach-lookup <add|remove> <service>",
420
+ " Service rules support one trailing *; use * for all services.",
421
+ ]
422
+ : []),
319
423
  " /sandbox filesystem <deny-read|allow-write|deny-write> <add|remove> <path>",
320
424
  "",
321
425
  "Startup flags:",
@@ -371,6 +475,7 @@ const SANDBOX_TOP_LEVEL_COMPLETIONS: CommandCompletionOption[] = [
371
475
  { value: "doctor", label: "doctor" },
372
476
  { value: "mode ", label: "mode" },
373
477
  { value: "network ", label: "network" },
478
+ ...(IS_MACOS ? [{ value: "mach-lookup ", label: "mach-lookup" }] : []),
374
479
  { value: "filesystem ", label: "filesystem" },
375
480
  { value: "help", label: "help" },
376
481
  ];
@@ -436,6 +541,37 @@ function getStringValueCompletions(
436
541
  }));
437
542
  }
438
543
 
544
+ function getMachLookupArgumentCompletions(options: {
545
+ tokens: string[];
546
+ endsWithSpace: boolean;
547
+ runtimeConfig: SandboxRuntimeConfig | null;
548
+ }): Array<{ value: string; label: string; description?: string }> | null {
549
+ const { tokens, endsWithSpace, runtimeConfig } = options;
550
+
551
+ if (tokens.length === 1 && endsWithSpace) {
552
+ return getCommandCompletions("mach-lookup ", "", SANDBOX_LIST_OPERATION_COMPLETIONS);
553
+ }
554
+ if (tokens.length === 2 && !endsWithSpace) {
555
+ return getCommandCompletions(
556
+ "mach-lookup ",
557
+ tokens[1] ?? "",
558
+ SANDBOX_LIST_OPERATION_COMPLETIONS,
559
+ );
560
+ }
561
+
562
+ if (tokens[1]?.toLowerCase() !== "remove") return null;
563
+
564
+ const values = runtimeConfig?.network.allowMachLookup ?? [];
565
+ const valueBase = "mach-lookup remove ";
566
+ if (tokens.length === 2 && endsWithSpace) {
567
+ return getStringValueCompletions(valueBase, "", values);
568
+ }
569
+ if (tokens.length === 3 && !endsWithSpace) {
570
+ return getStringValueCompletions(valueBase, tokens[2] ?? "", values);
571
+ }
572
+ return null;
573
+ }
574
+
439
575
  function getSandboxArgumentCompletions(
440
576
  prefix: string,
441
577
  runtimeConfig: SandboxRuntimeConfig | null,
@@ -499,6 +635,10 @@ function getSandboxArgumentCompletions(
499
635
  return null;
500
636
  }
501
637
 
638
+ if (subcommand === "mach-lookup" && IS_MACOS) {
639
+ return getMachLookupArgumentCompletions({ tokens, endsWithSpace, runtimeConfig });
640
+ }
641
+
502
642
  if (subcommand === "filesystem") {
503
643
  if (tokens.length === 1 && endsWithSpace) {
504
644
  return getCommandCompletions("filesystem ", "", SANDBOX_FILESYSTEM_LIST_COMPLETIONS);
@@ -575,12 +715,7 @@ function formatSandboxConfigLoadErrorMessage(
575
715
  return `Could not read sandbox override config ${path}: ${detail ?? "unknown error"}`;
576
716
  }
577
717
 
578
- function coerceStringArray(value: unknown, fallback: string[], field: string): string[] {
579
- if (!Array.isArray(value)) {
580
- console.error(`Warning: Expected ${field} to be a string[]; using defaults.`);
581
- return [...fallback];
582
- }
583
-
718
+ function cleanStringArray(value: unknown[], field: string): string[] {
584
719
  const cleaned = value
585
720
  .filter((item): item is string => typeof item === "string")
586
721
  .map((item) => item.trim())
@@ -594,6 +729,30 @@ function coerceStringArray(value: unknown, fallback: string[], field: string): s
594
729
  return cleaned;
595
730
  }
596
731
 
732
+ function coerceStringArray(value: unknown, fallback: string[], field: string): string[] {
733
+ if (!Array.isArray(value)) {
734
+ console.error(`Warning: Expected ${field} to be a string[]; using defaults.`);
735
+ return [...fallback];
736
+ }
737
+
738
+ return cleanStringArray(value, field);
739
+ }
740
+
741
+ function coerceOptionalStringArray(
742
+ value: unknown,
743
+ fallback: string[] | undefined,
744
+ field: string,
745
+ ): string[] | undefined {
746
+ if (value === undefined) return fallback ? [...fallback] : undefined;
747
+
748
+ if (!Array.isArray(value)) {
749
+ console.error(`Warning: Expected ${field} to be a string[]; using defaults.`);
750
+ return fallback ? [...fallback] : undefined;
751
+ }
752
+
753
+ return cleanStringArray(value, field);
754
+ }
755
+
597
756
  function finalizeConfig(config: SandboxConfig): SandboxConfig {
598
757
  return {
599
758
  ...config,
@@ -611,6 +770,16 @@ function finalizeConfig(config: SandboxConfig): SandboxConfig {
611
770
  DEFAULT_CONFIG.network.deniedDomains,
612
771
  "network.deniedDomains",
613
772
  ),
773
+ allowMachLookup: coerceStringArray(
774
+ config.network?.allowMachLookup,
775
+ DEFAULT_CONFIG.network.allowMachLookup ?? [],
776
+ "network.allowMachLookup",
777
+ ),
778
+ allowUnixSockets: coerceOptionalStringArray(
779
+ config.network?.allowUnixSockets,
780
+ DEFAULT_CONFIG.network.allowUnixSockets,
781
+ "network.allowUnixSockets",
782
+ ),
614
783
  },
615
784
  filesystem: {
616
785
  ...config.filesystem,
@@ -619,6 +788,11 @@ function finalizeConfig(config: SandboxConfig): SandboxConfig {
619
788
  DEFAULT_CONFIG.filesystem.denyRead,
620
789
  "filesystem.denyRead",
621
790
  ),
791
+ allowRead: coerceOptionalStringArray(
792
+ config.filesystem?.allowRead,
793
+ DEFAULT_CONFIG.filesystem.allowRead,
794
+ "filesystem.allowRead",
795
+ ),
622
796
  allowWrite: coerceStringArray(
623
797
  config.filesystem?.allowWrite,
624
798
  DEFAULT_CONFIG.filesystem.allowWrite,
@@ -680,13 +854,17 @@ function loadOverrideConfig(cwd: string, overrideConfigPath: string): LoadedSand
680
854
  };
681
855
  }
682
856
 
683
- function loadConfig(cwd: string, overrideConfigPath?: string): LoadedSandboxConfig {
857
+ function loadConfig(
858
+ cwd: string,
859
+ overrideConfigPath?: string,
860
+ options: { projectTrusted?: boolean } = {},
861
+ ): LoadedSandboxConfig {
684
862
  if (overrideConfigPath) {
685
863
  return loadOverrideConfig(cwd, overrideConfigPath);
686
864
  }
687
865
 
688
- const projectConfigPath = join(cwd, ".pi", "sandbox.json");
689
- const globalConfigPath = join(homedir(), ".pi", "agent", "sandbox.json");
866
+ const projectConfigPath = join(cwd, CONFIG_DIR_NAME, "sandbox.json");
867
+ const globalConfigPath = join(getAgentDir(), "sandbox.json");
690
868
 
691
869
  let globalConfig: Partial<SandboxConfig> = {};
692
870
  let projectConfig: Partial<SandboxConfig> = {};
@@ -703,12 +881,16 @@ function loadConfig(cwd: string, overrideConfigPath?: string): LoadedSandboxConf
703
881
  }
704
882
 
705
883
  if (existsSync(projectConfigPath)) {
706
- try {
707
- projectConfig = JSON.parse(readFileSync(projectConfigPath, "utf-8"));
708
- paths.push({ label: "Project", path: projectConfigPath, status: "loaded" });
709
- } catch (e) {
710
- paths.push({ label: "Project", path: projectConfigPath, status: "parse-error" });
711
- console.error(`Warning: Could not parse ${projectConfigPath}: ${e}`);
884
+ if (options.projectTrusted !== true) {
885
+ paths.push({ label: "Project", path: projectConfigPath, status: "skipped-untrusted" });
886
+ } else {
887
+ try {
888
+ projectConfig = JSON.parse(readFileSync(projectConfigPath, "utf-8"));
889
+ paths.push({ label: "Project", path: projectConfigPath, status: "loaded" });
890
+ } catch (e) {
891
+ paths.push({ label: "Project", path: projectConfigPath, status: "parse-error" });
892
+ console.error(`Warning: Could not parse ${projectConfigPath}: ${e}`);
893
+ }
712
894
  }
713
895
  }
714
896
 
@@ -773,15 +955,128 @@ function getTemporaryWritePaths(): string[] {
773
955
  return cachedTemporaryWritePaths;
774
956
  }
775
957
 
958
+ function deduplicateStrings(values: string[]): string[] {
959
+ return Array.from(new Set(values));
960
+ }
961
+
962
+ function deduplicateOptionalStrings(values: string[] | undefined): string[] | undefined {
963
+ return values ? deduplicateStrings(values) : undefined;
964
+ }
965
+
966
+ function containsControlCharacter(value: string): boolean {
967
+ for (let index = 0; index < value.length; index += 1) {
968
+ const code = value.charCodeAt(index);
969
+ if (code <= 0x1f || (code >= 0x7f && code <= 0x9f) || code === 0x2028 || code === 0x2029) {
970
+ return true;
971
+ }
972
+ }
973
+
974
+ return false;
975
+ }
976
+
977
+ function expandPathConfigEntry(value: string, field: string): string | null {
978
+ const source = value.trim();
979
+ if (!source) return null;
980
+
981
+ const missingEnvNames = new Set<string>();
982
+ const expanded = source.replace(
983
+ ENV_PATH_REFERENCE_PATTERN,
984
+ (_match, bracedName: string | undefined, bareName: string | undefined) => {
985
+ const envName = bracedName ?? bareName;
986
+ const envValue = envName ? (process.env[envName]?.trim() ?? "") : "";
987
+ if (!envValue) {
988
+ if (envName) missingEnvNames.add(envName);
989
+ return "";
990
+ }
991
+ return envValue;
992
+ },
993
+ );
994
+
995
+ if (missingEnvNames.size > 0) {
996
+ const names = Array.from(missingEnvNames).join(", ");
997
+ const label = missingEnvNames.size === 1 ? "environment variable" : "environment variables";
998
+ const verb = missingEnvNames.size === 1 ? "is" : "are";
999
+ console.error(
1000
+ `Warning: Ignoring ${field} entry because ${label} ${names} ${verb} unset or empty.`,
1001
+ );
1002
+ return null;
1003
+ }
1004
+
1005
+ if (containsControlCharacter(expanded)) {
1006
+ console.error(
1007
+ `Warning: Ignoring ${field} entry because the expanded path contains control characters.`,
1008
+ );
1009
+ return null;
1010
+ }
1011
+
1012
+ return expanded;
1013
+ }
1014
+
1015
+ function expandPathConfigList(values: string[] | undefined, field: string): string[] | undefined {
1016
+ if (!values) return undefined;
1017
+
1018
+ return values
1019
+ .map((value) => expandPathConfigEntry(value, field))
1020
+ .filter((value): value is string => value !== null);
1021
+ }
1022
+
1023
+ function expandMitmProxyPathConfig(
1024
+ mitmProxy: SandboxRuntimeConfig["network"]["mitmProxy"],
1025
+ ): SandboxRuntimeConfig["network"]["mitmProxy"] {
1026
+ if (!mitmProxy) return undefined;
1027
+ if (!isPlainObject(mitmProxy) || typeof mitmProxy.socketPath !== "string") return mitmProxy;
1028
+
1029
+ const socketPath = expandPathConfigEntry(mitmProxy.socketPath, "network.mitmProxy.socketPath");
1030
+ if (!socketPath) {
1031
+ console.error("Warning: Disabling network.mitmProxy because its socketPath did not expand.");
1032
+ return undefined;
1033
+ }
1034
+
1035
+ return { ...mitmProxy, socketPath } as SandboxRuntimeConfig["network"]["mitmProxy"];
1036
+ }
1037
+
1038
+ function expandNetworkPathConfig(
1039
+ network: SandboxRuntimeConfig["network"],
1040
+ ): SandboxRuntimeConfig["network"] {
1041
+ return {
1042
+ ...network,
1043
+ allowUnixSockets: expandPathConfigList(network.allowUnixSockets, "network.allowUnixSockets"),
1044
+ mitmProxy: expandMitmProxyPathConfig(network.mitmProxy),
1045
+ };
1046
+ }
1047
+
1048
+ function expandFilesystemPathConfig(
1049
+ filesystem: SandboxRuntimeConfig["filesystem"],
1050
+ ): SandboxRuntimeConfig["filesystem"] {
1051
+ return {
1052
+ ...filesystem,
1053
+ denyRead: expandPathConfigList(filesystem.denyRead, "filesystem.denyRead") ?? [],
1054
+ allowRead: expandPathConfigList(filesystem.allowRead, "filesystem.allowRead"),
1055
+ allowWrite: expandPathConfigList(filesystem.allowWrite, "filesystem.allowWrite") ?? [],
1056
+ denyWrite: expandPathConfigList(filesystem.denyWrite, "filesystem.denyWrite") ?? [],
1057
+ };
1058
+ }
1059
+
776
1060
  function toRuntimeConfig(config: SandboxConfig): SandboxRuntimeConfig {
777
1061
  const { allowGitCommonDir: _allowGitCommonDir, allowTempDirs, ...filesystem } = config.filesystem;
1062
+ const expandedNetwork = expandNetworkPathConfig(config.network);
1063
+ const expandedFilesystem = expandFilesystemPathConfig(filesystem);
778
1064
  const allowWrite = allowTempDirs
779
- ? Array.from(new Set([...filesystem.allowWrite, ...getTemporaryWritePaths()]))
780
- : filesystem.allowWrite;
1065
+ ? [...expandedFilesystem.allowWrite, ...getTemporaryWritePaths()]
1066
+ : expandedFilesystem.allowWrite;
781
1067
 
782
1068
  return {
783
- network: config.network,
784
- filesystem: { ...filesystem, allowWrite },
1069
+ network: {
1070
+ ...expandedNetwork,
1071
+ allowUnixSockets: deduplicateOptionalStrings(expandedNetwork.allowUnixSockets),
1072
+ },
1073
+ filesystem: {
1074
+ ...expandedFilesystem,
1075
+ denyRead: deduplicateStrings(expandedFilesystem.denyRead),
1076
+ allowRead: deduplicateOptionalStrings(expandedFilesystem.allowRead),
1077
+ allowWrite: deduplicateStrings(allowWrite),
1078
+ denyWrite: deduplicateStrings(expandedFilesystem.denyWrite),
1079
+ },
785
1080
  ignoreViolations: config.ignoreViolations,
786
1081
  enableWeakerNestedSandbox: config.enableWeakerNestedSandbox,
787
1082
  enableWeakerNetworkIsolation: config.enableWeakerNetworkIsolation,
@@ -818,6 +1113,19 @@ function inferSandboxRuleMatch(path: string, rules: string[], cwd?: string): str
818
1113
  return null;
819
1114
  }
820
1115
 
1116
+ function matchesSandboxRuleExactly(path: string, rule: string, cwd?: string): boolean {
1117
+ if (containsGlobChars(rule)) return false;
1118
+ return normalizeSandboxPath(path) === normalizeSandboxPath(rule, cwd);
1119
+ }
1120
+
1121
+ function inferExactSandboxRuleMatch(path: string, rules: string[], cwd?: string): string | null {
1122
+ for (const rule of rules) {
1123
+ if (matchesSandboxRuleExactly(path, rule, cwd)) return rule;
1124
+ }
1125
+
1126
+ return null;
1127
+ }
1128
+
821
1129
  function isSandboxWritablePath(
822
1130
  runtimeConfig: SandboxRuntimeConfig,
823
1131
  path: string,
@@ -827,6 +1135,42 @@ function isSandboxWritablePath(
827
1135
  return inferSandboxRuleMatch(path, runtimeConfig.filesystem.denyWrite, cwd) === null;
828
1136
  }
829
1137
 
1138
+ function isRuntimeProtectedWriteViolation(
1139
+ runtimeConfig: SandboxRuntimeConfig | null,
1140
+ violation: FilesystemViolation,
1141
+ cwd?: string,
1142
+ ): boolean {
1143
+ if (
1144
+ !runtimeConfig ||
1145
+ runtimeConfig.filesystem.disabled ||
1146
+ !violation.path ||
1147
+ violation.kind !== "write"
1148
+ ) {
1149
+ return false;
1150
+ }
1151
+
1152
+ return isSandboxWritablePath(runtimeConfig, violation.path, cwd);
1153
+ }
1154
+
1155
+ function getRuntimeProtectedWriteViolations(
1156
+ runtimeConfig: SandboxRuntimeConfig | null,
1157
+ violations: FilesystemViolation[],
1158
+ cwd?: string,
1159
+ ): FilesystemViolation[] {
1160
+ const violationsByPath = new Map<string, FilesystemViolation>();
1161
+
1162
+ for (const violation of violations) {
1163
+ if (!isRuntimeProtectedWriteViolation(runtimeConfig, violation, cwd) || !violation.path) {
1164
+ continue;
1165
+ }
1166
+ if (!violationsByPath.has(violation.path)) {
1167
+ violationsByPath.set(violation.path, violation);
1168
+ }
1169
+ }
1170
+
1171
+ return Array.from(violationsByPath.values());
1172
+ }
1173
+
830
1174
  function resolveGitFilesystemPaths(cwd: string): GitFilesystemPaths | null {
831
1175
  if (GIT_FILESYSTEM_PATHS_CACHE.has(cwd)) {
832
1176
  return GIT_FILESYSTEM_PATHS_CACHE.get(cwd) ?? null;
@@ -910,8 +1254,8 @@ function extractAppendedSandboxAnnotation(
910
1254
  : violationLines;
911
1255
  if (newViolationLines.length === 0) return "";
912
1256
 
913
- // Filesystem and network sandbox violations are summarized elsewhere via compact
914
- // extension messages, so suppress the verbose synthetic annotation block.
1257
+ // Sandbox violations are summarized elsewhere via compact extension messages,
1258
+ // so suppress the verbose synthetic annotation block.
915
1259
  return "";
916
1260
  }
917
1261
 
@@ -957,7 +1301,60 @@ function extractPathLikeValue(text: string): string | undefined {
957
1301
 
958
1302
  function extractViolationProcessName(line: string): string | undefined {
959
1303
  const match = line.match(/^([^\s(]+)\(/);
960
- return match?.[1]?.trim() || undefined;
1304
+ const processName = match?.[1]?.trim();
1305
+ if (!processName) return undefined;
1306
+ return processName.split("/").pop() || processName;
1307
+ }
1308
+
1309
+ function detectMachLookupViolationFromLine(line: string): MachLookupViolation | null {
1310
+ const match = line.match(/\bdeny\(\d+\)\s+mach-lookup\s+([^\s()"'*]+)/i);
1311
+ const service = match?.[1];
1312
+ return service ? { service } : null;
1313
+ }
1314
+
1315
+ function detectMachLookupViolations(lines: string[]): MachLookupViolation[] {
1316
+ const violationsByService = new Map<string, MachLookupViolation>();
1317
+
1318
+ for (let index = lines.length - 1; index >= 0; index -= 1) {
1319
+ const violation = detectMachLookupViolationFromLine(lines[index]);
1320
+ if (violation && !violationsByService.has(violation.service)) {
1321
+ violationsByService.set(violation.service, violation);
1322
+ }
1323
+ }
1324
+
1325
+ return Array.from(violationsByService.values());
1326
+ }
1327
+
1328
+ function isValidMachLookupRule(rule: string): boolean {
1329
+ const trimmed = rule.trim();
1330
+ if (!trimmed || /\s/.test(trimmed)) return false;
1331
+
1332
+ const prefix = trimmed.endsWith("*") ? trimmed.slice(0, -1) : trimmed;
1333
+ return !prefix.includes("*");
1334
+ }
1335
+
1336
+ function matchesMachLookupRule(service: string, rule: string): boolean {
1337
+ if (rule === "*") return true;
1338
+ if (rule.endsWith("*")) return service.startsWith(rule.slice(0, -1));
1339
+ return service === rule;
1340
+ }
1341
+
1342
+ function mutateMachLookupAllowList(
1343
+ runtimeConfig: SandboxRuntimeConfig,
1344
+ op: ListOp,
1345
+ service: string,
1346
+ ): boolean {
1347
+ runtimeConfig.network.allowMachLookup ??= [];
1348
+ return mutateStringList(runtimeConfig.network.allowMachLookup, op, service);
1349
+ }
1350
+
1351
+ function isMachLookupAlreadyAllowed(
1352
+ runtimeConfig: SandboxRuntimeConfig | null,
1353
+ service: string,
1354
+ ): boolean {
1355
+ return (runtimeConfig?.network.allowMachLookup ?? []).some((rule) =>
1356
+ matchesMachLookupRule(service, rule),
1357
+ );
961
1358
  }
962
1359
 
963
1360
  function detectFilesystemViolationFromLine(line: string): FilesystemViolation | null {
@@ -966,8 +1363,12 @@ function detectFilesystemViolationFromLine(line: string): FilesystemViolation |
966
1363
  const path = extractPathLikeValue(line);
967
1364
  const processName = extractViolationProcessName(line);
968
1365
 
1366
+ if (lower.includes("file-write-unlink")) {
1367
+ return { kind: "write", path, processName, writeAccess: "unlink" };
1368
+ }
1369
+
969
1370
  if (lower.includes("file-write")) {
970
- return { kind: "write", path, processName };
1371
+ return { kind: "write", path, processName, writeAccess: "unknown" };
971
1372
  }
972
1373
 
973
1374
  if (lower.includes("file-read-metadata")) {
@@ -989,6 +1390,7 @@ function detectFilesystemViolations(
989
1390
  output: string,
990
1391
  fallbackOutput: string = output,
991
1392
  skipViolationLines = 0,
1393
+ allowOutputFallback = true,
992
1394
  ): FilesystemViolation[] {
993
1395
  const violations: FilesystemViolation[] = [];
994
1396
  const allViolationLines = extractSandboxViolationLines(output);
@@ -1002,7 +1404,7 @@ function detectFilesystemViolations(
1002
1404
  if (violation) violations.push(violation);
1003
1405
  }
1004
1406
 
1005
- if (violations.length > 0) return violations;
1407
+ if (violations.length > 0 || !allowOutputFallback) return violations;
1006
1408
 
1007
1409
  const hasEperm = /\bEPERM\b/i.test(fallbackOutput);
1008
1410
  const hasOperationNotPermitted = /(?:^|\n)[^\n]*Operation not permitted(?:$|\n)/i.test(
@@ -1016,19 +1418,30 @@ function detectFilesystemViolations(
1016
1418
  return violations;
1017
1419
  }
1018
1420
 
1019
- function isMetadataTraversalViolation(
1421
+ function isTraversalViolation(
1020
1422
  runtimeConfig: SandboxRuntimeConfig | null,
1021
1423
  violation: FilesystemViolation,
1022
1424
  cwd?: string,
1023
1425
  ): boolean {
1024
- if (!runtimeConfig || violation.kind !== "read" || violation.readAccess !== "metadata")
1025
- return false;
1026
- if (!violation.path || !METADATA_TRAVERSAL_PROCESSES.has(violation.processName ?? ""))
1027
- return false;
1028
- return inferSandboxRuleMatch(violation.path, runtimeConfig.filesystem.denyRead, cwd) !== null;
1426
+ if (!runtimeConfig || !violation.path) return false;
1427
+ if (!READ_TRAVERSAL_PROCESSES.has(violation.processName ?? "")) return false;
1428
+
1429
+ if (violation.kind === "read") {
1430
+ return inferSandboxRuleMatch(violation.path, runtimeConfig.filesystem.denyRead, cwd) !== null;
1431
+ }
1432
+
1433
+ if (violation.kind !== "write" || violation.writeAccess !== "unlink") return false;
1434
+
1435
+ // Seatbelt can emit file-write-unlink checks for protected directory roots
1436
+ // while traversal commands enumerate them. Only exact protected roots are
1437
+ // treated as skipped traversal so writes under protected trees still prompt.
1438
+ return (
1439
+ inferExactSandboxRuleMatch(violation.path, runtimeConfig.filesystem.denyRead, cwd) !== null ||
1440
+ inferExactSandboxRuleMatch(violation.path, runtimeConfig.filesystem.denyWrite, cwd) !== null
1441
+ );
1029
1442
  }
1030
1443
 
1031
- function getMetadataTraversalPaths(options: {
1444
+ function getTraversalPaths(options: {
1032
1445
  runtimeConfig: SandboxRuntimeConfig | null;
1033
1446
  output: string;
1034
1447
  cwd?: string;
@@ -1047,7 +1460,7 @@ function getMetadataTraversalPaths(options: {
1047
1460
  const skippedPaths: string[] = [];
1048
1461
  for (const line of violationLines) {
1049
1462
  const violation = detectFilesystemViolationFromLine(line);
1050
- if (!violation || !isMetadataTraversalViolation(runtimeConfig, violation, cwd)) {
1463
+ if (!violation || !isTraversalViolation(runtimeConfig, violation, cwd)) {
1051
1464
  return null;
1052
1465
  }
1053
1466
  if (violation.path && !skippedPaths.includes(violation.path)) {
@@ -1058,7 +1471,7 @@ function getMetadataTraversalPaths(options: {
1058
1471
  return skippedPaths.length > 0 ? skippedPaths : null;
1059
1472
  }
1060
1473
 
1061
- function formatMetadataTraversalNotice(paths: string[]): string {
1474
+ function formatTraversalNotice(paths: string[]): string {
1062
1475
  if (paths.length === 0) return "";
1063
1476
 
1064
1477
  const visiblePaths = paths.slice(0, 3).join(", ");
@@ -1067,6 +1480,22 @@ function formatMetadataTraversalNotice(paths: string[]): string {
1067
1480
  return `[sandbox] Continued after skipping protected ${label}: ${visiblePaths}${suffix}`;
1068
1481
  }
1069
1482
 
1483
+ function formatRuntimeProtectedWriteNotice(
1484
+ violations: FilesystemViolation[],
1485
+ continued: boolean,
1486
+ ): string {
1487
+ const paths = violations
1488
+ .map((violation) => violation.path)
1489
+ .filter((path): path is string => path !== undefined);
1490
+ if (paths.length === 0) return "";
1491
+
1492
+ const visiblePaths = paths.slice(0, 3).join(", ");
1493
+ const suffix = paths.length > 3 ? ", ..." : "";
1494
+ const label = paths.length === 1 ? "write" : "writes";
1495
+ const prefix = continued ? "Continued after blocking" : "Blocked";
1496
+ return `[sandbox] ${prefix} runtime-protected ${label} that sandbox config cannot override: ${visiblePaths}${suffix}`;
1497
+ }
1498
+
1070
1499
  function escapeSlashCommandArg(value: string): string {
1071
1500
  if (/^[a-zA-Z0-9_./:@%+\-~]+$/.test(value)) return value;
1072
1501
  return JSON.stringify(value);
@@ -1187,56 +1616,61 @@ function formatFilesystemViolationSummary(violation: FilesystemViolation): strin
1187
1616
  return "[sandbox] Blocked filesystem access (EPERM).";
1188
1617
  }
1189
1618
 
1190
- const FILESYSTEM_ALLOW_RETRY_OPTION = "Allow and retry now";
1191
- const FILESYSTEM_ALLOW_ADAPT_OPTION = "Allow but adapt for side-effects";
1192
- const FILESYSTEM_DENY_OPTION = "Deny";
1619
+ const VIOLATION_ALLOW_RETRY_OPTION = "Allow and retry now";
1620
+ const VIOLATION_ALLOW_ADAPT_OPTION = "Allow but adapt for side-effects";
1621
+ const VIOLATION_DENY_OPTION = "Deny";
1193
1622
 
1194
- function getFilesystemPromptOptions(
1195
- _violation: FilesystemViolation,
1196
- autoRetryAvailable: boolean,
1197
- ): string[] {
1623
+ function getViolationPromptOptions(autoRetryAvailable: boolean): string[] {
1198
1624
  if (!autoRetryAvailable) {
1199
- return [FILESYSTEM_ALLOW_ADAPT_OPTION, FILESYSTEM_DENY_OPTION];
1625
+ return [VIOLATION_ALLOW_ADAPT_OPTION, VIOLATION_DENY_OPTION];
1200
1626
  }
1201
1627
 
1202
- return [FILESYSTEM_ALLOW_RETRY_OPTION, FILESYSTEM_ALLOW_ADAPT_OPTION, FILESYSTEM_DENY_OPTION];
1628
+ return [VIOLATION_ALLOW_RETRY_OPTION, VIOLATION_ALLOW_ADAPT_OPTION, VIOLATION_DENY_OPTION];
1203
1629
  }
1204
1630
 
1205
- function parseFilesystemPromptSelection(
1631
+ function parseViolationPromptSelection(
1206
1632
  selection: string | undefined,
1207
1633
  autoRetryAvailable: boolean,
1208
- ): FilesystemViolationResolutionKind {
1209
- if (selection === FILESYSTEM_ALLOW_ADAPT_OPTION) return "allow-adapt";
1210
- if (selection === FILESYSTEM_ALLOW_RETRY_OPTION && autoRetryAvailable) return "allow-retry";
1634
+ ): ViolationResolutionKind {
1635
+ if (selection === VIOLATION_ALLOW_ADAPT_OPTION) return "allow-adapt";
1636
+ if (selection === VIOLATION_ALLOW_RETRY_OPTION && autoRetryAvailable) return "allow-retry";
1211
1637
  return "deny";
1212
1638
  }
1213
1639
 
1214
- function formatFilesystemAllowRetryMessage(_target: string): string {
1215
- return "\nSandbox blocked access.\n\nGranting access and retrying the command per user request...\n\n";
1640
+ function formatViolationAllowRetryMessage(blockedTarget: string): string {
1641
+ return `\n${blockedTarget}\n\nGranting access and retrying the command per user request...\n\n`;
1216
1642
  }
1217
1643
 
1218
- function formatFilesystemAllowAdaptMessage(_target: string): string {
1219
- return "\nSandbox blocked access.\n\nAccess granted for this session. Retry the command manually if appropriate.";
1644
+ function formatViolationAllowAdaptMessage(blockedTarget: string): string {
1645
+ return `\n${blockedTarget}\n\nAccess granted for this session. Retry the command manually if appropriate.`;
1220
1646
  }
1221
1647
 
1222
- function formatFilesystemDeniedMessage(_target: string): string {
1223
- return "\nSandbox blocked access.\n\nAccess remains denied for this session.";
1648
+ function formatViolationDeniedMessage(blockedTarget: string): string {
1649
+ return `\n${blockedTarget}\n\nAccess remains denied for this session.`;
1224
1650
  }
1225
1651
 
1226
- function formatFilesystemAlreadyAllowedMessage(_target: string): string {
1227
- return "\nSandbox blocked access again after permission had already been granted. The remaining failure may be unrelated to sandbox policy.";
1652
+ function formatViolationAlreadyAllowedMessage(blockedTarget: string): string {
1653
+ return `\n${blockedTarget}\n\nAccess had already been granted for this session. The remaining failure may be unrelated to sandbox policy.`;
1228
1654
  }
1229
1655
 
1230
- function formatFilesystemRetrySucceededMessage(_target: string): string {
1231
- return "";
1656
+ function formatViolationRetrySucceededMessage(blockedTarget: string): string {
1657
+ return `\n${blockedTarget}\n\nAccess granted for this session and the command was retried successfully.`;
1232
1658
  }
1233
1659
 
1234
- function formatFilesystemRetryFailedMessage(_target: string): string {
1235
- return "\nAccess granted and command retried per user request, but the command still exited non-zero. The sandbox block was resolved; the remaining failure may be unrelated.";
1660
+ function formatViolationRetryFailedMessage(blockedTarget: string): string {
1661
+ return `\n${blockedTarget}\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.`;
1236
1662
  }
1237
1663
 
1238
- function formatFilesystemRetrySkippedMessage(_target: string): string {
1239
- return "\nAccess granted for this session, but automatic retry was skipped because the timeout was exhausted. Retry the command manually if needed.";
1664
+ function formatViolationRetrySkippedMessage(blockedTarget: string): string {
1665
+ return `\n${blockedTarget}\n\nAccess granted for this session, but automatic retry was skipped because the timeout was exhausted. Retry the command manually if needed.`;
1666
+ }
1667
+
1668
+ function formatFilesystemBlockedTarget(target: string): string {
1669
+ return `Sandbox blocked filesystem ${target}.`;
1670
+ }
1671
+
1672
+ function formatMachLookupBlockedTarget(service: string): string {
1673
+ return `Sandbox blocked access to macOS service ${service}.`;
1240
1674
  }
1241
1675
 
1242
1676
  function appendOutputPostamble(postamble: string, addition: string, output: string): string {
@@ -1262,7 +1696,7 @@ async function handleFilesystemViolation(options: {
1262
1696
  rawOutput: string;
1263
1697
  command: string;
1264
1698
  cwd?: string;
1265
- pendingPrompts?: Map<string, Promise<FilesystemViolationResolution | null>>;
1699
+ pendingPrompts?: Map<string, Promise<ViolationResolution | null>>;
1266
1700
  applyRuntimeConfigForSession?: (
1267
1701
  ctx: ExtensionContext,
1268
1702
  runtimeConfig: SandboxRuntimeConfig,
@@ -1270,7 +1704,9 @@ async function handleFilesystemViolation(options: {
1270
1704
  existingViolationCount?: number;
1271
1705
  recordEvent?: (event: SandboxEvent) => void;
1272
1706
  autoRetryAvailable?: boolean;
1273
- }): Promise<FilesystemViolationResolution | null> {
1707
+ runtimeProtectedWriteViolations?: FilesystemViolation[];
1708
+ allowOutputFallback?: boolean;
1709
+ }): Promise<ViolationResolution | null> {
1274
1710
  const {
1275
1711
  pi,
1276
1712
  ctx,
@@ -1285,19 +1721,35 @@ async function handleFilesystemViolation(options: {
1285
1721
  existingViolationCount,
1286
1722
  recordEvent,
1287
1723
  autoRetryAvailable = true,
1724
+ runtimeProtectedWriteViolations = [],
1725
+ allowOutputFallback = true,
1288
1726
  } = options;
1289
- const violations = detectFilesystemViolations(output, rawOutput, existingViolationCount ?? 0);
1290
- if (violations.length === 0) return null;
1727
+ const violations = detectFilesystemViolations(
1728
+ output,
1729
+ rawOutput,
1730
+ existingViolationCount ?? 0,
1731
+ allowOutputFallback,
1732
+ );
1733
+ const runtimeProtectedWritePaths = new Set(
1734
+ runtimeProtectedWriteViolations.map((violation) => violation.path).filter(Boolean),
1735
+ );
1736
+ const actionableViolations = violations.filter((violation) => {
1737
+ const isRuntimeProtectedWrite =
1738
+ violation.kind !== "read" && runtimeProtectedWritePaths.has(violation.path);
1739
+ return !isRuntimeProtectedWrite && !isTraversalViolation(runtimeConfig, violation, cwd);
1740
+ });
1741
+ if (actionableViolations.length === 0) return null;
1291
1742
 
1292
1743
  const violation =
1293
- violations.find((candidate) => {
1744
+ actionableViolations.find((candidate) => {
1294
1745
  const candidateAction = buildFilesystemAllowAction(runtimeConfig, candidate, cwd);
1295
1746
  if (!candidateAction) return false;
1296
1747
  return !isFilesystemAllowActionAlreadyApplied(runtimeConfig, candidateAction);
1297
- }) ?? violations[0];
1748
+ }) ?? actionableViolations[0];
1298
1749
 
1299
1750
  const summary = formatFilesystemViolationSummary(violation);
1300
1751
  const target = describeFilesystemViolationTarget(violation);
1752
+ const blockedTarget = formatFilesystemBlockedTarget(target);
1301
1753
  const allowAction = buildFilesystemAllowAction(runtimeConfig, violation, cwd);
1302
1754
  const allowCommand = allowAction ? buildFilesystemAllowCommand(allowAction) : null;
1303
1755
  const alreadyApproved = allowAction
@@ -1336,25 +1788,25 @@ async function handleFilesystemViolation(options: {
1336
1788
 
1337
1789
  if (alreadyApproved) {
1338
1790
  recordFilesystemEvent("blocked");
1339
- return { kind: "allow-adapt", message: formatFilesystemAlreadyAllowedMessage(target) };
1791
+ return { kind: "allow-adapt", message: formatViolationAlreadyAllowedMessage(blockedTarget) };
1340
1792
  }
1341
1793
 
1342
1794
  const promptKey = `${allowCommand}:${autoRetryAvailable ? "retry" : "adapt"}`;
1343
1795
  const existingPrompt = pendingPrompts?.get(promptKey);
1344
1796
  if (existingPrompt) return existingPrompt;
1345
1797
 
1346
- const promptTask: Promise<FilesystemViolationResolution | null> = (async () => {
1798
+ const promptTask: Promise<ViolationResolution | null> = (async () => {
1347
1799
  try {
1348
1800
  const selection = await withPromptSignal(pi, () =>
1349
1801
  ctx.ui.select(
1350
1802
  `Sandbox blocked filesystem ${target}`,
1351
- getFilesystemPromptOptions(violation, autoRetryAvailable),
1803
+ getViolationPromptOptions(autoRetryAvailable),
1352
1804
  ),
1353
1805
  );
1354
- const decision = parseFilesystemPromptSelection(selection, autoRetryAvailable);
1806
+ const decision = parseViolationPromptSelection(selection, autoRetryAvailable);
1355
1807
  if (decision === "deny") {
1356
1808
  recordFilesystemEvent("blocked");
1357
- return { kind: "deny", message: formatFilesystemDeniedMessage(target) };
1809
+ return { kind: "deny", message: formatViolationDeniedMessage(blockedTarget) };
1358
1810
  }
1359
1811
 
1360
1812
  const nextConfig = cloneRuntimeConfig(runtimeConfig);
@@ -1368,18 +1820,163 @@ async function handleFilesystemViolation(options: {
1368
1820
  if (decision === "allow-retry") {
1369
1821
  return {
1370
1822
  kind: "allow-retry",
1371
- message: formatFilesystemAllowRetryMessage(target),
1372
- retrySuccessMessage: formatFilesystemRetrySucceededMessage(target),
1373
- retryFailureMessage: formatFilesystemRetryFailedMessage(target),
1374
- retrySkippedMessage: formatFilesystemRetrySkippedMessage(target),
1823
+ message: formatViolationAllowRetryMessage(blockedTarget),
1824
+ retrySuccessMessage: formatViolationRetrySucceededMessage(blockedTarget),
1825
+ retryFailureMessage: formatViolationRetryFailedMessage(blockedTarget),
1826
+ retrySkippedMessage: formatViolationRetrySkippedMessage(blockedTarget),
1827
+ };
1828
+ }
1829
+
1830
+ return {
1831
+ kind: "allow-adapt",
1832
+ message: changed
1833
+ ? formatViolationAllowAdaptMessage(blockedTarget)
1834
+ : formatViolationAlreadyAllowedMessage(blockedTarget),
1835
+ };
1836
+ } catch {
1837
+ return null;
1838
+ }
1839
+ })();
1840
+
1841
+ if (!pendingPrompts) return promptTask;
1842
+
1843
+ pendingPrompts.set(promptKey, promptTask);
1844
+ try {
1845
+ return await promptTask;
1846
+ } finally {
1847
+ pendingPrompts.delete(promptKey);
1848
+ }
1849
+ }
1850
+
1851
+ function buildMachLookupAllowCommand(service: string): string {
1852
+ return `/sandbox mach-lookup add ${escapeSlashCommandArg(service)}`;
1853
+ }
1854
+
1855
+ function formatMachLookupViolationSummary(service: string): string {
1856
+ return `[sandbox] Blocked macOS service lookup: ${service}`;
1857
+ }
1858
+
1859
+ function describeMachLookupEventSummary(
1860
+ reason: SandboxEventReason,
1861
+ outcome: SandboxEventOutcome,
1862
+ ): string {
1863
+ if (outcome === "allowed") return "user allowed macOS service lookup for this session";
1864
+ if (reason === "already-approved-still-failed") {
1865
+ return "macOS service lookup was previously allowed for this session but is still failing";
1866
+ }
1867
+ return "macOS service lookup is not in the allowed service list";
1868
+ }
1869
+
1870
+ async function handleMachLookupViolation(options: {
1871
+ pi: ExtensionAPI;
1872
+ ctx: ExtensionContext | null;
1873
+ promptMode: PromptMode;
1874
+ runtimeConfig: SandboxRuntimeConfig;
1875
+ violations: MachLookupViolation[];
1876
+ command: string;
1877
+ cwd?: string;
1878
+ pendingPrompts?: Map<string, Promise<ViolationResolution | null>>;
1879
+ applyRuntimeConfigForSession?: (
1880
+ ctx: ExtensionContext,
1881
+ runtimeConfig: SandboxRuntimeConfig,
1882
+ ) => void;
1883
+ recordEvent?: (event: SandboxEvent) => void;
1884
+ autoRetryAvailable?: boolean;
1885
+ }): Promise<ViolationResolution | null> {
1886
+ const {
1887
+ pi,
1888
+ ctx,
1889
+ promptMode,
1890
+ runtimeConfig,
1891
+ violations,
1892
+ command,
1893
+ cwd,
1894
+ pendingPrompts,
1895
+ applyRuntimeConfigForSession,
1896
+ recordEvent,
1897
+ autoRetryAvailable = true,
1898
+ } = options;
1899
+ if (violations.length === 0) return null;
1900
+
1901
+ const violation =
1902
+ violations.find((candidate) => !isMachLookupAlreadyAllowed(runtimeConfig, candidate.service)) ??
1903
+ violations[0];
1904
+ const { service } = violation;
1905
+ const blockedTarget = formatMachLookupBlockedTarget(service);
1906
+ const allowCommand = buildMachLookupAllowCommand(service);
1907
+ const alreadyApproved = isMachLookupAlreadyAllowed(runtimeConfig, service);
1908
+ const eventReason: SandboxEventReason = alreadyApproved
1909
+ ? "already-approved-still-failed"
1910
+ : "missing-mach-lookup";
1911
+
1912
+ const recordMachLookupEvent = (outcome: SandboxEventOutcome): void => {
1913
+ recordEvent?.({
1914
+ timestamp: Date.now(),
1915
+ kind: "mach-lookup",
1916
+ outcome,
1917
+ reason: eventReason,
1918
+ target: service,
1919
+ command,
1920
+ cwd,
1921
+ summary: describeMachLookupEventSummary(eventReason, outcome),
1922
+ suggestedCommand: outcome === "blocked" && !alreadyApproved ? allowCommand : undefined,
1923
+ });
1924
+ };
1925
+
1926
+ if (promptMode === "non-interactive" || !ctx?.hasUI) {
1927
+ recordMachLookupEvent("blocked");
1928
+ return {
1929
+ kind: "deny",
1930
+ message: `${formatMachLookupViolationSummary(service)}\n[sandbox] To temporarily allow for this session, run: ${allowCommand}`,
1931
+ };
1932
+ }
1933
+
1934
+ if (alreadyApproved) {
1935
+ recordMachLookupEvent("blocked");
1936
+ return { kind: "allow-adapt", message: formatViolationAlreadyAllowedMessage(blockedTarget) };
1937
+ }
1938
+
1939
+ const promptKey = `${allowCommand}:${autoRetryAvailable ? "retry" : "adapt"}`;
1940
+ const existingPrompt = pendingPrompts?.get(promptKey);
1941
+ if (existingPrompt) return existingPrompt;
1942
+
1943
+ const promptTask: Promise<ViolationResolution | null> = (async () => {
1944
+ try {
1945
+ const selection = await withPromptSignal(pi, () =>
1946
+ ctx.ui.select(
1947
+ `Sandbox blocked access to macOS service ${service}`,
1948
+ getViolationPromptOptions(autoRetryAvailable),
1949
+ ),
1950
+ );
1951
+ const decision = parseViolationPromptSelection(selection, autoRetryAvailable);
1952
+ if (decision === "deny") {
1953
+ recordMachLookupEvent("blocked");
1954
+ return { kind: "deny", message: formatViolationDeniedMessage(blockedTarget) };
1955
+ }
1956
+
1957
+ const nextConfig = cloneRuntimeConfig(runtimeConfig);
1958
+ const changed = mutateMachLookupAllowList(nextConfig, "add", service);
1959
+ if (changed) {
1960
+ applyRuntimeConfigForSession?.(ctx, nextConfig);
1961
+ }
1962
+
1963
+ recordMachLookupEvent("allowed");
1964
+
1965
+ if (decision === "allow-retry") {
1966
+ return {
1967
+ kind: "allow-retry",
1968
+ message: formatViolationAllowRetryMessage(blockedTarget),
1969
+ retrySuccessMessage: formatViolationRetrySucceededMessage(blockedTarget),
1970
+ retryFailureMessage: formatViolationRetryFailedMessage(blockedTarget),
1971
+ retrySkippedMessage: formatViolationRetrySkippedMessage(blockedTarget),
1375
1972
  };
1376
1973
  }
1377
1974
 
1378
1975
  return {
1379
1976
  kind: "allow-adapt",
1380
1977
  message: changed
1381
- ? formatFilesystemAllowAdaptMessage(target)
1382
- : formatFilesystemAlreadyAllowedMessage(target),
1978
+ ? formatViolationAllowAdaptMessage(blockedTarget)
1979
+ : formatViolationAlreadyAllowedMessage(blockedTarget),
1383
1980
  };
1384
1981
  } catch {
1385
1982
  return null;
@@ -1413,17 +2010,20 @@ interface BashAttemptResult {
1413
2010
  exitCode: number | null;
1414
2011
  combinedOutput: string;
1415
2012
  interruptedByFilesystemViolation: boolean;
2013
+ runtimeProtectedWriteViolations: FilesystemViolation[];
1416
2014
  }
1417
2015
 
1418
2016
  interface ProcessedSandboxAttempt {
1419
2017
  exitCode: number | null;
1420
2018
  postamble: string;
1421
- resolution: FilesystemViolationResolution | null;
2019
+ resolution: ViolationResolution | null;
2020
+ runtimeProtectedWriteViolations: FilesystemViolation[];
1422
2021
  }
1423
2022
 
1424
2023
  interface PreparedSandboxAttempt {
1425
2024
  attempt: BashAttemptResult;
1426
2025
  existingViolationCount: number;
2026
+ runtimeConfig: SandboxRuntimeConfig | null;
1427
2027
  }
1428
2028
 
1429
2029
  function killProcessGroup(
@@ -1489,7 +2089,8 @@ function createSandboxedBashOps(options: SandboxedBashOpsOptions): BashOperation
1489
2089
  applyRuntimeConfigForSession,
1490
2090
  recordEvent,
1491
2091
  } = options;
1492
- const pendingFilesystemPrompts = new Map<string, Promise<FilesystemViolationResolution | null>>();
2092
+ const pendingFilesystemPrompts = new Map<string, Promise<ViolationResolution | null>>();
2093
+ const pendingMachLookupPrompts = new Map<string, Promise<ViolationResolution | null>>();
1493
2094
 
1494
2095
  let executionQueue: Promise<void> = Promise.resolve();
1495
2096
 
@@ -1502,10 +2103,17 @@ function createSandboxedBashOps(options: SandboxedBashOpsOptions): BashOperation
1502
2103
  return run;
1503
2104
  }
1504
2105
 
2106
+ function withSandboxDefaultEnv(env?: NodeJS.ProcessEnv): NodeJS.ProcessEnv {
2107
+ const baseEnv = env ?? process.env;
2108
+ if (baseEnv.GIT_OPTIONAL_LOCKS !== undefined) return baseEnv;
2109
+ return { ...baseEnv, GIT_OPTIONAL_LOCKS: "0" };
2110
+ }
2111
+
1505
2112
  async function runSandboxAttempt(
1506
2113
  command: string,
1507
2114
  wrappedCommand: string,
1508
2115
  cwd: string,
2116
+ runtimeConfig: SandboxRuntimeConfig | null,
1509
2117
  onData: (data: Buffer) => void,
1510
2118
  existingViolationCount: number,
1511
2119
  signal?: AbortSignal,
@@ -1515,7 +2123,7 @@ function createSandboxedBashOps(options: SandboxedBashOpsOptions): BashOperation
1515
2123
  return new Promise((resolve, reject) => {
1516
2124
  const child = spawn("bash", ["-c", wrappedCommand], {
1517
2125
  cwd,
1518
- env,
2126
+ env: withSandboxDefaultEnv(env),
1519
2127
  detached: true,
1520
2128
  stdio: ["ignore", "pipe", "pipe"],
1521
2129
  });
@@ -1524,6 +2132,7 @@ function createSandboxedBashOps(options: SandboxedBashOpsOptions): BashOperation
1524
2132
  let timedOut = false;
1525
2133
  let interruptedByFilesystemViolation = false;
1526
2134
  let seenViolationCount = existingViolationCount;
2135
+ const runtimeProtectedWriteViolations = new Map<string, FilesystemViolation>();
1527
2136
  let timeoutHandle: NodeJS.Timeout | undefined;
1528
2137
  let timeoutEscalationHandle: NodeJS.Timeout | undefined;
1529
2138
  let filesystemStopEscalationHandle: NodeJS.Timeout | undefined;
@@ -1538,11 +2147,9 @@ function createSandboxedBashOps(options: SandboxedBashOpsOptions): BashOperation
1538
2147
  }, 500);
1539
2148
  };
1540
2149
 
1541
- // sandbox-runtime only provides live filesystem violation events on macOS.
1542
- // Upstream documents Linux violation monitoring as future work via
1543
- // automatic strace-based detection integrated with the violation store,
1544
- // but there is no Linux implementation yet:
1545
- // https://github.com/anthropic-experimental/sandbox-runtime#known-limitations-and-future-work
2150
+ // The Linux monitor filters attempts against configured write rules and does not
2151
+ // report mandatory runtime denies within already-allowed paths, so runtime-protected
2152
+ // continuation remains macOS-only.
1546
2153
  const unsubscribeViolations =
1547
2154
  process.platform !== "darwin"
1548
2155
  ? () => undefined
@@ -1554,14 +2161,23 @@ function createSandboxedBashOps(options: SandboxedBashOpsOptions): BashOperation
1554
2161
  const newViolations = violations.slice(seenViolationCount);
1555
2162
  seenViolationCount = violations.length;
1556
2163
 
1557
- const runtimeConfig = getRuntimeConfig();
1558
- const shouldStop = newViolations.some((violation) => {
2164
+ for (const violation of newViolations) {
1559
2165
  const filesystemViolation = detectFilesystemViolationFromLine(violation.line);
1560
- if (!filesystemViolation) return false;
1561
- return !isMetadataTraversalViolation(runtimeConfig, filesystemViolation, cwd);
1562
- });
1563
- if (shouldStop) {
1564
- stopForFilesystemViolation();
2166
+ if (filesystemViolation) {
2167
+ if (isTraversalViolation(runtimeConfig, filesystemViolation, cwd)) continue;
2168
+
2169
+ if (isRuntimeProtectedWriteViolation(runtimeConfig, filesystemViolation, cwd)) {
2170
+ if (filesystemViolation.path) {
2171
+ runtimeProtectedWriteViolations.set(
2172
+ filesystemViolation.path,
2173
+ filesystemViolation,
2174
+ );
2175
+ }
2176
+ continue;
2177
+ }
2178
+
2179
+ stopForFilesystemViolation();
2180
+ }
1565
2181
  }
1566
2182
  });
1567
2183
 
@@ -1624,11 +2240,22 @@ function createSandboxedBashOps(options: SandboxedBashOpsOptions): BashOperation
1624
2240
  exitCode: interruptedByFilesystemViolation && code === null ? 1 : code,
1625
2241
  combinedOutput: Buffer.concat(chunks).toString("utf-8"),
1626
2242
  interruptedByFilesystemViolation,
2243
+ runtimeProtectedWriteViolations: Array.from(runtimeProtectedWriteViolations.values()),
1627
2244
  });
1628
2245
  });
1629
2246
  });
1630
2247
  }
1631
2248
 
2249
+ function validateTimeout(timeout: number | undefined): void {
2250
+ if (timeout === undefined) return;
2251
+ if (!Number.isFinite(timeout) || timeout <= 0) {
2252
+ throw new Error("Invalid timeout: must be a finite number of seconds");
2253
+ }
2254
+ if (timeout * 1000 > MAX_TIMEOUT_MS) {
2255
+ throw new Error(`Invalid timeout: maximum is ${MAX_TIMEOUT_SECONDS} seconds`);
2256
+ }
2257
+ }
2258
+
1632
2259
  function getRemainingTimeout(timeout: number | undefined, startedAt: number): number | undefined {
1633
2260
  if (timeout === undefined) return undefined;
1634
2261
 
@@ -1661,7 +2288,13 @@ function createSandboxedBashOps(options: SandboxedBashOpsOptions): BashOperation
1661
2288
  applyRuntimeConfigForSession,
1662
2289
  });
1663
2290
 
1664
- const wrappedCommand = await SandboxManager.wrapWithSandbox(command);
2291
+ const runtimeConfig = getRuntimeConfig();
2292
+ const attemptRuntimeConfig = runtimeConfig ? cloneRuntimeConfig(runtimeConfig) : null;
2293
+ const wrappedCommand = await SandboxManager.wrapWithSandbox(
2294
+ command,
2295
+ IS_MACOS ? MACOS_SANDBOX_SHELL : undefined,
2296
+ attemptRuntimeConfig ?? undefined,
2297
+ );
1665
2298
  const existingViolationCount =
1666
2299
  SandboxManager.getSandboxViolationStore().getViolationsForCommand(command).length;
1667
2300
 
@@ -1670,13 +2303,14 @@ function createSandboxedBashOps(options: SandboxedBashOpsOptions): BashOperation
1670
2303
  command,
1671
2304
  wrappedCommand,
1672
2305
  cwd,
2306
+ attemptRuntimeConfig,
1673
2307
  onData,
1674
2308
  existingViolationCount,
1675
2309
  signal,
1676
2310
  timeout,
1677
2311
  env,
1678
2312
  );
1679
- return { attempt, existingViolationCount };
2313
+ return { attempt, existingViolationCount, runtimeConfig: attemptRuntimeConfig };
1680
2314
  } catch (err) {
1681
2315
  safeCleanupAfterCommand();
1682
2316
  throw err;
@@ -1688,42 +2322,95 @@ function createSandboxedBashOps(options: SandboxedBashOpsOptions): BashOperation
1688
2322
  command: string;
1689
2323
  cwd: string;
1690
2324
  existingViolationCount: number;
2325
+ runtimeConfig: SandboxRuntimeConfig | null;
1691
2326
  autoRetryAvailable: boolean;
1692
2327
  }): Promise<ProcessedSandboxAttempt> {
1693
- const { attempt, command, cwd, existingViolationCount, autoRetryAvailable } = options;
1694
- const commandSucceeded = attempt.exitCode === 0 && !attempt.interruptedByFilesystemViolation;
1695
- if (commandSucceeded) {
1696
- return { exitCode: attempt.exitCode, postamble: "", resolution: null };
1697
- }
1698
-
2328
+ const { attempt, command, cwd, existingViolationCount, runtimeConfig, autoRetryAvailable } =
2329
+ options;
1699
2330
  const annotatedOutput = SandboxManager.annotateStderrWithSandboxFailures(
1700
2331
  command,
1701
2332
  attempt.combinedOutput,
1702
2333
  );
1703
- const runtimeConfig = getRuntimeConfig();
1704
- const metadataTraversalPaths = getMetadataTraversalPaths({
2334
+ // Capture violations delivered after the child closed but before post-processing.
2335
+ const storedViolationLines = SandboxManager.getSandboxViolationStore()
2336
+ .getViolationsForCommand(command)
2337
+ .slice(existingViolationCount)
2338
+ .map((violation) => violation.line);
2339
+ const storedFilesystemViolations = storedViolationLines
2340
+ .map((line) => detectFilesystemViolationFromLine(line))
2341
+ .filter((violation): violation is FilesystemViolation => violation !== null);
2342
+ const runtimeProtectedWriteViolations = getRuntimeProtectedWriteViolations(
1705
2343
  runtimeConfig,
1706
- output: annotatedOutput,
2344
+ [...attempt.runtimeProtectedWriteViolations, ...storedFilesystemViolations],
1707
2345
  cwd,
1708
- skipViolationLines: existingViolationCount,
1709
- });
1710
- const effectiveExitCode = metadataTraversalPaths ? 0 : attempt.exitCode;
2346
+ );
2347
+ const machLookupViolations = detectMachLookupViolations(storedViolationLines);
1711
2348
  let postamble = extractAppendedSandboxAnnotation(
1712
2349
  attempt.combinedOutput,
1713
2350
  annotatedOutput,
1714
2351
  existingViolationCount,
1715
2352
  );
1716
- let resolution: FilesystemViolationResolution | null = null;
1717
2353
 
1718
- if (metadataTraversalPaths) {
1719
- const notice = formatMetadataTraversalNotice(metadataTraversalPaths);
2354
+ if (runtimeProtectedWriteViolations.length > 0) {
2355
+ const notice = formatRuntimeProtectedWriteNotice(
2356
+ runtimeProtectedWriteViolations,
2357
+ !attempt.interruptedByFilesystemViolation,
2358
+ );
1720
2359
  postamble = appendOutputPostamble(postamble, notice, attempt.combinedOutput);
1721
- } else if (runtimeConfig) {
2360
+
2361
+ for (const violation of runtimeProtectedWriteViolations) {
2362
+ recordEvent?.({
2363
+ timestamp: Date.now(),
2364
+ kind: "filesystem",
2365
+ outcome: "blocked",
2366
+ reason: "runtime-protected-write",
2367
+ target: violation.path,
2368
+ command,
2369
+ cwd,
2370
+ summary: "filesystem write is protected by the sandbox runtime",
2371
+ });
2372
+ }
2373
+ }
2374
+
2375
+ const commandSucceeded = attempt.exitCode === 0 && !attempt.interruptedByFilesystemViolation;
2376
+ if (commandSucceeded) {
2377
+ return {
2378
+ exitCode: attempt.exitCode,
2379
+ postamble,
2380
+ resolution: null,
2381
+ runtimeProtectedWriteViolations,
2382
+ };
2383
+ }
2384
+
2385
+ const traversalPaths = getTraversalPaths({
2386
+ runtimeConfig,
2387
+ output: annotatedOutput,
2388
+ cwd,
2389
+ skipViolationLines: existingViolationCount,
2390
+ });
2391
+ const continuedTraversal = machLookupViolations.length === 0 ? traversalPaths : null;
2392
+ const effectiveExitCode = continuedTraversal ? 0 : attempt.exitCode;
2393
+ let resolution: ViolationResolution | null = null;
2394
+
2395
+ if (continuedTraversal) {
2396
+ const notice = formatTraversalNotice(continuedTraversal);
2397
+ postamble = appendOutputPostamble(postamble, notice, attempt.combinedOutput);
2398
+ } else {
2399
+ const currentRuntimeConfig = getRuntimeConfig();
2400
+ if (!currentRuntimeConfig) {
2401
+ return {
2402
+ exitCode: effectiveExitCode,
2403
+ postamble,
2404
+ resolution,
2405
+ runtimeProtectedWriteViolations,
2406
+ };
2407
+ }
2408
+
1722
2409
  resolution = await handleFilesystemViolation({
1723
2410
  pi,
1724
2411
  ctx: getContext(),
1725
2412
  promptMode: getPromptMode(),
1726
- runtimeConfig,
2413
+ runtimeConfig: currentRuntimeConfig,
1727
2414
  output: annotatedOutput,
1728
2415
  rawOutput: attempt.combinedOutput,
1729
2416
  command,
@@ -1733,18 +2420,42 @@ function createSandboxedBashOps(options: SandboxedBashOpsOptions): BashOperation
1733
2420
  existingViolationCount,
1734
2421
  recordEvent,
1735
2422
  autoRetryAvailable,
2423
+ runtimeProtectedWriteViolations,
2424
+ allowOutputFallback: machLookupViolations.length === 0,
1736
2425
  });
1737
2426
 
2427
+ if (!resolution) {
2428
+ resolution = await handleMachLookupViolation({
2429
+ pi,
2430
+ ctx: getContext(),
2431
+ promptMode: getPromptMode(),
2432
+ runtimeConfig: currentRuntimeConfig,
2433
+ violations: machLookupViolations,
2434
+ command,
2435
+ cwd,
2436
+ pendingPrompts: pendingMachLookupPrompts,
2437
+ applyRuntimeConfigForSession,
2438
+ recordEvent,
2439
+ autoRetryAvailable,
2440
+ });
2441
+ }
2442
+
1738
2443
  if (resolution) {
1739
2444
  postamble = appendOutputPostamble(postamble, resolution.message, attempt.combinedOutput);
1740
2445
  }
1741
2446
  }
1742
2447
 
1743
- return { exitCode: effectiveExitCode, postamble, resolution };
2448
+ return {
2449
+ exitCode: effectiveExitCode,
2450
+ postamble,
2451
+ resolution,
2452
+ runtimeProtectedWriteViolations,
2453
+ };
1744
2454
  }
1745
2455
 
1746
2456
  return {
1747
2457
  async exec(command, cwd, { onData, signal, timeout, env }) {
2458
+ validateTimeout(timeout);
1748
2459
  return runSerially(async () => {
1749
2460
  if (!existsSync(cwd)) {
1750
2461
  throw new Error(`Working directory does not exist: ${cwd}`);
@@ -1767,6 +2478,7 @@ function createSandboxedBashOps(options: SandboxedBashOpsOptions): BashOperation
1767
2478
  command,
1768
2479
  cwd,
1769
2480
  existingViolationCount: initialRun.existingViolationCount,
2481
+ runtimeConfig: initialRun.runtimeConfig,
1770
2482
  autoRetryAvailable: true,
1771
2483
  });
1772
2484
  } catch (postProcessError) {
@@ -1811,6 +2523,7 @@ function createSandboxedBashOps(options: SandboxedBashOpsOptions): BashOperation
1811
2523
  command,
1812
2524
  cwd,
1813
2525
  existingViolationCount: retryRun.existingViolationCount,
2526
+ runtimeConfig: retryRun.runtimeConfig,
1814
2527
  autoRetryAvailable: false,
1815
2528
  });
1816
2529
  } catch (postProcessError) {
@@ -1826,7 +2539,10 @@ function createSandboxedBashOps(options: SandboxedBashOpsOptions): BashOperation
1826
2539
  retryResolution.retrySuccessMessage,
1827
2540
  retryRun.attempt.combinedOutput,
1828
2541
  );
1829
- } else if (!processedRetry.resolution) {
2542
+ } else if (
2543
+ !processedRetry.resolution &&
2544
+ processedRetry.runtimeProtectedWriteViolations.length === 0
2545
+ ) {
1830
2546
  retryPostamble = appendOutputPostamble(
1831
2547
  retryPostamble,
1832
2548
  retryResolution.retryFailureMessage,
@@ -1849,7 +2565,7 @@ function getSandboxRunMode(state: SandboxState): SandboxRunMode {
1849
2565
  }
1850
2566
 
1851
2567
  function getStateRuntimeConfig(state: SandboxState): SandboxRuntimeConfig | null {
1852
- if (state.status === "active" || state.status === "suspended") return state.runtimeConfig;
2568
+ if (state.status === "active") return state.runtimeConfig;
1853
2569
  return null;
1854
2570
  }
1855
2571
 
@@ -1917,6 +2633,9 @@ function describeFilesystemEventSummary(
1917
2633
 
1918
2634
  if (reason === "explicit-deny-read") return "filesystem read matched a deny-read rule";
1919
2635
  if (reason === "explicit-deny-write") return "filesystem write matched a deny-write rule";
2636
+ if (reason === "runtime-protected-write") {
2637
+ return "filesystem write is protected by the sandbox runtime";
2638
+ }
1920
2639
  if (reason === "already-approved-still-failed") {
1921
2640
  return "filesystem access was previously allowed for this session but is still failing";
1922
2641
  }
@@ -1985,7 +2704,12 @@ function renderSandboxDoctorReport(options: {
1985
2704
  } else {
1986
2705
  lines.push("- Config paths:");
1987
2706
  for (const configPath of configPaths) {
1988
- const status = configPath.status === "loaded" ? "loaded" : "parse error";
2707
+ const status =
2708
+ configPath.status === "loaded"
2709
+ ? "loaded"
2710
+ : configPath.status === "skipped-untrusted"
2711
+ ? "skipped (project not trusted)"
2712
+ : "parse error";
1989
2713
  lines.push(` - ${configPath.label}: ${configPath.path} (${status})`);
1990
2714
  }
1991
2715
  }
@@ -2017,6 +2741,10 @@ function getSandboxConfigParseErrors(paths: SandboxConfigPath[]): SandboxConfigP
2017
2741
  return paths.filter((configPath) => configPath.status === "parse-error");
2018
2742
  }
2019
2743
 
2744
+ function getSkippedUntrustedProjectConfigPaths(paths: SandboxConfigPath[]): SandboxConfigPath[] {
2745
+ return paths.filter((configPath) => configPath.status === "skipped-untrusted");
2746
+ }
2747
+
2020
2748
  function notifySandboxConfigParseErrors(ctx: ExtensionContext, paths: SandboxConfigPath[]): void {
2021
2749
  const details = paths
2022
2750
  .map((configPath) => `${configPath.label.toLowerCase()} (${configPath.path})`)
@@ -2042,7 +2770,7 @@ function loadSandboxConfigForContext(
2042
2770
  const { exitOnError = false } = options;
2043
2771
 
2044
2772
  try {
2045
- return loadConfig(cwd, overrideConfigPath);
2773
+ return loadConfig(cwd, overrideConfigPath, { projectTrusted: ctx.isProjectTrusted() });
2046
2774
  } catch (error) {
2047
2775
  if (!(error instanceof SandboxConfigLoadError)) throw error;
2048
2776
 
@@ -2083,6 +2811,7 @@ export default function (pi: ExtensionAPI) {
2083
2811
  let sessionContext: ExtensionContext | null = null;
2084
2812
  let sandboxConfigPaths: SandboxConfigPath[] = [];
2085
2813
  let sandboxEvents: SandboxEvent[] = [];
2814
+ const warnedSkippedProjectConfigPaths = new Set<string>();
2086
2815
 
2087
2816
  const pendingNetworkApprovals = new Map<string, Promise<boolean>>();
2088
2817
 
@@ -2127,21 +2856,38 @@ export default function (pi: ExtensionAPI) {
2127
2856
  });
2128
2857
  }
2129
2858
 
2859
+ function notifySkippedUntrustedProjectConfigs(ctx: ExtensionContext): void {
2860
+ const skippedConfigs = getSkippedUntrustedProjectConfigPaths(sandboxConfigPaths).filter(
2861
+ (configPath) => !warnedSkippedProjectConfigPaths.has(configPath.path),
2862
+ );
2863
+ if (skippedConfigs.length === 0) return;
2864
+
2865
+ for (const configPath of skippedConfigs) {
2866
+ warnedSkippedProjectConfigPaths.add(configPath.path);
2867
+ }
2868
+
2869
+ const details = skippedConfigs.map((configPath) => configPath.path).join(", ");
2870
+ notify(
2871
+ ctx,
2872
+ `Ignoring project sandbox config because this project is not trusted: ${details}`,
2873
+ "warning",
2874
+ );
2875
+ }
2876
+
2130
2877
  function applyRuntimeConfigForSession(
2131
2878
  ctx: ExtensionContext,
2132
2879
  runtimeConfig: SandboxRuntimeConfig,
2133
- targetStatus: "active" | "suspended" = sandboxState.status === "suspended"
2134
- ? "suspended"
2135
- : "active",
2136
2880
  ): void {
2137
2881
  const nextConfig = cloneRuntimeConfig(runtimeConfig);
2138
- sandboxState = { status: targetStatus, runtimeConfig: nextConfig };
2882
+ sandboxState = { status: "active", runtimeConfig: nextConfig };
2139
2883
  SandboxManager.updateConfig(nextConfig);
2140
- setSandboxStatus(ctx, targetStatus === "active", nextConfig, promptMode);
2884
+ setSandboxStatus(ctx, true, nextConfig, promptMode);
2141
2885
  }
2142
2886
 
2143
2887
  const createNetworkAskCallback = (): SandboxAskCallback => {
2144
2888
  return async ({ host, port }) => {
2889
+ if (sandboxState.status === "suspended") return true;
2890
+
2145
2891
  const normalizedHost = host.toLowerCase();
2146
2892
  const key = normalizedHost;
2147
2893
 
@@ -2355,6 +3101,7 @@ export default function (pi: ExtensionAPI) {
2355
3101
  if (!loadedConfig) return;
2356
3102
 
2357
3103
  sandboxConfigPaths = loadedConfig.paths;
3104
+ notifySkippedUntrustedProjectConfigs(ctx);
2358
3105
  const parseErrors = getSandboxConfigParseErrors(sandboxConfigPaths);
2359
3106
  if (parseErrors.length > 0) {
2360
3107
  notifySandboxConfigParseErrors(ctx, parseErrors);
@@ -2444,47 +3191,34 @@ export default function (pi: ExtensionAPI) {
2444
3191
  return;
2445
3192
  }
2446
3193
 
2447
- let runtimeConfig: SandboxRuntimeConfig | null = null;
2448
- let initializedNow = false;
2449
-
2450
- if (sandboxState.status === "suspended") {
2451
- runtimeConfig = sandboxState.runtimeConfig;
2452
- } else {
2453
- if (!isSupportedPlatform()) {
2454
- sandboxState = { status: "blocked", reason: "unsupported-platform" };
2455
- recordRuntimeEvent(
2456
- "init",
2457
- "unsupported-platform",
2458
- `sandbox not supported on ${process.platform}`,
2459
- );
2460
- notify(ctx, `Sandbox not supported on ${process.platform}`, "warning");
2461
- return;
2462
- }
3194
+ if (!isSupportedPlatform()) {
3195
+ sandboxState = { status: "blocked", reason: "unsupported-platform" };
3196
+ recordRuntimeEvent(
3197
+ "init",
3198
+ "unsupported-platform",
3199
+ `sandbox not supported on ${process.platform}`,
3200
+ );
3201
+ notify(ctx, `Sandbox not supported on ${process.platform}`, "warning");
3202
+ return;
3203
+ }
2463
3204
 
2464
- const sandboxConfigOverride = getStringFlag(pi, "sandbox-config");
2465
- const loadedConfig = loadSandboxConfigForContext(ctx, ctx.cwd, sandboxConfigOverride);
2466
- if (!loadedConfig) return;
3205
+ const sandboxConfigOverride = getStringFlag(pi, "sandbox-config");
3206
+ const loadedConfig = loadSandboxConfigForContext(ctx, ctx.cwd, sandboxConfigOverride);
3207
+ if (!loadedConfig) return;
2467
3208
 
2468
- sandboxConfigPaths = loadedConfig.paths;
2469
- const parseErrors = getSandboxConfigParseErrors(sandboxConfigPaths);
2470
- if (parseErrors.length > 0) {
2471
- notifySandboxConfigParseErrors(ctx, parseErrors);
2472
- }
2473
- sandboxConfig = loadedConfig.config;
2474
- runtimeConfig = await initializeSandboxRuntime(ctx, loadedConfig.config);
2475
- if (!runtimeConfig) return;
2476
- initializedNow = true;
3209
+ sandboxConfigPaths = loadedConfig.paths;
3210
+ notifySkippedUntrustedProjectConfigs(ctx);
3211
+ const parseErrors = getSandboxConfigParseErrors(sandboxConfigPaths);
3212
+ if (parseErrors.length > 0) {
3213
+ notifySandboxConfigParseErrors(ctx, parseErrors);
2477
3214
  }
2478
-
3215
+ sandboxConfig = loadedConfig.config;
3216
+ const runtimeConfig = await initializeSandboxRuntime(ctx, loadedConfig.config);
2479
3217
  if (!runtimeConfig) return;
2480
3218
 
2481
- if (initializedNow) {
2482
- setSandboxStatus(ctx, true, runtimeConfig, promptMode);
2483
- } else {
2484
- applyRuntimeConfigForSession(ctx, runtimeConfig, "active");
2485
- }
3219
+ setSandboxStatus(ctx, true, runtimeConfig, promptMode);
2486
3220
 
2487
- notify(ctx, "Sandbox enabled", "info");
3221
+ announceSandboxState(pi, ctx, true);
2488
3222
  return;
2489
3223
  }
2490
3224
 
@@ -2499,9 +3233,22 @@ export default function (pi: ExtensionAPI) {
2499
3233
  return;
2500
3234
  }
2501
3235
 
2502
- sandboxState = { status: "suspended", runtimeConfig: sandboxState.runtimeConfig };
3236
+ sandboxState = { status: "suspended" };
3237
+ pendingNetworkApprovals.clear();
2503
3238
  setSandboxStatus(ctx, false);
2504
- notify(ctx, "Sandbox disabled", "info");
3239
+
3240
+ try {
3241
+ await SandboxManager.reset();
3242
+ } catch (error) {
3243
+ notify(
3244
+ ctx,
3245
+ `Sandbox disabled, but cleanup failed: ${error instanceof Error ? error.message : error}`,
3246
+ "warning",
3247
+ );
3248
+ return;
3249
+ }
3250
+
3251
+ announceSandboxState(pi, ctx, false);
2505
3252
  return;
2506
3253
  }
2507
3254
 
@@ -2529,9 +3276,17 @@ export default function (pi: ExtensionAPI) {
2529
3276
  ` allowLocalBinding: ${runtimeConfig.network.allowLocalBinding ? "true" : "false"}`,
2530
3277
  ` allowAllUnixSockets: ${runtimeConfig.network.allowAllUnixSockets ? "true" : "false"}`,
2531
3278
  ` allowUnixSockets: ${runtimeConfig.network.allowUnixSockets?.join(", ") || "(none)"}`,
3279
+ ...(IS_MACOS
3280
+ ? [
3281
+ "",
3282
+ " macOS service lookup (mach-lookup):",
3283
+ ` Allowed: ${runtimeConfig.network.allowMachLookup?.join(", ") || "(none)"}`,
3284
+ ]
3285
+ : []),
2532
3286
  "",
2533
3287
  " Filesystem:",
2534
3288
  ` Deny Read: ${runtimeConfig.filesystem.denyRead.join(", ") || "(none)"}`,
3289
+ ` Allow Read: ${runtimeConfig.filesystem.allowRead?.join(", ") || "(none)"}`,
2535
3290
  ` Allow Write: ${runtimeConfig.filesystem.allowWrite.join(", ") || "(none)"}`,
2536
3291
  ` Deny Write: ${runtimeConfig.filesystem.denyWrite.join(", ") || "(none)"}`,
2537
3292
  ` allowTempDirs: ${sandboxConfig?.filesystem.allowTempDirs ? "true" : "false"}`,
@@ -2606,6 +3361,42 @@ export default function (pi: ExtensionAPI) {
2606
3361
  return;
2607
3362
  }
2608
3363
 
3364
+ if (subcommand === "mach-lookup") {
3365
+ if (!IS_MACOS) {
3366
+ notify(ctx, "Mach service lookup controls are only available on macOS.", "warning");
3367
+ return;
3368
+ }
3369
+
3370
+ const runtimeConfig = requireRuntimeConfig(ctx, sandboxState);
3371
+ if (!runtimeConfig) return;
3372
+
3373
+ const op = tokens[1]?.toLowerCase() as ListOp | undefined;
3374
+ const service = tokens[2]?.trim() ?? "";
3375
+
3376
+ if (
3377
+ (op !== "add" && op !== "remove") ||
3378
+ tokens.length !== 3 ||
3379
+ !isValidMachLookupRule(service)
3380
+ ) {
3381
+ notify(ctx, "Usage: /sandbox mach-lookup <add|remove> <service>", "warning");
3382
+ return;
3383
+ }
3384
+
3385
+ const nextConfig = cloneRuntimeConfig(runtimeConfig);
3386
+ const changed = mutateMachLookupAllowList(nextConfig, op, service);
3387
+ if (!changed) {
3388
+ notify(
3389
+ ctx,
3390
+ `No change: mach-lookup allow list already ${op === "add" ? "contains" : "omits"} ${service}`,
3391
+ );
3392
+ return;
3393
+ }
3394
+
3395
+ applyRuntimeConfigForSession(ctx, nextConfig);
3396
+ notify(ctx, `Updated mach-lookup allow list (${op}: ${service})`, "info");
3397
+ return;
3398
+ }
3399
+
2609
3400
  if (subcommand === "filesystem") {
2610
3401
  const runtimeConfig = requireRuntimeConfig(ctx, sandboxState);
2611
3402
  if (!runtimeConfig) return;