tau-coding-agent 0.1.4 → 0.1.5

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 (45) hide show
  1. package/README.md +1 -1
  2. package/extensions/answer.ts +48 -42
  3. package/extensions/branch-term.ts +8 -13
  4. package/extensions/btw.ts +4 -4
  5. package/extensions/ghostty.ts +64 -11
  6. package/extensions/insights.ts +4 -3
  7. package/extensions/loop.ts +37 -9
  8. package/extensions/memory.ts +5 -10
  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 +66 -0
  25. package/extensions/review/submit-triage-tool.ts +59 -0
  26. package/extensions/review/triage.ts +746 -0
  27. package/extensions/sandbox/index.ts +399 -102
  28. package/extensions/{interlude.ts → stash.ts} +13 -11
  29. package/extensions/tool-display-mode.ts +2 -3
  30. package/extensions/usage/index.ts +8 -4
  31. package/extensions/websearch/README.md +1 -1
  32. package/extensions/websearch/browser/chromium.ts +42 -31
  33. package/extensions/websearch/browser/discovery.ts +11 -9
  34. package/extensions/websearch/browser/firefox.ts +10 -7
  35. package/extensions/websearch/browser/fs.ts +10 -0
  36. package/extensions/websearch/browser/sqlite.ts +22 -15
  37. package/extensions/websearch/config.ts +2 -2
  38. package/extensions/websearch/index.ts +6 -6
  39. package/extensions/worktree.ts +33 -33
  40. package/package.json +3 -3
  41. package/skills/git-clean-history/SKILL.md +1 -0
  42. package/skills/git-commit/SKILL.md +2 -2
  43. package/skills/oracle/scripts/oracle +28 -11
  44. package/themes/tau-dark.json +2 -1
  45. package/extensions/review.ts +0 -4420
@@ -63,12 +63,20 @@ import {
63
63
  globToRegex,
64
64
  normalizePathForSandbox,
65
65
  } 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";
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";
68
74
 
69
75
  // --- Constants ---
70
76
 
71
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;
72
80
 
73
81
  const DEFAULT_CONFIG: SandboxConfig = {
74
82
  enabled: true,
@@ -79,30 +87,43 @@ const DEFAULT_CONFIG: SandboxConfig = {
79
87
  "127.0.0.1",
80
88
  "npmjs.org",
81
89
  "*.npmjs.org",
82
- "registry.npmjs.org",
90
+ "npmjs.com",
91
+ "*.npmjs.com",
83
92
  "registry.yarnpkg.com",
93
+ "nodejs.org",
94
+ "*.nodejs.org",
84
95
  "pypi.org",
85
96
  "*.pypi.org",
86
97
  "pythonhosted.org",
87
98
  "*.pythonhosted.org",
88
99
  "crates.io",
89
100
  "*.crates.io",
101
+ "rustup.rs",
102
+ "*.rust-lang.org",
103
+ "repo.maven.apache.org",
104
+ "gradle.org",
105
+ "*.gradle.org",
90
106
  "proxy.golang.org",
91
107
  "sum.golang.org",
92
108
  "go.dev",
93
109
  "golang.org",
94
110
  "rubygems.org",
95
111
  "*.rubygems.org",
112
+ "nuget.org",
113
+ "*.nuget.org",
96
114
  "github.com",
97
115
  "*.github.com",
98
116
  "githubusercontent.com",
99
117
  "*.githubusercontent.com",
100
- "api.github.com",
101
- "raw.githubusercontent.com",
118
+ "gitlab.com",
119
+ "*.gitlab.com",
120
+ "bitbucket.org",
121
+ "*.bitbucket.org",
102
122
  "ghcr.io",
103
123
  "docker.io",
104
124
  "*.docker.io",
105
- "production.cloudflare.docker.com",
125
+ "docker.com",
126
+ "*.docker.com",
106
127
  "sentry.io",
107
128
  "*.sentry.io",
108
129
  "datadoghq.com",
@@ -117,6 +138,8 @@ const DEFAULT_CONFIG: SandboxConfig = {
117
138
  "*.openai.com",
118
139
  "chatgpt.com",
119
140
  "*.chatgpt.com",
141
+ "openrouter.ai",
142
+ "*.openrouter.ai",
120
143
  "google.com",
121
144
  "*.google.com",
122
145
  "googleapis.com",
@@ -129,22 +152,64 @@ const DEFAULT_CONFIG: SandboxConfig = {
129
152
  "*.doist.com",
130
153
  ],
131
154
  deniedDomains: [],
155
+ allowUnixSockets: ["$SSH_AUTH_SOCK"],
132
156
  allowLocalBinding: true,
133
157
  },
134
158
  filesystem: {
135
159
  denyRead: ["~/.ssh", "~/.aws", "~/.gnupg"],
136
- allowWrite: [".", "~/.cache", "~/.npm"],
137
- denyWrite: [".env", ".env.*", "*.pem", "*.key"],
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
+ ],
138
199
  allowTempDirs: true,
139
200
  allowGitConfig: true,
140
201
  allowGitCommonDir: true,
141
202
  },
203
+ ignoreViolations: {
204
+ "*": ["/__pycache__"],
205
+ },
142
206
  };
143
207
 
144
208
  const STATUS_KEY = "sandbox";
145
209
  const SANDBOX_EVENT_LIMIT = 50;
146
- const METADATA_TRAVERSAL_PROCESSES = new Set(["find", "ls", "fd", "fdfind"]);
210
+ const READ_TRAVERSAL_PROCESSES = new Set(["find", "ls", "fd", "fdfind"]);
147
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;
148
213
 
149
214
  // --- Types ---
150
215
 
@@ -158,7 +223,7 @@ type SandboxRunMode = "sandbox" | "user-disabled" | SandboxBypassReason | Sandbo
158
223
  type SandboxState =
159
224
  | { status: "pending" }
160
225
  | { status: "active"; runtimeConfig: SandboxRuntimeConfig }
161
- | { status: "suspended"; runtimeConfig: SandboxRuntimeConfig }
226
+ | { status: "suspended" }
162
227
  | { status: "bypassed"; reason: SandboxBypassReason }
163
228
  | { status: "blocked"; reason: SandboxBlockedReason };
164
229
 
@@ -184,7 +249,7 @@ type SandboxEventReason =
184
249
  | "already-approved-still-failed"
185
250
  | "unknown";
186
251
  type SandboxEventOutcome = "blocked" | "allowed";
187
- type SandboxConfigPathStatus = "loaded" | "parse-error";
252
+ type SandboxConfigPathStatus = "loaded" | "parse-error" | "skipped-untrusted";
188
253
  type SandboxConfigPathLabel = "Global" | "Project" | "Override";
189
254
 
190
255
  type PromptStatus = "completed" | "error";
@@ -196,6 +261,7 @@ type FilesystemList = "deny-read" | "allow-write" | "deny-write";
196
261
 
197
262
  type FilesystemViolationKind = "read" | "write" | "unknown";
198
263
  type FilesystemReadAccess = "metadata" | "data" | "unknown";
264
+ type FilesystemWriteAccess = "unlink" | "unknown";
199
265
  type FilesystemViolationResolutionKind = "allow-retry" | "allow-adapt" | "deny";
200
266
 
201
267
  interface SandboxEvent {
@@ -245,6 +311,7 @@ interface FilesystemViolation {
245
311
  path?: string;
246
312
  processName?: string;
247
313
  readAccess?: FilesystemReadAccess;
314
+ writeAccess?: FilesystemWriteAccess;
248
315
  }
249
316
 
250
317
  type FilesystemViolationResolution =
@@ -575,12 +642,7 @@ function formatSandboxConfigLoadErrorMessage(
575
642
  return `Could not read sandbox override config ${path}: ${detail ?? "unknown error"}`;
576
643
  }
577
644
 
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
-
645
+ function cleanStringArray(value: unknown[], field: string): string[] {
584
646
  const cleaned = value
585
647
  .filter((item): item is string => typeof item === "string")
586
648
  .map((item) => item.trim())
@@ -594,6 +656,30 @@ function coerceStringArray(value: unknown, fallback: string[], field: string): s
594
656
  return cleaned;
595
657
  }
596
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
+
597
683
  function finalizeConfig(config: SandboxConfig): SandboxConfig {
598
684
  return {
599
685
  ...config,
@@ -611,6 +697,11 @@ function finalizeConfig(config: SandboxConfig): SandboxConfig {
611
697
  DEFAULT_CONFIG.network.deniedDomains,
612
698
  "network.deniedDomains",
613
699
  ),
700
+ allowUnixSockets: coerceOptionalStringArray(
701
+ config.network?.allowUnixSockets,
702
+ DEFAULT_CONFIG.network.allowUnixSockets,
703
+ "network.allowUnixSockets",
704
+ ),
614
705
  },
615
706
  filesystem: {
616
707
  ...config.filesystem,
@@ -619,6 +710,11 @@ function finalizeConfig(config: SandboxConfig): SandboxConfig {
619
710
  DEFAULT_CONFIG.filesystem.denyRead,
620
711
  "filesystem.denyRead",
621
712
  ),
713
+ allowRead: coerceOptionalStringArray(
714
+ config.filesystem?.allowRead,
715
+ DEFAULT_CONFIG.filesystem.allowRead,
716
+ "filesystem.allowRead",
717
+ ),
622
718
  allowWrite: coerceStringArray(
623
719
  config.filesystem?.allowWrite,
624
720
  DEFAULT_CONFIG.filesystem.allowWrite,
@@ -680,13 +776,17 @@ function loadOverrideConfig(cwd: string, overrideConfigPath: string): LoadedSand
680
776
  };
681
777
  }
682
778
 
683
- function loadConfig(cwd: string, overrideConfigPath?: string): LoadedSandboxConfig {
779
+ function loadConfig(
780
+ cwd: string,
781
+ overrideConfigPath?: string,
782
+ options: { projectTrusted?: boolean } = {},
783
+ ): LoadedSandboxConfig {
684
784
  if (overrideConfigPath) {
685
785
  return loadOverrideConfig(cwd, overrideConfigPath);
686
786
  }
687
787
 
688
- const projectConfigPath = join(cwd, ".pi", "sandbox.json");
689
- const globalConfigPath = join(homedir(), ".pi", "agent", "sandbox.json");
788
+ const projectConfigPath = join(cwd, CONFIG_DIR_NAME, "sandbox.json");
789
+ const globalConfigPath = join(getAgentDir(), "sandbox.json");
690
790
 
691
791
  let globalConfig: Partial<SandboxConfig> = {};
692
792
  let projectConfig: Partial<SandboxConfig> = {};
@@ -703,12 +803,16 @@ function loadConfig(cwd: string, overrideConfigPath?: string): LoadedSandboxConf
703
803
  }
704
804
 
705
805
  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}`);
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
+ }
712
816
  }
713
817
  }
714
818
 
@@ -773,15 +877,128 @@ function getTemporaryWritePaths(): string[] {
773
877
  return cachedTemporaryWritePaths;
774
878
  }
775
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
+
776
982
  function toRuntimeConfig(config: SandboxConfig): SandboxRuntimeConfig {
777
983
  const { allowGitCommonDir: _allowGitCommonDir, allowTempDirs, ...filesystem } = config.filesystem;
984
+ const expandedNetwork = expandNetworkPathConfig(config.network);
985
+ const expandedFilesystem = expandFilesystemPathConfig(filesystem);
778
986
  const allowWrite = allowTempDirs
779
- ? Array.from(new Set([...filesystem.allowWrite, ...getTemporaryWritePaths()]))
780
- : filesystem.allowWrite;
987
+ ? [...expandedFilesystem.allowWrite, ...getTemporaryWritePaths()]
988
+ : expandedFilesystem.allowWrite;
781
989
 
782
990
  return {
783
- network: config.network,
784
- filesystem: { ...filesystem, allowWrite },
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
+ },
785
1002
  ignoreViolations: config.ignoreViolations,
786
1003
  enableWeakerNestedSandbox: config.enableWeakerNestedSandbox,
787
1004
  enableWeakerNetworkIsolation: config.enableWeakerNetworkIsolation,
@@ -818,6 +1035,19 @@ function inferSandboxRuleMatch(path: string, rules: string[], cwd?: string): str
818
1035
  return null;
819
1036
  }
820
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
+
821
1051
  function isSandboxWritablePath(
822
1052
  runtimeConfig: SandboxRuntimeConfig,
823
1053
  path: string,
@@ -957,7 +1187,9 @@ function extractPathLikeValue(text: string): string | undefined {
957
1187
 
958
1188
  function extractViolationProcessName(line: string): string | undefined {
959
1189
  const match = line.match(/^([^\s(]+)\(/);
960
- return match?.[1]?.trim() || undefined;
1190
+ const processName = match?.[1]?.trim();
1191
+ if (!processName) return undefined;
1192
+ return processName.split("/").pop() || processName;
961
1193
  }
962
1194
 
963
1195
  function detectFilesystemViolationFromLine(line: string): FilesystemViolation | null {
@@ -966,8 +1198,12 @@ function detectFilesystemViolationFromLine(line: string): FilesystemViolation |
966
1198
  const path = extractPathLikeValue(line);
967
1199
  const processName = extractViolationProcessName(line);
968
1200
 
1201
+ if (lower.includes("file-write-unlink")) {
1202
+ return { kind: "write", path, processName, writeAccess: "unlink" };
1203
+ }
1204
+
969
1205
  if (lower.includes("file-write")) {
970
- return { kind: "write", path, processName };
1206
+ return { kind: "write", path, processName, writeAccess: "unknown" };
971
1207
  }
972
1208
 
973
1209
  if (lower.includes("file-read-metadata")) {
@@ -1016,19 +1252,30 @@ function detectFilesystemViolations(
1016
1252
  return violations;
1017
1253
  }
1018
1254
 
1019
- function isMetadataTraversalViolation(
1255
+ function isTraversalViolation(
1020
1256
  runtimeConfig: SandboxRuntimeConfig | null,
1021
1257
  violation: FilesystemViolation,
1022
1258
  cwd?: string,
1023
1259
  ): 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;
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
+ );
1029
1276
  }
1030
1277
 
1031
- function getMetadataTraversalPaths(options: {
1278
+ function getTraversalPaths(options: {
1032
1279
  runtimeConfig: SandboxRuntimeConfig | null;
1033
1280
  output: string;
1034
1281
  cwd?: string;
@@ -1047,7 +1294,7 @@ function getMetadataTraversalPaths(options: {
1047
1294
  const skippedPaths: string[] = [];
1048
1295
  for (const line of violationLines) {
1049
1296
  const violation = detectFilesystemViolationFromLine(line);
1050
- if (!violation || !isMetadataTraversalViolation(runtimeConfig, violation, cwd)) {
1297
+ if (!violation || !isTraversalViolation(runtimeConfig, violation, cwd)) {
1051
1298
  return null;
1052
1299
  }
1053
1300
  if (violation.path && !skippedPaths.includes(violation.path)) {
@@ -1058,7 +1305,7 @@ function getMetadataTraversalPaths(options: {
1058
1305
  return skippedPaths.length > 0 ? skippedPaths : null;
1059
1306
  }
1060
1307
 
1061
- function formatMetadataTraversalNotice(paths: string[]): string {
1308
+ function formatTraversalNotice(paths: string[]): string {
1062
1309
  if (paths.length === 0) return "";
1063
1310
 
1064
1311
  const visiblePaths = paths.slice(0, 3).join(", ");
@@ -1211,32 +1458,36 @@ function parseFilesystemPromptSelection(
1211
1458
  return "deny";
1212
1459
  }
1213
1460
 
1214
- function formatFilesystemAllowRetryMessage(_target: string): string {
1215
- return "\nSandbox blocked access.\n\nGranting access and retrying the command per user request...\n\n";
1461
+ function formatFilesystemAllowRetryMessage(target: string): string {
1462
+ return `\n${formatFilesystemBlockedTarget(target)}\n\nGranting access and retrying the command per user request...\n\n`;
1216
1463
  }
1217
1464
 
1218
- function formatFilesystemAllowAdaptMessage(_target: string): string {
1219
- return "\nSandbox blocked access.\n\nAccess granted for this session. Retry the command manually if appropriate.";
1465
+ function formatFilesystemAllowAdaptMessage(target: string): string {
1466
+ return `\n${formatFilesystemBlockedTarget(target)}\n\nAccess granted for this session. Retry the command manually if appropriate.`;
1220
1467
  }
1221
1468
 
1222
- function formatFilesystemDeniedMessage(_target: string): string {
1223
- return "\nSandbox blocked access.\n\nAccess remains denied for this session.";
1469
+ function formatFilesystemDeniedMessage(target: string): string {
1470
+ return `\n${formatFilesystemBlockedTarget(target)}\n\nAccess remains denied for this session.`;
1224
1471
  }
1225
1472
 
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.";
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.`;
1228
1475
  }
1229
1476
 
1230
1477
  function formatFilesystemRetrySucceededMessage(_target: string): string {
1231
1478
  return "";
1232
1479
  }
1233
1480
 
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.";
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.`;
1236
1487
  }
1237
1488
 
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.";
1489
+ function formatFilesystemBlockedTarget(target: string): string {
1490
+ return `Sandbox blocked filesystem ${target}.`;
1240
1491
  }
1241
1492
 
1242
1493
  function appendOutputPostamble(postamble: string, addition: string, output: string): string {
@@ -1502,6 +1753,12 @@ function createSandboxedBashOps(options: SandboxedBashOpsOptions): BashOperation
1502
1753
  return run;
1503
1754
  }
1504
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
+
1505
1762
  async function runSandboxAttempt(
1506
1763
  command: string,
1507
1764
  wrappedCommand: string,
@@ -1515,7 +1772,7 @@ function createSandboxedBashOps(options: SandboxedBashOpsOptions): BashOperation
1515
1772
  return new Promise((resolve, reject) => {
1516
1773
  const child = spawn("bash", ["-c", wrappedCommand], {
1517
1774
  cwd,
1518
- env,
1775
+ env: withSandboxDefaultEnv(env),
1519
1776
  detached: true,
1520
1777
  stdio: ["ignore", "pipe", "pipe"],
1521
1778
  });
@@ -1558,7 +1815,7 @@ function createSandboxedBashOps(options: SandboxedBashOpsOptions): BashOperation
1558
1815
  const shouldStop = newViolations.some((violation) => {
1559
1816
  const filesystemViolation = detectFilesystemViolationFromLine(violation.line);
1560
1817
  if (!filesystemViolation) return false;
1561
- return !isMetadataTraversalViolation(runtimeConfig, filesystemViolation, cwd);
1818
+ return !isTraversalViolation(runtimeConfig, filesystemViolation, cwd);
1562
1819
  });
1563
1820
  if (shouldStop) {
1564
1821
  stopForFilesystemViolation();
@@ -1629,6 +1886,16 @@ function createSandboxedBashOps(options: SandboxedBashOpsOptions): BashOperation
1629
1886
  });
1630
1887
  }
1631
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
+
1632
1899
  function getRemainingTimeout(timeout: number | undefined, startedAt: number): number | undefined {
1633
1900
  if (timeout === undefined) return undefined;
1634
1901
 
@@ -1701,13 +1968,13 @@ function createSandboxedBashOps(options: SandboxedBashOpsOptions): BashOperation
1701
1968
  attempt.combinedOutput,
1702
1969
  );
1703
1970
  const runtimeConfig = getRuntimeConfig();
1704
- const metadataTraversalPaths = getMetadataTraversalPaths({
1971
+ const traversalPaths = getTraversalPaths({
1705
1972
  runtimeConfig,
1706
1973
  output: annotatedOutput,
1707
1974
  cwd,
1708
1975
  skipViolationLines: existingViolationCount,
1709
1976
  });
1710
- const effectiveExitCode = metadataTraversalPaths ? 0 : attempt.exitCode;
1977
+ const effectiveExitCode = traversalPaths ? 0 : attempt.exitCode;
1711
1978
  let postamble = extractAppendedSandboxAnnotation(
1712
1979
  attempt.combinedOutput,
1713
1980
  annotatedOutput,
@@ -1715,8 +1982,8 @@ function createSandboxedBashOps(options: SandboxedBashOpsOptions): BashOperation
1715
1982
  );
1716
1983
  let resolution: FilesystemViolationResolution | null = null;
1717
1984
 
1718
- if (metadataTraversalPaths) {
1719
- const notice = formatMetadataTraversalNotice(metadataTraversalPaths);
1985
+ if (traversalPaths) {
1986
+ const notice = formatTraversalNotice(traversalPaths);
1720
1987
  postamble = appendOutputPostamble(postamble, notice, attempt.combinedOutput);
1721
1988
  } else if (runtimeConfig) {
1722
1989
  resolution = await handleFilesystemViolation({
@@ -1745,6 +2012,7 @@ function createSandboxedBashOps(options: SandboxedBashOpsOptions): BashOperation
1745
2012
 
1746
2013
  return {
1747
2014
  async exec(command, cwd, { onData, signal, timeout, env }) {
2015
+ validateTimeout(timeout);
1748
2016
  return runSerially(async () => {
1749
2017
  if (!existsSync(cwd)) {
1750
2018
  throw new Error(`Working directory does not exist: ${cwd}`);
@@ -1849,7 +2117,7 @@ function getSandboxRunMode(state: SandboxState): SandboxRunMode {
1849
2117
  }
1850
2118
 
1851
2119
  function getStateRuntimeConfig(state: SandboxState): SandboxRuntimeConfig | null {
1852
- if (state.status === "active" || state.status === "suspended") return state.runtimeConfig;
2120
+ if (state.status === "active") return state.runtimeConfig;
1853
2121
  return null;
1854
2122
  }
1855
2123
 
@@ -1985,7 +2253,12 @@ function renderSandboxDoctorReport(options: {
1985
2253
  } else {
1986
2254
  lines.push("- Config paths:");
1987
2255
  for (const configPath of configPaths) {
1988
- const status = configPath.status === "loaded" ? "loaded" : "parse error";
2256
+ const status =
2257
+ configPath.status === "loaded"
2258
+ ? "loaded"
2259
+ : configPath.status === "skipped-untrusted"
2260
+ ? "skipped (project not trusted)"
2261
+ : "parse error";
1989
2262
  lines.push(` - ${configPath.label}: ${configPath.path} (${status})`);
1990
2263
  }
1991
2264
  }
@@ -2017,6 +2290,10 @@ function getSandboxConfigParseErrors(paths: SandboxConfigPath[]): SandboxConfigP
2017
2290
  return paths.filter((configPath) => configPath.status === "parse-error");
2018
2291
  }
2019
2292
 
2293
+ function getSkippedUntrustedProjectConfigPaths(paths: SandboxConfigPath[]): SandboxConfigPath[] {
2294
+ return paths.filter((configPath) => configPath.status === "skipped-untrusted");
2295
+ }
2296
+
2020
2297
  function notifySandboxConfigParseErrors(ctx: ExtensionContext, paths: SandboxConfigPath[]): void {
2021
2298
  const details = paths
2022
2299
  .map((configPath) => `${configPath.label.toLowerCase()} (${configPath.path})`)
@@ -2042,7 +2319,7 @@ function loadSandboxConfigForContext(
2042
2319
  const { exitOnError = false } = options;
2043
2320
 
2044
2321
  try {
2045
- return loadConfig(cwd, overrideConfigPath);
2322
+ return loadConfig(cwd, overrideConfigPath, { projectTrusted: ctx.isProjectTrusted() });
2046
2323
  } catch (error) {
2047
2324
  if (!(error instanceof SandboxConfigLoadError)) throw error;
2048
2325
 
@@ -2083,6 +2360,7 @@ export default function (pi: ExtensionAPI) {
2083
2360
  let sessionContext: ExtensionContext | null = null;
2084
2361
  let sandboxConfigPaths: SandboxConfigPath[] = [];
2085
2362
  let sandboxEvents: SandboxEvent[] = [];
2363
+ const warnedSkippedProjectConfigPaths = new Set<string>();
2086
2364
 
2087
2365
  const pendingNetworkApprovals = new Map<string, Promise<boolean>>();
2088
2366
 
@@ -2127,21 +2405,38 @@ export default function (pi: ExtensionAPI) {
2127
2405
  });
2128
2406
  }
2129
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
+
2130
2426
  function applyRuntimeConfigForSession(
2131
2427
  ctx: ExtensionContext,
2132
2428
  runtimeConfig: SandboxRuntimeConfig,
2133
- targetStatus: "active" | "suspended" = sandboxState.status === "suspended"
2134
- ? "suspended"
2135
- : "active",
2136
2429
  ): void {
2137
2430
  const nextConfig = cloneRuntimeConfig(runtimeConfig);
2138
- sandboxState = { status: targetStatus, runtimeConfig: nextConfig };
2431
+ sandboxState = { status: "active", runtimeConfig: nextConfig };
2139
2432
  SandboxManager.updateConfig(nextConfig);
2140
- setSandboxStatus(ctx, targetStatus === "active", nextConfig, promptMode);
2433
+ setSandboxStatus(ctx, true, nextConfig, promptMode);
2141
2434
  }
2142
2435
 
2143
2436
  const createNetworkAskCallback = (): SandboxAskCallback => {
2144
2437
  return async ({ host, port }) => {
2438
+ if (sandboxState.status === "suspended") return true;
2439
+
2145
2440
  const normalizedHost = host.toLowerCase();
2146
2441
  const key = normalizedHost;
2147
2442
 
@@ -2355,6 +2650,7 @@ export default function (pi: ExtensionAPI) {
2355
2650
  if (!loadedConfig) return;
2356
2651
 
2357
2652
  sandboxConfigPaths = loadedConfig.paths;
2653
+ notifySkippedUntrustedProjectConfigs(ctx);
2358
2654
  const parseErrors = getSandboxConfigParseErrors(sandboxConfigPaths);
2359
2655
  if (parseErrors.length > 0) {
2360
2656
  notifySandboxConfigParseErrors(ctx, parseErrors);
@@ -2444,45 +2740,32 @@ export default function (pi: ExtensionAPI) {
2444
2740
  return;
2445
2741
  }
2446
2742
 
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
- }
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
+ }
2463
2753
 
2464
- const sandboxConfigOverride = getStringFlag(pi, "sandbox-config");
2465
- const loadedConfig = loadSandboxConfigForContext(ctx, ctx.cwd, sandboxConfigOverride);
2466
- if (!loadedConfig) return;
2754
+ const sandboxConfigOverride = getStringFlag(pi, "sandbox-config");
2755
+ const loadedConfig = loadSandboxConfigForContext(ctx, ctx.cwd, sandboxConfigOverride);
2756
+ if (!loadedConfig) return;
2467
2757
 
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;
2758
+ sandboxConfigPaths = loadedConfig.paths;
2759
+ notifySkippedUntrustedProjectConfigs(ctx);
2760
+ const parseErrors = getSandboxConfigParseErrors(sandboxConfigPaths);
2761
+ if (parseErrors.length > 0) {
2762
+ notifySandboxConfigParseErrors(ctx, parseErrors);
2477
2763
  }
2478
-
2764
+ sandboxConfig = loadedConfig.config;
2765
+ const runtimeConfig = await initializeSandboxRuntime(ctx, loadedConfig.config);
2479
2766
  if (!runtimeConfig) return;
2480
2767
 
2481
- if (initializedNow) {
2482
- setSandboxStatus(ctx, true, runtimeConfig, promptMode);
2483
- } else {
2484
- applyRuntimeConfigForSession(ctx, runtimeConfig, "active");
2485
- }
2768
+ setSandboxStatus(ctx, true, runtimeConfig, promptMode);
2486
2769
 
2487
2770
  notify(ctx, "Sandbox enabled", "info");
2488
2771
  return;
@@ -2499,8 +2782,21 @@ export default function (pi: ExtensionAPI) {
2499
2782
  return;
2500
2783
  }
2501
2784
 
2502
- sandboxState = { status: "suspended", runtimeConfig: sandboxState.runtimeConfig };
2785
+ sandboxState = { status: "suspended" };
2786
+ pendingNetworkApprovals.clear();
2503
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
+
2504
2800
  notify(ctx, "Sandbox disabled", "info");
2505
2801
  return;
2506
2802
  }
@@ -2532,6 +2828,7 @@ export default function (pi: ExtensionAPI) {
2532
2828
  "",
2533
2829
  " Filesystem:",
2534
2830
  ` Deny Read: ${runtimeConfig.filesystem.denyRead.join(", ") || "(none)"}`,
2831
+ ` Allow Read: ${runtimeConfig.filesystem.allowRead?.join(", ") || "(none)"}`,
2535
2832
  ` Allow Write: ${runtimeConfig.filesystem.allowWrite.join(", ") || "(none)"}`,
2536
2833
  ` Deny Write: ${runtimeConfig.filesystem.denyWrite.join(", ") || "(none)"}`,
2537
2834
  ` allowTempDirs: ${sandboxConfig?.filesystem.allowTempDirs ? "true" : "false"}`,