tau-coding-agent 0.1.6 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +15 -12
- package/extensions/answer.ts +129 -79
- package/extensions/branch-term/README.md +7 -0
- package/extensions/{branch-term.ts → branch-term/index.ts} +113 -104
- package/extensions/btw.ts +17 -2
- package/extensions/caffeinate/README.md +5 -0
- package/extensions/caffeinate/index.ts +144 -0
- package/extensions/fast.ts +292 -0
- package/extensions/ghostty.ts +214 -211
- package/extensions/git-diff-stats.ts +124 -84
- package/extensions/git-pr-status.ts +274 -208
- package/extensions/insights.ts +109 -191
- package/extensions/loop.ts +149 -145
- package/extensions/memory.ts +138 -86
- package/extensions/notify.ts +14 -26
- package/extensions/openai-verbosity.ts +108 -42
- package/extensions/review/fix.ts +15 -6
- package/extensions/review/git.ts +93 -103
- package/extensions/review/index.ts +60 -62
- package/extensions/review/interrupt.ts +117 -0
- package/extensions/review/message-queue.ts +45 -12
- package/extensions/review/models.ts +79 -3
- package/extensions/review/prompts.ts +41 -38
- package/extensions/review/review.ts +161 -166
- package/extensions/review/runner.ts +139 -147
- package/extensions/review/runtime.ts +173 -120
- package/extensions/review/triage.ts +41 -44
- package/extensions/sandbox/bash.ts +775 -0
- package/extensions/sandbox/command.ts +605 -0
- package/extensions/sandbox/config.ts +764 -0
- package/extensions/sandbox/index.ts +132 -3338
- package/extensions/sandbox/permissions/dialog.ts +118 -0
- package/extensions/sandbox/permissions/filesystem.ts +559 -0
- package/extensions/sandbox/permissions/mach-lookup.ts +186 -0
- package/extensions/sandbox/permissions/network.ts +164 -0
- package/extensions/sandbox/permissions/unsandboxed.ts +270 -0
- package/extensions/sandbox/runtime.ts +616 -0
- package/extensions/stash.ts +28 -15
- package/extensions/subagent/README.md +73 -0
- package/extensions/subagent/index.ts +822 -0
- package/extensions/subagent/interrupt.ts +117 -0
- package/extensions/subagent/permissions.ts +101 -0
- package/extensions/subagent/rpc.ts +177 -0
- package/extensions/tool-display-mode.ts +267 -64
- package/extensions/usage/index.ts +249 -356
- package/extensions/usage/openrouter.ts +10 -2
- package/extensions/websearch/README.md +12 -47
- package/extensions/websearch/config.ts +5 -2
- package/extensions/websearch/index.ts +94 -109
- package/extensions/websearch/output.ts +51 -0
- package/extensions/websearch/providers/anthropic.pi.ts +27 -44
- package/extensions/websearch/providers/gemini.browser.ts +68 -77
- package/extensions/websearch/providers/gemini.pi.ts +17 -17
- package/extensions/websearch/providers/openai-codex.pi.ts +145 -7
- package/extensions/websearch/providers/pi-model.shared.ts +33 -39
- package/extensions/websearch/providers/shared.ts +87 -2
- package/extensions/websearch/types.ts +0 -3
- package/extensions/worktree.ts +132 -172
- package/package.json +10 -5
- package/skills/browser-tools/SKILL.md +29 -234
- package/skills/browser-tools/references/cookies.md +36 -0
- package/skills/browser-tools/references/interaction.md +90 -0
- package/skills/browser-tools/references/logging.md +34 -0
- package/skills/git-clean-history/SKILL.md +6 -6
- package/skills/git-commit/SKILL.md +5 -3
- package/skills/github-pull-request/SKILL.md +60 -0
- package/skills/github-pull-request/references/create.md +40 -0
- package/skills/github-pull-request/references/stewardship.md +60 -0
- package/skills/oracle/SKILL.md +3 -3
- package/skills/oracle/scripts/oracle +10 -1
- package/skills/sentry/SKILL.md +15 -185
- package/skills/sentry/references/events.md +79 -0
- package/skills/sentry/references/issues.md +62 -0
- package/skills/sentry/references/logs.md +46 -0
- package/skills/update-changelog/SKILL.md +27 -121
- package/skills/web-design/SKILL.md +16 -105
- package/themes/tau-dark.json +4 -0
- package/extensions/openai-fast.ts +0 -229
- package/extensions/websearch/providers/openai-codex.browser.ts +0 -77
- package/extensions/websearch/providers/openai-codex.shared.ts +0 -123
|
@@ -3,7 +3,8 @@
|
|
|
3
3
|
*
|
|
4
4
|
* Uses @anthropic-ai/sandbox-runtime to enforce filesystem and network
|
|
5
5
|
* restrictions on bash commands at the OS level (sandbox-exec on macOS,
|
|
6
|
-
* bubblewrap on Linux).
|
|
6
|
+
* bubblewrap on Linux). PowerShell is intentionally not registered or sandboxed
|
|
7
|
+
* because Tau does not officially support Windows.
|
|
7
8
|
*
|
|
8
9
|
* Config files (merged, project takes precedence):
|
|
9
10
|
* - ~/.pi/agent/sandbox.json (global)
|
|
@@ -25,7 +26,8 @@
|
|
|
25
26
|
* "allowMachLookup": [
|
|
26
27
|
* "com.apple.dnssd.service",
|
|
27
28
|
* "com.apple.SystemConfiguration.configd",
|
|
28
|
-
* "com.apple.SystemConfiguration.DNSConfiguration"
|
|
29
|
+
* "com.apple.SystemConfiguration.DNSConfiguration",
|
|
30
|
+
* "com.apple.FSEvents"
|
|
29
31
|
* ]
|
|
30
32
|
* },
|
|
31
33
|
* "filesystem": {
|
|
@@ -51,2747 +53,20 @@
|
|
|
51
53
|
* Linux also requires: bubblewrap, socat, ripgrep
|
|
52
54
|
*/
|
|
53
55
|
|
|
54
|
-
import { spawn, spawnSync } from "node:child_process";
|
|
55
|
-
import { existsSync, readFileSync } from "node:fs";
|
|
56
|
-
import { homedir, tmpdir } from "node:os";
|
|
57
|
-
import { join, resolve } from "node:path";
|
|
58
56
|
import { fileURLToPath } from "node:url";
|
|
59
57
|
import {
|
|
60
|
-
|
|
61
|
-
type
|
|
62
|
-
type
|
|
63
|
-
} from "@
|
|
64
|
-
|
|
65
|
-
// The public package exports do not expose these helpers, but using the same internals keeps
|
|
66
|
-
// the extension's policy checks aligned with the runtime's actual sandbox behavior.
|
|
67
|
-
import {
|
|
68
|
-
containsGlobChars,
|
|
69
|
-
globToRegex,
|
|
70
|
-
normalizePathForSandbox,
|
|
71
|
-
} from "@anthropic-ai/sandbox-runtime/dist/sandbox/sandbox-utils.js";
|
|
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";
|
|
80
|
-
|
|
81
|
-
// --- Constants ---
|
|
82
|
-
|
|
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;
|
|
88
|
-
|
|
89
|
-
const DEFAULT_CONFIG: SandboxConfig = {
|
|
90
|
-
enabled: true,
|
|
91
|
-
mode: DEFAULT_PROMPT_MODE,
|
|
92
|
-
network: {
|
|
93
|
-
allowedDomains: [
|
|
94
|
-
"localhost",
|
|
95
|
-
"127.0.0.1",
|
|
96
|
-
"npmjs.org",
|
|
97
|
-
"*.npmjs.org",
|
|
98
|
-
"npmjs.com",
|
|
99
|
-
"*.npmjs.com",
|
|
100
|
-
"registry.yarnpkg.com",
|
|
101
|
-
"nodejs.org",
|
|
102
|
-
"*.nodejs.org",
|
|
103
|
-
"pypi.org",
|
|
104
|
-
"*.pypi.org",
|
|
105
|
-
"pythonhosted.org",
|
|
106
|
-
"*.pythonhosted.org",
|
|
107
|
-
"crates.io",
|
|
108
|
-
"*.crates.io",
|
|
109
|
-
"rustup.rs",
|
|
110
|
-
"*.rust-lang.org",
|
|
111
|
-
"repo.maven.apache.org",
|
|
112
|
-
"gradle.org",
|
|
113
|
-
"*.gradle.org",
|
|
114
|
-
"proxy.golang.org",
|
|
115
|
-
"sum.golang.org",
|
|
116
|
-
"go.dev",
|
|
117
|
-
"golang.org",
|
|
118
|
-
"rubygems.org",
|
|
119
|
-
"*.rubygems.org",
|
|
120
|
-
"nuget.org",
|
|
121
|
-
"*.nuget.org",
|
|
122
|
-
"github.com",
|
|
123
|
-
"*.github.com",
|
|
124
|
-
"githubusercontent.com",
|
|
125
|
-
"*.githubusercontent.com",
|
|
126
|
-
"gitlab.com",
|
|
127
|
-
"*.gitlab.com",
|
|
128
|
-
"bitbucket.org",
|
|
129
|
-
"*.bitbucket.org",
|
|
130
|
-
"ghcr.io",
|
|
131
|
-
"docker.io",
|
|
132
|
-
"*.docker.io",
|
|
133
|
-
"docker.com",
|
|
134
|
-
"*.docker.com",
|
|
135
|
-
"sentry.io",
|
|
136
|
-
"*.sentry.io",
|
|
137
|
-
"datadoghq.com",
|
|
138
|
-
"*.datadoghq.com",
|
|
139
|
-
"datadoghq.eu",
|
|
140
|
-
"*.datadoghq.eu",
|
|
141
|
-
"anthropic.com",
|
|
142
|
-
"*.anthropic.com",
|
|
143
|
-
"claude.ai",
|
|
144
|
-
"*.claude.ai",
|
|
145
|
-
"openai.com",
|
|
146
|
-
"*.openai.com",
|
|
147
|
-
"chatgpt.com",
|
|
148
|
-
"*.chatgpt.com",
|
|
149
|
-
"openrouter.ai",
|
|
150
|
-
"*.openrouter.ai",
|
|
151
|
-
"google.com",
|
|
152
|
-
"*.google.com",
|
|
153
|
-
"googleapis.com",
|
|
154
|
-
"*.googleapis.com",
|
|
155
|
-
"todoist.com",
|
|
156
|
-
"*.todoist.com",
|
|
157
|
-
"twist.com",
|
|
158
|
-
"*.twist.com",
|
|
159
|
-
"doist.com",
|
|
160
|
-
"*.doist.com",
|
|
161
|
-
],
|
|
162
|
-
deniedDomains: [],
|
|
163
|
-
allowUnixSockets: ["$SSH_AUTH_SOCK"],
|
|
164
|
-
allowLocalBinding: true,
|
|
165
|
-
allowMachLookup: [
|
|
166
|
-
"com.apple.dnssd.service",
|
|
167
|
-
"com.apple.SystemConfiguration.configd",
|
|
168
|
-
"com.apple.SystemConfiguration.DNSConfiguration",
|
|
169
|
-
],
|
|
170
|
-
},
|
|
171
|
-
filesystem: {
|
|
172
|
-
denyRead: ["~/.ssh", "~/.aws", "~/.gnupg"],
|
|
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
|
-
],
|
|
212
|
-
allowTempDirs: true,
|
|
213
|
-
allowGitConfig: true,
|
|
214
|
-
allowGitCommonDir: true,
|
|
215
|
-
},
|
|
216
|
-
ignoreViolations: {
|
|
217
|
-
"*": ["/__pycache__", "mach-lookup com.apple.usymptomsd"],
|
|
218
|
-
},
|
|
219
|
-
};
|
|
220
|
-
|
|
221
|
-
const STATUS_KEY = "sandbox";
|
|
222
|
-
const SANDBOX_EVENT_LIMIT = 50;
|
|
223
|
-
const READ_TRAVERSAL_PROCESSES = new Set(["find", "ls", "fd", "fdfind"]);
|
|
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;
|
|
226
|
-
|
|
227
|
-
// --- Types ---
|
|
228
|
-
|
|
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 };
|
|
244
|
-
|
|
245
|
-
type SandboxBypassReason = "no-sandbox-flag" | "config-disabled" | "missing-dependencies";
|
|
246
|
-
type SandboxBlockedReason = "unsupported-platform" | "init-failed";
|
|
247
|
-
|
|
248
|
-
type SandboxRunMode = "sandbox" | "user-disabled" | SandboxBypassReason | SandboxBlockedReason;
|
|
249
|
-
|
|
250
|
-
type SandboxState =
|
|
251
|
-
| { status: "pending" }
|
|
252
|
-
| { status: "active"; runtimeConfig: SandboxRuntimeConfig }
|
|
253
|
-
| { status: "suspended" }
|
|
254
|
-
| { status: "bypassed"; reason: SandboxBypassReason }
|
|
255
|
-
| { status: "blocked"; reason: SandboxBlockedReason };
|
|
256
|
-
|
|
257
|
-
type SandboxConfig = Omit<SandboxRuntimeConfig, "filesystem"> & {
|
|
258
|
-
enabled?: boolean;
|
|
259
|
-
mode?: PromptMode;
|
|
260
|
-
filesystem: SandboxRuntimeConfig["filesystem"] & {
|
|
261
|
-
allowTempDirs?: boolean;
|
|
262
|
-
allowGitCommonDir?: boolean;
|
|
263
|
-
};
|
|
264
|
-
};
|
|
265
|
-
|
|
266
|
-
type SandboxEventKind = "filesystem" | "network" | "mach-lookup" | "init" | "runtime";
|
|
267
|
-
type SandboxEventReason =
|
|
268
|
-
| "explicit-deny-read"
|
|
269
|
-
| "explicit-deny-write"
|
|
270
|
-
| "explicit-deny-domain"
|
|
271
|
-
| "missing-allow-write"
|
|
272
|
-
| "missing-allowed-domain"
|
|
273
|
-
| "missing-mach-lookup"
|
|
274
|
-
| "missing-dependencies"
|
|
275
|
-
| "unsupported-platform"
|
|
276
|
-
| "init-failed"
|
|
277
|
-
| "runtime-protected-write"
|
|
278
|
-
| "already-approved-still-failed"
|
|
279
|
-
| "unknown";
|
|
280
|
-
type SandboxConfigPathStatus = "loaded" | "parse-error" | "skipped-untrusted";
|
|
281
|
-
type SandboxConfigPathLabel = "Global" | "Project" | "Override";
|
|
282
|
-
|
|
283
|
-
type PromptStatus = "completed" | "error";
|
|
284
|
-
type UiLevel = "info" | "warning" | "error";
|
|
285
|
-
|
|
286
|
-
type NetworkList = "allow" | "deny";
|
|
287
|
-
type FilesystemList = "deny-read" | "allow-write" | "deny-write";
|
|
288
|
-
|
|
289
|
-
type FilesystemViolationKind = "read" | "write" | "unknown";
|
|
290
|
-
type FilesystemReadAccess = "metadata" | "data" | "unknown";
|
|
291
|
-
type FilesystemWriteAccess = "unlink" | "unknown";
|
|
292
|
-
interface SandboxEvent {
|
|
293
|
-
timestamp: number;
|
|
294
|
-
kind: SandboxEventKind;
|
|
295
|
-
outcome: SandboxEventOutcome;
|
|
296
|
-
reason: SandboxEventReason;
|
|
297
|
-
target?: string;
|
|
298
|
-
command?: string;
|
|
299
|
-
cwd?: string;
|
|
300
|
-
summary: string;
|
|
301
|
-
suggestedCommand?: string;
|
|
302
|
-
}
|
|
303
|
-
|
|
304
|
-
interface SandboxConfigPath {
|
|
305
|
-
label: SandboxConfigPathLabel;
|
|
306
|
-
path: string;
|
|
307
|
-
status: SandboxConfigPathStatus;
|
|
308
|
-
}
|
|
309
|
-
|
|
310
|
-
interface LoadedSandboxConfig {
|
|
311
|
-
config: SandboxConfig;
|
|
312
|
-
paths: SandboxConfigPath[];
|
|
313
|
-
}
|
|
314
|
-
|
|
315
|
-
interface GitFilesystemPaths {
|
|
316
|
-
gitDir: string;
|
|
317
|
-
gitCommonDir: string;
|
|
318
|
-
}
|
|
319
|
-
|
|
320
|
-
type SandboxConfigLoadErrorKind = "not-found" | "read-failed" | "parse-error";
|
|
321
|
-
|
|
322
|
-
class SandboxConfigLoadError extends Error {
|
|
323
|
-
readonly kind: SandboxConfigLoadErrorKind;
|
|
324
|
-
readonly path: string;
|
|
325
|
-
|
|
326
|
-
constructor(kind: SandboxConfigLoadErrorKind, path: string, detail?: string) {
|
|
327
|
-
super(formatSandboxConfigLoadErrorMessage(kind, path, detail));
|
|
328
|
-
this.name = "SandboxConfigLoadError";
|
|
329
|
-
this.kind = kind;
|
|
330
|
-
this.path = path;
|
|
331
|
-
}
|
|
332
|
-
}
|
|
333
|
-
|
|
334
|
-
interface FilesystemViolation {
|
|
335
|
-
kind: FilesystemViolationKind;
|
|
336
|
-
path?: string;
|
|
337
|
-
processName?: string;
|
|
338
|
-
readAccess?: FilesystemReadAccess;
|
|
339
|
-
writeAccess?: FilesystemWriteAccess;
|
|
340
|
-
}
|
|
341
|
-
|
|
342
|
-
interface MachLookupViolation {
|
|
343
|
-
service: string;
|
|
344
|
-
}
|
|
345
|
-
|
|
346
|
-
// --- Helpers ---
|
|
347
|
-
|
|
348
|
-
async function withPromptSignal<T>(pi: ExtensionAPI, run: () => Promise<T>): Promise<T> {
|
|
349
|
-
pi.events.emit("ui:prompt_start", { source: "sandbox" });
|
|
350
|
-
|
|
351
|
-
let status: PromptStatus = "completed";
|
|
352
|
-
try {
|
|
353
|
-
return await run();
|
|
354
|
-
} catch (error) {
|
|
355
|
-
status = "error";
|
|
356
|
-
throw error;
|
|
357
|
-
} finally {
|
|
358
|
-
pi.events.emit("ui:prompt_end", { source: "sandbox", status });
|
|
359
|
-
}
|
|
360
|
-
}
|
|
361
|
-
|
|
362
|
-
function normalizePromptMode(value: unknown): PromptMode {
|
|
363
|
-
return value === "non-interactive" ? "non-interactive" : "interactive";
|
|
364
|
-
}
|
|
365
|
-
|
|
366
|
-
function setSandboxStatus(
|
|
367
|
-
ctx: ExtensionContext,
|
|
368
|
-
enabled: boolean,
|
|
369
|
-
runtimeConfig?: SandboxRuntimeConfig,
|
|
370
|
-
promptMode: PromptMode = DEFAULT_PROMPT_MODE,
|
|
371
|
-
): void {
|
|
372
|
-
if (!ctx.hasUI) return;
|
|
373
|
-
|
|
374
|
-
if (!enabled || !runtimeConfig) {
|
|
375
|
-
ctx.ui.setStatus(STATUS_KEY, undefined);
|
|
376
|
-
return;
|
|
377
|
-
}
|
|
378
|
-
|
|
379
|
-
const networkCount = runtimeConfig.network.allowedDomains.length;
|
|
380
|
-
const writeCount = runtimeConfig.filesystem.allowWrite.length;
|
|
381
|
-
const text = `sandbox (${promptMode}, ${networkCount} domains, ${writeCount} write paths)`;
|
|
382
|
-
ctx.ui.setStatus(STATUS_KEY, ctx.ui.theme.fg("dim", text));
|
|
383
|
-
}
|
|
384
|
-
|
|
385
|
-
function notify(ctx: ExtensionContext, text: string, level: UiLevel = "info"): void {
|
|
386
|
-
if (ctx.hasUI) {
|
|
387
|
-
ctx.ui.notify(text, level);
|
|
388
|
-
return;
|
|
389
|
-
}
|
|
390
|
-
|
|
391
|
-
if (level === "error" || level === "warning") console.error(text);
|
|
392
|
-
else console.log(text);
|
|
393
|
-
}
|
|
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
|
-
|
|
408
|
-
function showHelp(ctx: ExtensionContext): void {
|
|
409
|
-
const lines = [
|
|
410
|
-
"Usage:",
|
|
411
|
-
" /sandbox enable|on",
|
|
412
|
-
" /sandbox disable|off",
|
|
413
|
-
" /sandbox show",
|
|
414
|
-
" /sandbox doctor",
|
|
415
|
-
" /sandbox mode <interactive|non-interactive>",
|
|
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
|
-
: []),
|
|
423
|
-
" /sandbox filesystem <deny-read|allow-write|deny-write> <add|remove> <path>",
|
|
424
|
-
"",
|
|
425
|
-
"Startup flags:",
|
|
426
|
-
" --no-sandbox",
|
|
427
|
-
" --sandbox-config <path>",
|
|
428
|
-
];
|
|
429
|
-
notify(ctx, lines.join("\n"), "info");
|
|
430
|
-
}
|
|
431
|
-
|
|
432
|
-
function parseCommandArgs(args?: string): string[] {
|
|
433
|
-
if (!args?.trim()) return [];
|
|
434
|
-
|
|
435
|
-
const input = args.trim();
|
|
436
|
-
const tokens: string[] = [];
|
|
437
|
-
const tokenPattern = /"((?:\\.|[^"\\])*)"|'([^']*)'|((?:\\.|[^\s])+)/g;
|
|
438
|
-
|
|
439
|
-
for (const match of input.matchAll(tokenPattern)) {
|
|
440
|
-
if (match[1] !== undefined) {
|
|
441
|
-
tokens.push(match[1].replace(/\\(.)/g, "$1"));
|
|
442
|
-
} else if (match[2] !== undefined) {
|
|
443
|
-
tokens.push(match[2]);
|
|
444
|
-
} else if (match[3] !== undefined) {
|
|
445
|
-
tokens.push(match[3].replace(/\\(.)/g, "$1"));
|
|
446
|
-
}
|
|
447
|
-
}
|
|
448
|
-
|
|
449
|
-
return tokens;
|
|
450
|
-
}
|
|
451
|
-
|
|
452
|
-
function normalizeSubcommand(token?: string): string | undefined {
|
|
453
|
-
switch (token?.toLowerCase()) {
|
|
454
|
-
case "on":
|
|
455
|
-
return "enable";
|
|
456
|
-
case "off":
|
|
457
|
-
return "disable";
|
|
458
|
-
default:
|
|
459
|
-
return token?.toLowerCase();
|
|
460
|
-
}
|
|
461
|
-
}
|
|
462
|
-
|
|
463
|
-
type CommandCompletionOption = {
|
|
464
|
-
value: string;
|
|
465
|
-
label?: string;
|
|
466
|
-
description?: string;
|
|
467
|
-
};
|
|
468
|
-
|
|
469
|
-
const SANDBOX_TOP_LEVEL_COMPLETIONS: CommandCompletionOption[] = [
|
|
470
|
-
{ value: "enable", label: "enable" },
|
|
471
|
-
{ value: "on", label: "on" },
|
|
472
|
-
{ value: "disable", label: "disable" },
|
|
473
|
-
{ value: "off", label: "off" },
|
|
474
|
-
{ value: "show", label: "show" },
|
|
475
|
-
{ value: "doctor", label: "doctor" },
|
|
476
|
-
{ value: "mode ", label: "mode" },
|
|
477
|
-
{ value: "network ", label: "network" },
|
|
478
|
-
...(IS_MACOS ? [{ value: "mach-lookup ", label: "mach-lookup" }] : []),
|
|
479
|
-
{ value: "filesystem ", label: "filesystem" },
|
|
480
|
-
{ value: "help", label: "help" },
|
|
481
|
-
];
|
|
482
|
-
|
|
483
|
-
const SANDBOX_MODE_COMPLETIONS: CommandCompletionOption[] = [
|
|
484
|
-
{ value: "interactive", label: "interactive" },
|
|
485
|
-
{ value: "non-interactive", label: "non-interactive" },
|
|
486
|
-
];
|
|
487
|
-
|
|
488
|
-
const SANDBOX_NETWORK_LIST_COMPLETIONS: CommandCompletionOption[] = [
|
|
489
|
-
{ value: "allow ", label: "allow" },
|
|
490
|
-
{ value: "deny ", label: "deny" },
|
|
491
|
-
];
|
|
492
|
-
|
|
493
|
-
const SANDBOX_FILESYSTEM_LIST_COMPLETIONS: CommandCompletionOption[] = [
|
|
494
|
-
{ value: "deny-read ", label: "deny-read" },
|
|
495
|
-
{ value: "allow-write ", label: "allow-write" },
|
|
496
|
-
{ value: "deny-write ", label: "deny-write" },
|
|
497
|
-
];
|
|
498
|
-
|
|
499
|
-
const SANDBOX_LIST_OPERATION_COMPLETIONS: CommandCompletionOption[] = [
|
|
500
|
-
{ value: "add ", label: "add" },
|
|
501
|
-
{ value: "remove ", label: "remove" },
|
|
502
|
-
];
|
|
503
|
-
|
|
504
|
-
function normalizeCompletionFilter(value: string): string {
|
|
505
|
-
return value.trim().replace(/^['"]/, "").toLowerCase();
|
|
506
|
-
}
|
|
507
|
-
|
|
508
|
-
function getCommandCompletions(
|
|
509
|
-
base: string,
|
|
510
|
-
partial: string,
|
|
511
|
-
options: CommandCompletionOption[],
|
|
512
|
-
): Array<{ value: string; label: string; description?: string }> | null {
|
|
513
|
-
const normalizedPartial = normalizeCompletionFilter(partial);
|
|
514
|
-
const matches = options.filter((option) => {
|
|
515
|
-
const label = option.label ?? option.value.trimEnd();
|
|
516
|
-
return label.toLowerCase().startsWith(normalizedPartial);
|
|
517
|
-
});
|
|
518
|
-
if (matches.length === 0) return null;
|
|
519
|
-
|
|
520
|
-
return matches.map((option) => ({
|
|
521
|
-
value: `${base}${option.value}`,
|
|
522
|
-
label: option.label ?? option.value.trimEnd(),
|
|
523
|
-
...(option.description ? { description: option.description } : {}),
|
|
524
|
-
}));
|
|
525
|
-
}
|
|
526
|
-
|
|
527
|
-
function getStringValueCompletions(
|
|
528
|
-
base: string,
|
|
529
|
-
partial: string,
|
|
530
|
-
values: string[],
|
|
531
|
-
): Array<{ value: string; label: string }> | null {
|
|
532
|
-
const normalizedPartial = normalizeCompletionFilter(partial);
|
|
533
|
-
const matches = Array.from(new Set(values)).filter((value) =>
|
|
534
|
-
value.toLowerCase().startsWith(normalizedPartial),
|
|
535
|
-
);
|
|
536
|
-
if (matches.length === 0) return null;
|
|
537
|
-
|
|
538
|
-
return matches.map((value) => ({
|
|
539
|
-
value: `${base}${escapeSlashCommandArg(value)}`,
|
|
540
|
-
label: value,
|
|
541
|
-
}));
|
|
542
|
-
}
|
|
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
|
-
|
|
575
|
-
function getSandboxArgumentCompletions(
|
|
576
|
-
prefix: string,
|
|
577
|
-
runtimeConfig: SandboxRuntimeConfig | null,
|
|
578
|
-
): Array<{ value: string; label: string; description?: string }> | null {
|
|
579
|
-
const endsWithSpace = /\s$/.test(prefix);
|
|
580
|
-
const tokens = parseCommandArgs(prefix);
|
|
581
|
-
|
|
582
|
-
if (tokens.length === 0) {
|
|
583
|
-
return getCommandCompletions("", "", SANDBOX_TOP_LEVEL_COMPLETIONS);
|
|
584
|
-
}
|
|
585
|
-
|
|
586
|
-
if (tokens.length === 1 && !endsWithSpace) {
|
|
587
|
-
return getCommandCompletions("", tokens[0] ?? "", SANDBOX_TOP_LEVEL_COMPLETIONS);
|
|
588
|
-
}
|
|
589
|
-
|
|
590
|
-
const subcommand = normalizeSubcommand(tokens[0]);
|
|
591
|
-
if (!subcommand) return null;
|
|
592
|
-
|
|
593
|
-
if (subcommand === "mode") {
|
|
594
|
-
if (tokens.length === 1 && endsWithSpace) {
|
|
595
|
-
return getCommandCompletions("mode ", "", SANDBOX_MODE_COMPLETIONS);
|
|
596
|
-
}
|
|
597
|
-
if (tokens.length === 2 && !endsWithSpace) {
|
|
598
|
-
return getCommandCompletions("mode ", tokens[1] ?? "", SANDBOX_MODE_COMPLETIONS);
|
|
599
|
-
}
|
|
600
|
-
return null;
|
|
601
|
-
}
|
|
602
|
-
|
|
603
|
-
if (subcommand === "network") {
|
|
604
|
-
if (tokens.length === 1 && endsWithSpace) {
|
|
605
|
-
return getCommandCompletions("network ", "", SANDBOX_NETWORK_LIST_COMPLETIONS);
|
|
606
|
-
}
|
|
607
|
-
if (tokens.length === 2 && !endsWithSpace) {
|
|
608
|
-
return getCommandCompletions("network ", tokens[1] ?? "", SANDBOX_NETWORK_LIST_COMPLETIONS);
|
|
609
|
-
}
|
|
610
|
-
|
|
611
|
-
const list = tokens[1]?.toLowerCase();
|
|
612
|
-
if (list !== "allow" && list !== "deny") return null;
|
|
613
|
-
|
|
614
|
-
const listBase = `network ${list} `;
|
|
615
|
-
if (tokens.length === 2 && endsWithSpace) {
|
|
616
|
-
return getCommandCompletions(listBase, "", SANDBOX_LIST_OPERATION_COMPLETIONS);
|
|
617
|
-
}
|
|
618
|
-
if (tokens.length === 3 && !endsWithSpace) {
|
|
619
|
-
return getCommandCompletions(listBase, tokens[2] ?? "", SANDBOX_LIST_OPERATION_COMPLETIONS);
|
|
620
|
-
}
|
|
621
|
-
|
|
622
|
-
if (tokens[2]?.toLowerCase() !== "remove") return null;
|
|
623
|
-
|
|
624
|
-
const values =
|
|
625
|
-
list === "allow"
|
|
626
|
-
? (runtimeConfig?.network.allowedDomains ?? [])
|
|
627
|
-
: (runtimeConfig?.network.deniedDomains ?? []);
|
|
628
|
-
const valueBase = `${listBase}remove `;
|
|
629
|
-
if (tokens.length === 3 && endsWithSpace) {
|
|
630
|
-
return getStringValueCompletions(valueBase, "", values);
|
|
631
|
-
}
|
|
632
|
-
if (tokens.length === 4 && !endsWithSpace) {
|
|
633
|
-
return getStringValueCompletions(valueBase, tokens[3] ?? "", values);
|
|
634
|
-
}
|
|
635
|
-
return null;
|
|
636
|
-
}
|
|
637
|
-
|
|
638
|
-
if (subcommand === "mach-lookup" && IS_MACOS) {
|
|
639
|
-
return getMachLookupArgumentCompletions({ tokens, endsWithSpace, runtimeConfig });
|
|
640
|
-
}
|
|
641
|
-
|
|
642
|
-
if (subcommand === "filesystem") {
|
|
643
|
-
if (tokens.length === 1 && endsWithSpace) {
|
|
644
|
-
return getCommandCompletions("filesystem ", "", SANDBOX_FILESYSTEM_LIST_COMPLETIONS);
|
|
645
|
-
}
|
|
646
|
-
if (tokens.length === 2 && !endsWithSpace) {
|
|
647
|
-
return getCommandCompletions(
|
|
648
|
-
"filesystem ",
|
|
649
|
-
tokens[1] ?? "",
|
|
650
|
-
SANDBOX_FILESYSTEM_LIST_COMPLETIONS,
|
|
651
|
-
);
|
|
652
|
-
}
|
|
653
|
-
|
|
654
|
-
const list = tokens[1]?.toLowerCase() as FilesystemList | undefined;
|
|
655
|
-
if (list !== "deny-read" && list !== "allow-write" && list !== "deny-write") {
|
|
656
|
-
return null;
|
|
657
|
-
}
|
|
658
|
-
|
|
659
|
-
const listBase = `filesystem ${list} `;
|
|
660
|
-
if (tokens.length === 2 && endsWithSpace) {
|
|
661
|
-
return getCommandCompletions(listBase, "", SANDBOX_LIST_OPERATION_COMPLETIONS);
|
|
662
|
-
}
|
|
663
|
-
if (tokens.length === 3 && !endsWithSpace) {
|
|
664
|
-
return getCommandCompletions(listBase, tokens[2] ?? "", SANDBOX_LIST_OPERATION_COMPLETIONS);
|
|
665
|
-
}
|
|
666
|
-
|
|
667
|
-
if (tokens[2]?.toLowerCase() !== "remove") return null;
|
|
668
|
-
|
|
669
|
-
const values =
|
|
670
|
-
list === "deny-read"
|
|
671
|
-
? (runtimeConfig?.filesystem.denyRead ?? [])
|
|
672
|
-
: list === "allow-write"
|
|
673
|
-
? (runtimeConfig?.filesystem.allowWrite ?? [])
|
|
674
|
-
: (runtimeConfig?.filesystem.denyWrite ?? []);
|
|
675
|
-
const valueBase = `${listBase}remove `;
|
|
676
|
-
if (tokens.length === 3 && endsWithSpace) {
|
|
677
|
-
return getStringValueCompletions(valueBase, "", values);
|
|
678
|
-
}
|
|
679
|
-
if (tokens.length === 4 && !endsWithSpace) {
|
|
680
|
-
return getStringValueCompletions(valueBase, tokens[3] ?? "", values);
|
|
681
|
-
}
|
|
682
|
-
return null;
|
|
683
|
-
}
|
|
684
|
-
|
|
685
|
-
return null;
|
|
686
|
-
}
|
|
687
|
-
|
|
688
|
-
function getStringFlag(pi: ExtensionAPI, name: string): string | undefined {
|
|
689
|
-
const value = pi.getFlag(name);
|
|
690
|
-
if (typeof value !== "string") return undefined;
|
|
691
|
-
|
|
692
|
-
const trimmed = value.trim();
|
|
693
|
-
return trimmed.length > 0 ? trimmed : undefined;
|
|
694
|
-
}
|
|
695
|
-
|
|
696
|
-
function expandPath(value: string, cwd?: string): string {
|
|
697
|
-
const expanded =
|
|
698
|
-
value === "~" ? homedir() : value.startsWith("~/") ? join(homedir(), value.slice(2)) : value;
|
|
699
|
-
return cwd && !expanded.startsWith("/") ? resolve(cwd, expanded) : expanded;
|
|
700
|
-
}
|
|
701
|
-
|
|
702
|
-
function formatSandboxConfigLoadErrorMessage(
|
|
703
|
-
kind: SandboxConfigLoadErrorKind,
|
|
704
|
-
path: string,
|
|
705
|
-
detail?: string,
|
|
706
|
-
): string {
|
|
707
|
-
if (kind === "not-found") {
|
|
708
|
-
return `Sandbox override config not found: ${path}`;
|
|
709
|
-
}
|
|
710
|
-
|
|
711
|
-
if (kind === "parse-error") {
|
|
712
|
-
return `Could not parse sandbox override config ${path}: ${detail ?? "invalid JSON"}`;
|
|
713
|
-
}
|
|
714
|
-
|
|
715
|
-
return `Could not read sandbox override config ${path}: ${detail ?? "unknown error"}`;
|
|
716
|
-
}
|
|
717
|
-
|
|
718
|
-
function cleanStringArray(value: unknown[], field: string): string[] {
|
|
719
|
-
const cleaned = value
|
|
720
|
-
.filter((item): item is string => typeof item === "string")
|
|
721
|
-
.map((item) => item.trim())
|
|
722
|
-
.filter((item) => item.length > 0);
|
|
723
|
-
|
|
724
|
-
const droppedCount = value.length - cleaned.length;
|
|
725
|
-
if (droppedCount > 0) {
|
|
726
|
-
console.error(`Warning: Ignoring ${droppedCount} invalid values in ${field}.`);
|
|
727
|
-
}
|
|
728
|
-
|
|
729
|
-
return cleaned;
|
|
730
|
-
}
|
|
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
|
-
|
|
756
|
-
function finalizeConfig(config: SandboxConfig): SandboxConfig {
|
|
757
|
-
return {
|
|
758
|
-
...config,
|
|
759
|
-
enabled: typeof config.enabled === "boolean" ? config.enabled : DEFAULT_CONFIG.enabled,
|
|
760
|
-
mode: normalizePromptMode(config.mode),
|
|
761
|
-
network: {
|
|
762
|
-
...config.network,
|
|
763
|
-
allowedDomains: coerceStringArray(
|
|
764
|
-
config.network?.allowedDomains,
|
|
765
|
-
DEFAULT_CONFIG.network.allowedDomains,
|
|
766
|
-
"network.allowedDomains",
|
|
767
|
-
),
|
|
768
|
-
deniedDomains: coerceStringArray(
|
|
769
|
-
config.network?.deniedDomains,
|
|
770
|
-
DEFAULT_CONFIG.network.deniedDomains,
|
|
771
|
-
"network.deniedDomains",
|
|
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
|
-
),
|
|
783
|
-
},
|
|
784
|
-
filesystem: {
|
|
785
|
-
...config.filesystem,
|
|
786
|
-
denyRead: coerceStringArray(
|
|
787
|
-
config.filesystem?.denyRead,
|
|
788
|
-
DEFAULT_CONFIG.filesystem.denyRead,
|
|
789
|
-
"filesystem.denyRead",
|
|
790
|
-
),
|
|
791
|
-
allowRead: coerceOptionalStringArray(
|
|
792
|
-
config.filesystem?.allowRead,
|
|
793
|
-
DEFAULT_CONFIG.filesystem.allowRead,
|
|
794
|
-
"filesystem.allowRead",
|
|
795
|
-
),
|
|
796
|
-
allowWrite: coerceStringArray(
|
|
797
|
-
config.filesystem?.allowWrite,
|
|
798
|
-
DEFAULT_CONFIG.filesystem.allowWrite,
|
|
799
|
-
"filesystem.allowWrite",
|
|
800
|
-
),
|
|
801
|
-
denyWrite: coerceStringArray(
|
|
802
|
-
config.filesystem?.denyWrite,
|
|
803
|
-
DEFAULT_CONFIG.filesystem.denyWrite,
|
|
804
|
-
"filesystem.denyWrite",
|
|
805
|
-
),
|
|
806
|
-
allowTempDirs:
|
|
807
|
-
typeof config.filesystem?.allowTempDirs === "boolean"
|
|
808
|
-
? config.filesystem.allowTempDirs
|
|
809
|
-
: DEFAULT_CONFIG.filesystem.allowTempDirs,
|
|
810
|
-
allowGitCommonDir:
|
|
811
|
-
typeof config.filesystem?.allowGitCommonDir === "boolean"
|
|
812
|
-
? config.filesystem.allowGitCommonDir
|
|
813
|
-
: DEFAULT_CONFIG.filesystem.allowGitCommonDir,
|
|
814
|
-
},
|
|
815
|
-
};
|
|
816
|
-
}
|
|
817
|
-
|
|
818
|
-
function loadOverrideConfig(cwd: string, overrideConfigPath: string): LoadedSandboxConfig {
|
|
819
|
-
const resolvedPath = expandPath(overrideConfigPath, cwd);
|
|
820
|
-
|
|
821
|
-
let source: string;
|
|
822
|
-
try {
|
|
823
|
-
source = readFileSync(resolvedPath, "utf-8");
|
|
824
|
-
} catch (error) {
|
|
825
|
-
const code =
|
|
826
|
-
error instanceof Error && "code" in error && typeof error.code === "string"
|
|
827
|
-
? error.code
|
|
828
|
-
: undefined;
|
|
829
|
-
if (code === "ENOENT") {
|
|
830
|
-
throw new SandboxConfigLoadError("not-found", resolvedPath);
|
|
831
|
-
}
|
|
832
|
-
|
|
833
|
-
throw new SandboxConfigLoadError(
|
|
834
|
-
"read-failed",
|
|
835
|
-
resolvedPath,
|
|
836
|
-
error instanceof Error ? error.message : `${error}`,
|
|
837
|
-
);
|
|
838
|
-
}
|
|
839
|
-
|
|
840
|
-
let overrideConfig: Partial<SandboxConfig>;
|
|
841
|
-
try {
|
|
842
|
-
overrideConfig = JSON.parse(source);
|
|
843
|
-
} catch (error) {
|
|
844
|
-
throw new SandboxConfigLoadError(
|
|
845
|
-
"parse-error",
|
|
846
|
-
resolvedPath,
|
|
847
|
-
error instanceof Error ? error.message : `${error}`,
|
|
848
|
-
);
|
|
849
|
-
}
|
|
850
|
-
|
|
851
|
-
return {
|
|
852
|
-
config: finalizeConfig(deepMerge(DEFAULT_CONFIG, overrideConfig)),
|
|
853
|
-
paths: [{ label: "Override", path: resolvedPath, status: "loaded" }],
|
|
854
|
-
};
|
|
855
|
-
}
|
|
856
|
-
|
|
857
|
-
function loadConfig(
|
|
858
|
-
cwd: string,
|
|
859
|
-
overrideConfigPath?: string,
|
|
860
|
-
options: { projectTrusted?: boolean } = {},
|
|
861
|
-
): LoadedSandboxConfig {
|
|
862
|
-
if (overrideConfigPath) {
|
|
863
|
-
return loadOverrideConfig(cwd, overrideConfigPath);
|
|
864
|
-
}
|
|
865
|
-
|
|
866
|
-
const projectConfigPath = join(cwd, CONFIG_DIR_NAME, "sandbox.json");
|
|
867
|
-
const globalConfigPath = join(getAgentDir(), "sandbox.json");
|
|
868
|
-
|
|
869
|
-
let globalConfig: Partial<SandboxConfig> = {};
|
|
870
|
-
let projectConfig: Partial<SandboxConfig> = {};
|
|
871
|
-
const paths: SandboxConfigPath[] = [];
|
|
872
|
-
|
|
873
|
-
if (existsSync(globalConfigPath)) {
|
|
874
|
-
try {
|
|
875
|
-
globalConfig = JSON.parse(readFileSync(globalConfigPath, "utf-8"));
|
|
876
|
-
paths.push({ label: "Global", path: globalConfigPath, status: "loaded" });
|
|
877
|
-
} catch (e) {
|
|
878
|
-
paths.push({ label: "Global", path: globalConfigPath, status: "parse-error" });
|
|
879
|
-
console.error(`Warning: Could not parse ${globalConfigPath}: ${e}`);
|
|
880
|
-
}
|
|
881
|
-
}
|
|
882
|
-
|
|
883
|
-
if (existsSync(projectConfigPath)) {
|
|
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
|
-
}
|
|
894
|
-
}
|
|
895
|
-
}
|
|
896
|
-
|
|
897
|
-
const merged = deepMerge(deepMerge(DEFAULT_CONFIG, globalConfig), projectConfig);
|
|
898
|
-
|
|
899
|
-
return {
|
|
900
|
-
config: finalizeConfig(merged),
|
|
901
|
-
paths,
|
|
902
|
-
};
|
|
903
|
-
}
|
|
904
|
-
|
|
905
|
-
function isPlainObject(value: unknown): value is Record<string, unknown> {
|
|
906
|
-
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
907
|
-
}
|
|
908
|
-
|
|
909
|
-
function deepMerge(base: SandboxConfig, overrides: Partial<SandboxConfig>): SandboxConfig {
|
|
910
|
-
const result: SandboxConfig = { ...base };
|
|
911
|
-
|
|
912
|
-
if (overrides.enabled !== undefined) result.enabled = overrides.enabled;
|
|
913
|
-
if (overrides.mode !== undefined) {
|
|
914
|
-
result.mode = normalizePromptMode(overrides.mode);
|
|
915
|
-
}
|
|
916
|
-
if (isPlainObject(overrides.network)) {
|
|
917
|
-
result.network = {
|
|
918
|
-
...base.network,
|
|
919
|
-
...(overrides.network as Partial<SandboxRuntimeConfig["network"]>),
|
|
920
|
-
};
|
|
921
|
-
}
|
|
922
|
-
if (isPlainObject(overrides.filesystem)) {
|
|
923
|
-
result.filesystem = {
|
|
924
|
-
...base.filesystem,
|
|
925
|
-
...(overrides.filesystem as Partial<SandboxConfig["filesystem"]>),
|
|
926
|
-
};
|
|
927
|
-
}
|
|
928
|
-
if (overrides.ignoreViolations !== undefined) {
|
|
929
|
-
result.ignoreViolations = overrides.ignoreViolations;
|
|
930
|
-
}
|
|
931
|
-
if (overrides.enableWeakerNestedSandbox !== undefined) {
|
|
932
|
-
result.enableWeakerNestedSandbox = overrides.enableWeakerNestedSandbox;
|
|
933
|
-
}
|
|
934
|
-
if (overrides.enableWeakerNetworkIsolation !== undefined) {
|
|
935
|
-
result.enableWeakerNetworkIsolation = overrides.enableWeakerNetworkIsolation;
|
|
936
|
-
}
|
|
937
|
-
|
|
938
|
-
return result;
|
|
939
|
-
}
|
|
940
|
-
|
|
941
|
-
let cachedTemporaryWritePaths: string[] | undefined;
|
|
942
|
-
|
|
943
|
-
function getTemporaryWritePaths(): string[] {
|
|
944
|
-
if (cachedTemporaryWritePaths) return cachedTemporaryWritePaths;
|
|
945
|
-
|
|
946
|
-
// allowTempDirs always includes the conventional shared temp path, even when
|
|
947
|
-
// os.tmpdir() points at a platform-specific per-user directory.
|
|
948
|
-
const currentTmpDir = tmpdir();
|
|
949
|
-
const paths = ["/tmp", currentTmpDir, normalizePathForSandbox(currentTmpDir)];
|
|
950
|
-
if (process.platform === "darwin") paths.push("/private/tmp");
|
|
951
|
-
|
|
952
|
-
cachedTemporaryWritePaths = Array.from(
|
|
953
|
-
new Set(paths.map((path) => path.replace(/\/+$/, "") || "/")),
|
|
954
|
-
);
|
|
955
|
-
return cachedTemporaryWritePaths;
|
|
956
|
-
}
|
|
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
|
-
|
|
1060
|
-
function toRuntimeConfig(config: SandboxConfig): SandboxRuntimeConfig {
|
|
1061
|
-
const { allowGitCommonDir: _allowGitCommonDir, allowTempDirs, ...filesystem } = config.filesystem;
|
|
1062
|
-
const expandedNetwork = expandNetworkPathConfig(config.network);
|
|
1063
|
-
const expandedFilesystem = expandFilesystemPathConfig(filesystem);
|
|
1064
|
-
const allowWrite = allowTempDirs
|
|
1065
|
-
? [...expandedFilesystem.allowWrite, ...getTemporaryWritePaths()]
|
|
1066
|
-
: expandedFilesystem.allowWrite;
|
|
1067
|
-
|
|
1068
|
-
return {
|
|
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
|
-
},
|
|
1080
|
-
ignoreViolations: config.ignoreViolations,
|
|
1081
|
-
enableWeakerNestedSandbox: config.enableWeakerNestedSandbox,
|
|
1082
|
-
enableWeakerNetworkIsolation: config.enableWeakerNetworkIsolation,
|
|
1083
|
-
};
|
|
1084
|
-
}
|
|
1085
|
-
|
|
1086
|
-
function cloneRuntimeConfig(config: SandboxRuntimeConfig): SandboxRuntimeConfig {
|
|
1087
|
-
return structuredClone(config);
|
|
1088
|
-
}
|
|
1089
|
-
|
|
1090
|
-
function normalizeSandboxPath(value: string, cwd?: string): string {
|
|
1091
|
-
return normalizePathForSandbox(expandPath(value, cwd));
|
|
1092
|
-
}
|
|
1093
|
-
|
|
1094
|
-
function matchesSandboxRule(path: string, rule: string, cwd?: string): boolean {
|
|
1095
|
-
const normalizedPath = normalizeSandboxPath(path);
|
|
1096
|
-
const normalizedRule = normalizeSandboxPath(rule, cwd);
|
|
1097
|
-
|
|
1098
|
-
if (containsGlobChars(rule)) {
|
|
1099
|
-
return new RegExp(globToRegex(normalizedRule)).test(normalizedPath);
|
|
1100
|
-
}
|
|
1101
|
-
|
|
1102
|
-
if (normalizedPath === normalizedRule) return true;
|
|
1103
|
-
|
|
1104
|
-
const prefix = normalizedRule.endsWith("/") ? normalizedRule : `${normalizedRule}/`;
|
|
1105
|
-
return normalizedPath.startsWith(prefix);
|
|
1106
|
-
}
|
|
1107
|
-
|
|
1108
|
-
function inferSandboxRuleMatch(path: string, rules: string[], cwd?: string): string | null {
|
|
1109
|
-
for (const rule of rules) {
|
|
1110
|
-
if (matchesSandboxRule(path, rule, cwd)) return rule;
|
|
1111
|
-
}
|
|
1112
|
-
|
|
1113
|
-
return null;
|
|
1114
|
-
}
|
|
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
|
-
|
|
1129
|
-
function isSandboxWritablePath(
|
|
1130
|
-
runtimeConfig: SandboxRuntimeConfig,
|
|
1131
|
-
path: string,
|
|
1132
|
-
cwd?: string,
|
|
1133
|
-
): boolean {
|
|
1134
|
-
if (!inferSandboxRuleMatch(path, runtimeConfig.filesystem.allowWrite, cwd)) return false;
|
|
1135
|
-
return inferSandboxRuleMatch(path, runtimeConfig.filesystem.denyWrite, cwd) === null;
|
|
1136
|
-
}
|
|
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
|
-
|
|
1174
|
-
function resolveGitFilesystemPaths(cwd: string): GitFilesystemPaths | null {
|
|
1175
|
-
if (GIT_FILESYSTEM_PATHS_CACHE.has(cwd)) {
|
|
1176
|
-
return GIT_FILESYSTEM_PATHS_CACHE.get(cwd) ?? null;
|
|
1177
|
-
}
|
|
1178
|
-
|
|
1179
|
-
const result = spawnSync(
|
|
1180
|
-
"git",
|
|
1181
|
-
["rev-parse", "--path-format=absolute", "--git-dir", "--git-common-dir"],
|
|
1182
|
-
{
|
|
1183
|
-
cwd,
|
|
1184
|
-
encoding: "utf-8",
|
|
1185
|
-
stdio: ["ignore", "pipe", "ignore"],
|
|
1186
|
-
},
|
|
1187
|
-
);
|
|
1188
|
-
|
|
1189
|
-
if (result.status !== 0) {
|
|
1190
|
-
GIT_FILESYSTEM_PATHS_CACHE.set(cwd, null);
|
|
1191
|
-
return null;
|
|
1192
|
-
}
|
|
1193
|
-
|
|
1194
|
-
const [gitDir, gitCommonDir] = result.stdout
|
|
1195
|
-
.split("\n")
|
|
1196
|
-
.map((line) => line.trim())
|
|
1197
|
-
.filter(Boolean);
|
|
1198
|
-
|
|
1199
|
-
if (!gitDir || !gitCommonDir) {
|
|
1200
|
-
GIT_FILESYSTEM_PATHS_CACHE.set(cwd, null);
|
|
1201
|
-
return null;
|
|
1202
|
-
}
|
|
1203
|
-
|
|
1204
|
-
const gitPaths = { gitDir, gitCommonDir };
|
|
1205
|
-
GIT_FILESYSTEM_PATHS_CACHE.set(cwd, gitPaths);
|
|
1206
|
-
return gitPaths;
|
|
1207
|
-
}
|
|
1208
|
-
|
|
1209
|
-
function maybeAddGitMetadataWritePath(
|
|
1210
|
-
runtimeConfig: SandboxRuntimeConfig,
|
|
1211
|
-
path: string,
|
|
1212
|
-
cwd?: string,
|
|
1213
|
-
): SandboxRuntimeConfig | null {
|
|
1214
|
-
if (isSandboxWritablePath(runtimeConfig, path, cwd)) return null;
|
|
1215
|
-
if (inferSandboxRuleMatch(path, runtimeConfig.filesystem.denyWrite, cwd)) return null;
|
|
1216
|
-
|
|
1217
|
-
const nextConfig = cloneRuntimeConfig(runtimeConfig);
|
|
1218
|
-
if (!mutateStringList(nextConfig.filesystem.allowWrite, "add", path)) return null;
|
|
1219
|
-
return nextConfig;
|
|
1220
|
-
}
|
|
1221
|
-
|
|
1222
|
-
function extractSandboxViolationLines(output: string): string[] {
|
|
1223
|
-
// sandbox-runtime annotateStderrWithSandboxFailures wraps violations in this tag.
|
|
1224
|
-
const match = output.match(/<sandbox_violations>([\s\S]*?)<\/sandbox_violations>/i);
|
|
1225
|
-
if (!match?.[1]) return [];
|
|
1226
|
-
|
|
1227
|
-
return match[1]
|
|
1228
|
-
.split("\n")
|
|
1229
|
-
.map((line) => line.trim())
|
|
1230
|
-
.filter(Boolean);
|
|
1231
|
-
}
|
|
1232
|
-
|
|
1233
|
-
function stripSandboxViolationAnnotations(text: string): string {
|
|
1234
|
-
return text
|
|
1235
|
-
.replace(/\n?<sandbox_violations>[\s\S]*?<\/sandbox_violations>\n?/gi, "\n")
|
|
1236
|
-
.replace(/^\n+|\n+$/g, "");
|
|
1237
|
-
}
|
|
1238
|
-
|
|
1239
|
-
function extractAppendedSandboxAnnotation(
|
|
1240
|
-
original: string,
|
|
1241
|
-
annotated: string,
|
|
1242
|
-
skipViolationLines = 0,
|
|
1243
|
-
): string {
|
|
1244
|
-
if (annotated === original) return "";
|
|
1245
|
-
|
|
1246
|
-
if (annotated.startsWith(original)) {
|
|
1247
|
-
return stripSandboxViolationAnnotations(annotated.slice(original.length));
|
|
1248
|
-
}
|
|
1249
|
-
|
|
1250
|
-
const violationLines = extractSandboxViolationLines(annotated);
|
|
1251
|
-
const newViolationLines =
|
|
1252
|
-
skipViolationLines > 0
|
|
1253
|
-
? violationLines.slice(Math.min(skipViolationLines, violationLines.length))
|
|
1254
|
-
: violationLines;
|
|
1255
|
-
if (newViolationLines.length === 0) return "";
|
|
1256
|
-
|
|
1257
|
-
// Sandbox violations are summarized elsewhere via compact extension messages,
|
|
1258
|
-
// so suppress the verbose synthetic annotation block.
|
|
1259
|
-
return "";
|
|
1260
|
-
}
|
|
1261
|
-
|
|
1262
|
-
function sanitizeExtractedPath(path: string): string | undefined {
|
|
1263
|
-
const trimmed = path.trim();
|
|
1264
|
-
if (!trimmed) return undefined;
|
|
1265
|
-
|
|
1266
|
-
const withoutDelimiter = trimmed.replace(/:+$/g, "");
|
|
1267
|
-
return withoutDelimiter.length > 0 ? withoutDelimiter : undefined;
|
|
1268
|
-
}
|
|
1269
|
-
|
|
1270
|
-
function extractPathLikeValueFromLine(line: string): string | undefined {
|
|
1271
|
-
const sandboxViolationMatch = line.match(/\bfile-(?:read|write)[^\s]*\s+((?:~\/|\/).+)$/i);
|
|
1272
|
-
if (sandboxViolationMatch?.[1]) return sanitizeExtractedPath(sandboxViolationMatch[1]);
|
|
1273
|
-
|
|
1274
|
-
const operationNotPermittedMatch = line.match(
|
|
1275
|
-
/^(?:[^:\n]+:\s+)*((?:~\/|\/).+?):\s+Operation not permitted$/i,
|
|
1276
|
-
);
|
|
1277
|
-
if (operationNotPermittedMatch?.[1]) return sanitizeExtractedPath(operationNotPermittedMatch[1]);
|
|
1278
|
-
|
|
1279
|
-
const quotedPathMatch = line.match(/["']((?:~\/|\/)[^"']+)["']/);
|
|
1280
|
-
if (quotedPathMatch?.[1]) return sanitizeExtractedPath(quotedPathMatch[1]);
|
|
1281
|
-
|
|
1282
|
-
const rawPathMatch = line.match(/((?:~\/|\/)[^\s,)]+)/);
|
|
1283
|
-
if (rawPathMatch?.[1]) return sanitizeExtractedPath(rawPathMatch[1]);
|
|
1284
|
-
|
|
1285
|
-
return undefined;
|
|
1286
|
-
}
|
|
1287
|
-
|
|
1288
|
-
function extractPathLikeValue(text: string): string | undefined {
|
|
1289
|
-
const lines = text
|
|
1290
|
-
.split("\n")
|
|
1291
|
-
.map((line) => line.trim())
|
|
1292
|
-
.filter(Boolean);
|
|
1293
|
-
|
|
1294
|
-
for (let index = lines.length - 1; index >= 0; index -= 1) {
|
|
1295
|
-
const path = extractPathLikeValueFromLine(lines[index]);
|
|
1296
|
-
if (path) return path;
|
|
1297
|
-
}
|
|
1298
|
-
|
|
1299
|
-
return undefined;
|
|
1300
|
-
}
|
|
1301
|
-
|
|
1302
|
-
function extractViolationProcessName(line: string): string | undefined {
|
|
1303
|
-
const match = line.match(/^([^\s(]+)\(/);
|
|
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
|
-
);
|
|
1358
|
-
}
|
|
1359
|
-
|
|
1360
|
-
function detectFilesystemViolationFromLine(line: string): FilesystemViolation | null {
|
|
1361
|
-
// Runtime emits concrete op variants (e.g. file-write-create/unlink, file-read-data).
|
|
1362
|
-
const lower = line.toLowerCase();
|
|
1363
|
-
const path = extractPathLikeValue(line);
|
|
1364
|
-
const processName = extractViolationProcessName(line);
|
|
1365
|
-
|
|
1366
|
-
if (lower.includes("file-write-unlink")) {
|
|
1367
|
-
return { kind: "write", path, processName, writeAccess: "unlink" };
|
|
1368
|
-
}
|
|
1369
|
-
|
|
1370
|
-
if (lower.includes("file-write")) {
|
|
1371
|
-
return { kind: "write", path, processName, writeAccess: "unknown" };
|
|
1372
|
-
}
|
|
1373
|
-
|
|
1374
|
-
if (lower.includes("file-read-metadata")) {
|
|
1375
|
-
return { kind: "read", path, processName, readAccess: "metadata" };
|
|
1376
|
-
}
|
|
1377
|
-
|
|
1378
|
-
if (lower.includes("file-read-data")) {
|
|
1379
|
-
return { kind: "read", path, processName, readAccess: "data" };
|
|
1380
|
-
}
|
|
1381
|
-
|
|
1382
|
-
if (lower.includes("file-read")) {
|
|
1383
|
-
return { kind: "read", path, processName, readAccess: "unknown" };
|
|
1384
|
-
}
|
|
1385
|
-
|
|
1386
|
-
return null;
|
|
1387
|
-
}
|
|
1388
|
-
|
|
1389
|
-
function detectFilesystemViolations(
|
|
1390
|
-
output: string,
|
|
1391
|
-
fallbackOutput: string = output,
|
|
1392
|
-
skipViolationLines = 0,
|
|
1393
|
-
allowOutputFallback = true,
|
|
1394
|
-
): FilesystemViolation[] {
|
|
1395
|
-
const violations: FilesystemViolation[] = [];
|
|
1396
|
-
const allViolationLines = extractSandboxViolationLines(output);
|
|
1397
|
-
const violationLines =
|
|
1398
|
-
skipViolationLines > 0
|
|
1399
|
-
? allViolationLines.slice(Math.min(skipViolationLines, allViolationLines.length))
|
|
1400
|
-
: allViolationLines;
|
|
1401
|
-
|
|
1402
|
-
for (let index = violationLines.length - 1; index >= 0; index -= 1) {
|
|
1403
|
-
const violation = detectFilesystemViolationFromLine(violationLines[index]);
|
|
1404
|
-
if (violation) violations.push(violation);
|
|
1405
|
-
}
|
|
1406
|
-
|
|
1407
|
-
if (violations.length > 0 || !allowOutputFallback) return violations;
|
|
1408
|
-
|
|
1409
|
-
const hasEperm = /\bEPERM\b/i.test(fallbackOutput);
|
|
1410
|
-
const hasOperationNotPermitted = /(?:^|\n)[^\n]*Operation not permitted(?:$|\n)/i.test(
|
|
1411
|
-
fallbackOutput,
|
|
1412
|
-
);
|
|
1413
|
-
if (hasEperm || hasOperationNotPermitted) {
|
|
1414
|
-
const path = extractPathLikeValue(fallbackOutput);
|
|
1415
|
-
if (path) violations.push({ kind: "unknown", path });
|
|
1416
|
-
}
|
|
1417
|
-
|
|
1418
|
-
return violations;
|
|
1419
|
-
}
|
|
1420
|
-
|
|
1421
|
-
function isTraversalViolation(
|
|
1422
|
-
runtimeConfig: SandboxRuntimeConfig | null,
|
|
1423
|
-
violation: FilesystemViolation,
|
|
1424
|
-
cwd?: string,
|
|
1425
|
-
): boolean {
|
|
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
|
-
);
|
|
1442
|
-
}
|
|
1443
|
-
|
|
1444
|
-
function getTraversalPaths(options: {
|
|
1445
|
-
runtimeConfig: SandboxRuntimeConfig | null;
|
|
1446
|
-
output: string;
|
|
1447
|
-
cwd?: string;
|
|
1448
|
-
skipViolationLines?: number;
|
|
1449
|
-
}): string[] | null {
|
|
1450
|
-
const { runtimeConfig, output, cwd, skipViolationLines = 0 } = options;
|
|
1451
|
-
if (!runtimeConfig) return null;
|
|
1452
|
-
|
|
1453
|
-
const allViolationLines = extractSandboxViolationLines(output);
|
|
1454
|
-
const violationLines =
|
|
1455
|
-
skipViolationLines > 0
|
|
1456
|
-
? allViolationLines.slice(Math.min(skipViolationLines, allViolationLines.length))
|
|
1457
|
-
: allViolationLines;
|
|
1458
|
-
if (violationLines.length === 0) return null;
|
|
1459
|
-
|
|
1460
|
-
const skippedPaths: string[] = [];
|
|
1461
|
-
for (const line of violationLines) {
|
|
1462
|
-
const violation = detectFilesystemViolationFromLine(line);
|
|
1463
|
-
if (!violation || !isTraversalViolation(runtimeConfig, violation, cwd)) {
|
|
1464
|
-
return null;
|
|
1465
|
-
}
|
|
1466
|
-
if (violation.path && !skippedPaths.includes(violation.path)) {
|
|
1467
|
-
skippedPaths.push(violation.path);
|
|
1468
|
-
}
|
|
1469
|
-
}
|
|
1470
|
-
|
|
1471
|
-
return skippedPaths.length > 0 ? skippedPaths : null;
|
|
1472
|
-
}
|
|
1473
|
-
|
|
1474
|
-
function formatTraversalNotice(paths: string[]): string {
|
|
1475
|
-
if (paths.length === 0) return "";
|
|
1476
|
-
|
|
1477
|
-
const visiblePaths = paths.slice(0, 3).join(", ");
|
|
1478
|
-
const suffix = paths.length > 3 ? ", ..." : "";
|
|
1479
|
-
const label = paths.length === 1 ? "path" : "paths";
|
|
1480
|
-
return `[sandbox] Continued after skipping protected ${label}: ${visiblePaths}${suffix}`;
|
|
1481
|
-
}
|
|
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
|
-
|
|
1499
|
-
function escapeSlashCommandArg(value: string): string {
|
|
1500
|
-
if (/^[a-zA-Z0-9_./:@%+\-~]+$/.test(value)) return value;
|
|
1501
|
-
return JSON.stringify(value);
|
|
1502
|
-
}
|
|
1503
|
-
|
|
1504
|
-
interface FilesystemAllowAction {
|
|
1505
|
-
list: FilesystemList;
|
|
1506
|
-
op: ListOp;
|
|
1507
|
-
value: string;
|
|
1508
|
-
}
|
|
1509
|
-
|
|
1510
|
-
function buildFilesystemAllowAction(
|
|
1511
|
-
runtimeConfig: SandboxRuntimeConfig,
|
|
1512
|
-
violation: FilesystemViolation,
|
|
1513
|
-
cwd?: string,
|
|
1514
|
-
): FilesystemAllowAction | null {
|
|
1515
|
-
if (!violation.path) return null;
|
|
1516
|
-
|
|
1517
|
-
if (violation.kind === "read") {
|
|
1518
|
-
const matchedRule = inferSandboxRuleMatch(
|
|
1519
|
-
violation.path,
|
|
1520
|
-
runtimeConfig.filesystem.denyRead,
|
|
1521
|
-
cwd,
|
|
1522
|
-
);
|
|
1523
|
-
return { list: "deny-read", op: "remove", value: matchedRule ?? violation.path };
|
|
1524
|
-
}
|
|
1525
|
-
|
|
1526
|
-
if (violation.kind === "write") {
|
|
1527
|
-
const matchedDeny = inferSandboxRuleMatch(
|
|
1528
|
-
violation.path,
|
|
1529
|
-
runtimeConfig.filesystem.denyWrite,
|
|
1530
|
-
cwd,
|
|
1531
|
-
);
|
|
1532
|
-
if (matchedDeny) {
|
|
1533
|
-
return { list: "deny-write", op: "remove", value: matchedDeny };
|
|
1534
|
-
}
|
|
1535
|
-
|
|
1536
|
-
return { list: "allow-write", op: "add", value: violation.path };
|
|
1537
|
-
}
|
|
1538
|
-
|
|
1539
|
-
const matchedDenyWrite = inferSandboxRuleMatch(
|
|
1540
|
-
violation.path,
|
|
1541
|
-
runtimeConfig.filesystem.denyWrite,
|
|
1542
|
-
cwd,
|
|
1543
|
-
);
|
|
1544
|
-
if (matchedDenyWrite) {
|
|
1545
|
-
return { list: "deny-write", op: "remove", value: matchedDenyWrite };
|
|
1546
|
-
}
|
|
1547
|
-
|
|
1548
|
-
const matchedDenyRead = inferSandboxRuleMatch(
|
|
1549
|
-
violation.path,
|
|
1550
|
-
runtimeConfig.filesystem.denyRead,
|
|
1551
|
-
cwd,
|
|
1552
|
-
);
|
|
1553
|
-
if (matchedDenyRead) {
|
|
1554
|
-
return { list: "deny-read", op: "remove", value: matchedDenyRead };
|
|
1555
|
-
}
|
|
1556
|
-
|
|
1557
|
-
return { list: "allow-write", op: "add", value: violation.path };
|
|
1558
|
-
}
|
|
1559
|
-
|
|
1560
|
-
function buildFilesystemAllowCommand(action: FilesystemAllowAction): string {
|
|
1561
|
-
return `/sandbox filesystem ${action.list} ${action.op} ${escapeSlashCommandArg(action.value)}`;
|
|
1562
|
-
}
|
|
1563
|
-
|
|
1564
|
-
function getFilesystemListValues(
|
|
1565
|
-
runtimeConfig: SandboxRuntimeConfig,
|
|
1566
|
-
list: FilesystemList,
|
|
1567
|
-
): string[] {
|
|
1568
|
-
if (list === "deny-read") return runtimeConfig.filesystem.denyRead;
|
|
1569
|
-
if (list === "allow-write") return runtimeConfig.filesystem.allowWrite;
|
|
1570
|
-
return runtimeConfig.filesystem.denyWrite;
|
|
1571
|
-
}
|
|
1572
|
-
|
|
1573
|
-
function applyFilesystemAllowAction(
|
|
1574
|
-
runtimeConfig: SandboxRuntimeConfig,
|
|
1575
|
-
action: FilesystemAllowAction,
|
|
1576
|
-
): boolean {
|
|
1577
|
-
const values = getFilesystemListValues(runtimeConfig, action.list);
|
|
1578
|
-
return mutateStringList(values, action.op, action.value);
|
|
1579
|
-
}
|
|
1580
|
-
|
|
1581
|
-
function isFilesystemAllowActionAlreadyApplied(
|
|
1582
|
-
runtimeConfig: SandboxRuntimeConfig,
|
|
1583
|
-
action: FilesystemAllowAction,
|
|
1584
|
-
): boolean {
|
|
1585
|
-
const values = getFilesystemListValues(runtimeConfig, action.list);
|
|
1586
|
-
return action.op === "add" ? values.includes(action.value) : !values.includes(action.value);
|
|
1587
|
-
}
|
|
1588
|
-
|
|
1589
|
-
function describeFilesystemViolationTarget(violation: FilesystemViolation): string {
|
|
1590
|
-
if (violation.kind === "read") {
|
|
1591
|
-
if (violation.path) return `read from ${violation.path}`;
|
|
1592
|
-
return "read";
|
|
1593
|
-
}
|
|
1594
|
-
|
|
1595
|
-
if (violation.kind === "write") {
|
|
1596
|
-
if (violation.path) return `write to ${violation.path}`;
|
|
1597
|
-
return "write";
|
|
1598
|
-
}
|
|
1599
|
-
|
|
1600
|
-
if (violation.path) return `access to ${violation.path}`;
|
|
1601
|
-
return "access";
|
|
1602
|
-
}
|
|
1603
|
-
|
|
1604
|
-
function formatFilesystemViolationSummary(violation: FilesystemViolation): string {
|
|
1605
|
-
if (violation.kind === "read") {
|
|
1606
|
-
if (violation.path) return `[sandbox] Blocked filesystem read: ${violation.path}`;
|
|
1607
|
-
return "[sandbox] Blocked filesystem read.";
|
|
1608
|
-
}
|
|
1609
|
-
|
|
1610
|
-
if (violation.kind === "write") {
|
|
1611
|
-
if (violation.path) return `[sandbox] Blocked filesystem write: ${violation.path}`;
|
|
1612
|
-
return "[sandbox] Blocked filesystem write.";
|
|
1613
|
-
}
|
|
1614
|
-
|
|
1615
|
-
if (violation.path) return `[sandbox] Blocked filesystem access: ${violation.path}`;
|
|
1616
|
-
return "[sandbox] Blocked filesystem access (EPERM).";
|
|
1617
|
-
}
|
|
1618
|
-
|
|
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";
|
|
1622
|
-
|
|
1623
|
-
function getViolationPromptOptions(autoRetryAvailable: boolean): string[] {
|
|
1624
|
-
if (!autoRetryAvailable) {
|
|
1625
|
-
return [VIOLATION_ALLOW_ADAPT_OPTION, VIOLATION_DENY_OPTION];
|
|
1626
|
-
}
|
|
1627
|
-
|
|
1628
|
-
return [VIOLATION_ALLOW_RETRY_OPTION, VIOLATION_ALLOW_ADAPT_OPTION, VIOLATION_DENY_OPTION];
|
|
1629
|
-
}
|
|
1630
|
-
|
|
1631
|
-
function parseViolationPromptSelection(
|
|
1632
|
-
selection: string | undefined,
|
|
1633
|
-
autoRetryAvailable: boolean,
|
|
1634
|
-
): ViolationResolutionKind {
|
|
1635
|
-
if (selection === VIOLATION_ALLOW_ADAPT_OPTION) return "allow-adapt";
|
|
1636
|
-
if (selection === VIOLATION_ALLOW_RETRY_OPTION && autoRetryAvailable) return "allow-retry";
|
|
1637
|
-
return "deny";
|
|
1638
|
-
}
|
|
1639
|
-
|
|
1640
|
-
function formatViolationAllowRetryMessage(blockedTarget: string): string {
|
|
1641
|
-
return `\n${blockedTarget}\n\nGranting access and retrying the command per user request...\n\n`;
|
|
1642
|
-
}
|
|
1643
|
-
|
|
1644
|
-
function formatViolationAllowAdaptMessage(blockedTarget: string): string {
|
|
1645
|
-
return `\n${blockedTarget}\n\nAccess granted for this session. Retry the command manually if appropriate.`;
|
|
1646
|
-
}
|
|
1647
|
-
|
|
1648
|
-
function formatViolationDeniedMessage(blockedTarget: string): string {
|
|
1649
|
-
return `\n${blockedTarget}\n\nAccess remains denied for this session.`;
|
|
1650
|
-
}
|
|
1651
|
-
|
|
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.`;
|
|
1654
|
-
}
|
|
1655
|
-
|
|
1656
|
-
function formatViolationRetrySucceededMessage(blockedTarget: string): string {
|
|
1657
|
-
return `\n${blockedTarget}\n\nAccess granted for this session and the command was retried successfully.`;
|
|
1658
|
-
}
|
|
1659
|
-
|
|
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.`;
|
|
1662
|
-
}
|
|
1663
|
-
|
|
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}.`;
|
|
1674
|
-
}
|
|
1675
|
-
|
|
1676
|
-
function appendOutputPostamble(postamble: string, addition: string, output: string): string {
|
|
1677
|
-
if (!addition) return postamble;
|
|
1678
|
-
|
|
1679
|
-
const needsSeparator =
|
|
1680
|
-
postamble.length > 0 ? !postamble.endsWith("\n") : output.length > 0 && !output.endsWith("\n");
|
|
1681
|
-
|
|
1682
|
-
return `${postamble}${needsSeparator ? "\n" : ""}${addition}`;
|
|
1683
|
-
}
|
|
1684
|
-
|
|
1685
|
-
function ensureTrailingNewline(text: string): string {
|
|
1686
|
-
if (!text || text.endsWith("\n")) return text;
|
|
1687
|
-
return `${text}\n`;
|
|
1688
|
-
}
|
|
1689
|
-
|
|
1690
|
-
async function handleFilesystemViolation(options: {
|
|
1691
|
-
pi: ExtensionAPI;
|
|
1692
|
-
ctx: ExtensionContext | null;
|
|
1693
|
-
promptMode: PromptMode;
|
|
1694
|
-
runtimeConfig: SandboxRuntimeConfig;
|
|
1695
|
-
output: string;
|
|
1696
|
-
rawOutput: string;
|
|
1697
|
-
command: string;
|
|
1698
|
-
cwd?: string;
|
|
1699
|
-
pendingPrompts?: Map<string, Promise<ViolationResolution | null>>;
|
|
1700
|
-
applyRuntimeConfigForSession?: (
|
|
1701
|
-
ctx: ExtensionContext,
|
|
1702
|
-
runtimeConfig: SandboxRuntimeConfig,
|
|
1703
|
-
) => void;
|
|
1704
|
-
existingViolationCount?: number;
|
|
1705
|
-
recordEvent?: (event: SandboxEvent) => void;
|
|
1706
|
-
autoRetryAvailable?: boolean;
|
|
1707
|
-
runtimeProtectedWriteViolations?: FilesystemViolation[];
|
|
1708
|
-
allowOutputFallback?: boolean;
|
|
1709
|
-
}): Promise<ViolationResolution | null> {
|
|
1710
|
-
const {
|
|
1711
|
-
pi,
|
|
1712
|
-
ctx,
|
|
1713
|
-
promptMode,
|
|
1714
|
-
runtimeConfig,
|
|
1715
|
-
output,
|
|
1716
|
-
rawOutput,
|
|
1717
|
-
command,
|
|
1718
|
-
cwd,
|
|
1719
|
-
pendingPrompts,
|
|
1720
|
-
applyRuntimeConfigForSession,
|
|
1721
|
-
existingViolationCount,
|
|
1722
|
-
recordEvent,
|
|
1723
|
-
autoRetryAvailable = true,
|
|
1724
|
-
runtimeProtectedWriteViolations = [],
|
|
1725
|
-
allowOutputFallback = true,
|
|
1726
|
-
} = options;
|
|
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;
|
|
1742
|
-
|
|
1743
|
-
const violation =
|
|
1744
|
-
actionableViolations.find((candidate) => {
|
|
1745
|
-
const candidateAction = buildFilesystemAllowAction(runtimeConfig, candidate, cwd);
|
|
1746
|
-
if (!candidateAction) return false;
|
|
1747
|
-
return !isFilesystemAllowActionAlreadyApplied(runtimeConfig, candidateAction);
|
|
1748
|
-
}) ?? actionableViolations[0];
|
|
1749
|
-
|
|
1750
|
-
const summary = formatFilesystemViolationSummary(violation);
|
|
1751
|
-
const target = describeFilesystemViolationTarget(violation);
|
|
1752
|
-
const blockedTarget = formatFilesystemBlockedTarget(target);
|
|
1753
|
-
const allowAction = buildFilesystemAllowAction(runtimeConfig, violation, cwd);
|
|
1754
|
-
const allowCommand = allowAction ? buildFilesystemAllowCommand(allowAction) : null;
|
|
1755
|
-
const alreadyApproved = allowAction
|
|
1756
|
-
? isFilesystemAllowActionAlreadyApplied(runtimeConfig, allowAction)
|
|
1757
|
-
: false;
|
|
1758
|
-
const eventReason = classifyFilesystemEventReason(runtimeConfig, violation, cwd, alreadyApproved);
|
|
1759
|
-
const blockedSuggestedCommand = alreadyApproved ? undefined : (allowCommand ?? undefined);
|
|
1760
|
-
|
|
1761
|
-
const recordFilesystemEvent = (outcome: SandboxEventOutcome): void => {
|
|
1762
|
-
recordEvent?.({
|
|
1763
|
-
timestamp: Date.now(),
|
|
1764
|
-
kind: "filesystem",
|
|
1765
|
-
outcome,
|
|
1766
|
-
reason: eventReason,
|
|
1767
|
-
target: violation.path,
|
|
1768
|
-
command,
|
|
1769
|
-
cwd,
|
|
1770
|
-
summary: describeFilesystemEventSummary(eventReason, violation, outcome),
|
|
1771
|
-
suggestedCommand: outcome === "blocked" ? blockedSuggestedCommand : undefined,
|
|
1772
|
-
});
|
|
1773
|
-
};
|
|
1774
|
-
|
|
1775
|
-
if (promptMode === "non-interactive" || !ctx?.hasUI) {
|
|
1776
|
-
recordFilesystemEvent("blocked");
|
|
1777
|
-
if (!allowCommand) return { kind: "deny", message: summary };
|
|
1778
|
-
return {
|
|
1779
|
-
kind: "deny",
|
|
1780
|
-
message: `${summary}\n[sandbox] To temporarily allow for this session, run: ${allowCommand}`,
|
|
1781
|
-
};
|
|
1782
|
-
}
|
|
1783
|
-
|
|
1784
|
-
if (!allowAction || !allowCommand) {
|
|
1785
|
-
recordFilesystemEvent("blocked");
|
|
1786
|
-
return { kind: "deny", message: summary };
|
|
1787
|
-
}
|
|
1788
|
-
|
|
1789
|
-
if (alreadyApproved) {
|
|
1790
|
-
recordFilesystemEvent("blocked");
|
|
1791
|
-
return { kind: "allow-adapt", message: formatViolationAlreadyAllowedMessage(blockedTarget) };
|
|
1792
|
-
}
|
|
1793
|
-
|
|
1794
|
-
const promptKey = `${allowCommand}:${autoRetryAvailable ? "retry" : "adapt"}`;
|
|
1795
|
-
const existingPrompt = pendingPrompts?.get(promptKey);
|
|
1796
|
-
if (existingPrompt) return existingPrompt;
|
|
1797
|
-
|
|
1798
|
-
const promptTask: Promise<ViolationResolution | null> = (async () => {
|
|
1799
|
-
try {
|
|
1800
|
-
const selection = await withPromptSignal(pi, () =>
|
|
1801
|
-
ctx.ui.select(
|
|
1802
|
-
`Sandbox blocked filesystem ${target}`,
|
|
1803
|
-
getViolationPromptOptions(autoRetryAvailable),
|
|
1804
|
-
),
|
|
1805
|
-
);
|
|
1806
|
-
const decision = parseViolationPromptSelection(selection, autoRetryAvailable);
|
|
1807
|
-
if (decision === "deny") {
|
|
1808
|
-
recordFilesystemEvent("blocked");
|
|
1809
|
-
return { kind: "deny", message: formatViolationDeniedMessage(blockedTarget) };
|
|
1810
|
-
}
|
|
1811
|
-
|
|
1812
|
-
const nextConfig = cloneRuntimeConfig(runtimeConfig);
|
|
1813
|
-
const changed = applyFilesystemAllowAction(nextConfig, allowAction);
|
|
1814
|
-
if (changed) {
|
|
1815
|
-
applyRuntimeConfigForSession?.(ctx, nextConfig);
|
|
1816
|
-
}
|
|
1817
|
-
|
|
1818
|
-
recordFilesystemEvent("allowed");
|
|
1819
|
-
|
|
1820
|
-
if (decision === "allow-retry") {
|
|
1821
|
-
return {
|
|
1822
|
-
kind: "allow-retry",
|
|
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),
|
|
1972
|
-
};
|
|
1973
|
-
}
|
|
1974
|
-
|
|
1975
|
-
return {
|
|
1976
|
-
kind: "allow-adapt",
|
|
1977
|
-
message: changed
|
|
1978
|
-
? formatViolationAllowAdaptMessage(blockedTarget)
|
|
1979
|
-
: formatViolationAlreadyAllowedMessage(blockedTarget),
|
|
1980
|
-
};
|
|
1981
|
-
} catch {
|
|
1982
|
-
return null;
|
|
1983
|
-
}
|
|
1984
|
-
})();
|
|
1985
|
-
|
|
1986
|
-
if (!pendingPrompts) return promptTask;
|
|
1987
|
-
|
|
1988
|
-
pendingPrompts.set(promptKey, promptTask);
|
|
1989
|
-
try {
|
|
1990
|
-
return await promptTask;
|
|
1991
|
-
} finally {
|
|
1992
|
-
pendingPrompts.delete(promptKey);
|
|
1993
|
-
}
|
|
1994
|
-
}
|
|
1995
|
-
|
|
1996
|
-
interface SandboxedBashOpsOptions {
|
|
1997
|
-
pi: ExtensionAPI;
|
|
1998
|
-
getContext: () => ExtensionContext | null;
|
|
1999
|
-
getSandboxConfig: () => SandboxConfig | null;
|
|
2000
|
-
getRuntimeConfig: () => SandboxRuntimeConfig | null;
|
|
2001
|
-
getPromptMode: () => PromptMode;
|
|
2002
|
-
applyRuntimeConfigForSession: (
|
|
2003
|
-
ctx: ExtensionContext,
|
|
2004
|
-
runtimeConfig: SandboxRuntimeConfig,
|
|
2005
|
-
) => void;
|
|
2006
|
-
recordEvent?: (event: SandboxEvent) => void;
|
|
2007
|
-
}
|
|
2008
|
-
|
|
2009
|
-
interface BashAttemptResult {
|
|
2010
|
-
exitCode: number | null;
|
|
2011
|
-
combinedOutput: string;
|
|
2012
|
-
interruptedByFilesystemViolation: boolean;
|
|
2013
|
-
runtimeProtectedWriteViolations: FilesystemViolation[];
|
|
2014
|
-
}
|
|
2015
|
-
|
|
2016
|
-
interface ProcessedSandboxAttempt {
|
|
2017
|
-
exitCode: number | null;
|
|
2018
|
-
postamble: string;
|
|
2019
|
-
resolution: ViolationResolution | null;
|
|
2020
|
-
runtimeProtectedWriteViolations: FilesystemViolation[];
|
|
2021
|
-
}
|
|
2022
|
-
|
|
2023
|
-
interface PreparedSandboxAttempt {
|
|
2024
|
-
attempt: BashAttemptResult;
|
|
2025
|
-
existingViolationCount: number;
|
|
2026
|
-
runtimeConfig: SandboxRuntimeConfig | null;
|
|
2027
|
-
}
|
|
2028
|
-
|
|
2029
|
-
function killProcessGroup(
|
|
2030
|
-
child: ReturnType<typeof spawn>,
|
|
2031
|
-
signal: NodeJS.Signals = "SIGKILL",
|
|
2032
|
-
): void {
|
|
2033
|
-
if (!child.pid) return;
|
|
2034
|
-
|
|
2035
|
-
try {
|
|
2036
|
-
process.kill(-child.pid, signal);
|
|
2037
|
-
} catch {
|
|
2038
|
-
try {
|
|
2039
|
-
child.kill(signal);
|
|
2040
|
-
} catch {
|
|
2041
|
-
// Process likely already exited.
|
|
2042
|
-
}
|
|
2043
|
-
}
|
|
2044
|
-
}
|
|
2045
|
-
|
|
2046
|
-
function safeCleanupAfterCommand(): void {
|
|
2047
|
-
try {
|
|
2048
|
-
SandboxManager.cleanupAfterCommand();
|
|
2049
|
-
} catch {
|
|
2050
|
-
// Ignore cleanup errors.
|
|
2051
|
-
}
|
|
2052
|
-
}
|
|
2053
|
-
|
|
2054
|
-
function maybeAllowGitMetadataWriteForSession(options: {
|
|
2055
|
-
ctx: ExtensionContext | null;
|
|
2056
|
-
cwd: string;
|
|
2057
|
-
runtimeConfig: SandboxRuntimeConfig | null;
|
|
2058
|
-
allowGitCommonDir: boolean;
|
|
2059
|
-
applyRuntimeConfigForSession: (
|
|
2060
|
-
ctx: ExtensionContext,
|
|
2061
|
-
runtimeConfig: SandboxRuntimeConfig,
|
|
2062
|
-
) => void;
|
|
2063
|
-
}): void {
|
|
2064
|
-
const { ctx, cwd, runtimeConfig, allowGitCommonDir, applyRuntimeConfigForSession } = options;
|
|
2065
|
-
if (!ctx || !runtimeConfig) return;
|
|
2066
|
-
if (!isSandboxWritablePath(runtimeConfig, cwd, cwd)) return;
|
|
2067
|
-
|
|
2068
|
-
const gitPaths = resolveGitFilesystemPaths(cwd);
|
|
2069
|
-
if (!gitPaths) return;
|
|
2070
|
-
|
|
2071
|
-
let nextConfig = maybeAddGitMetadataWritePath(runtimeConfig, gitPaths.gitDir, cwd);
|
|
2072
|
-
if (allowGitCommonDir && gitPaths.gitCommonDir !== gitPaths.gitDir) {
|
|
2073
|
-
nextConfig =
|
|
2074
|
-
maybeAddGitMetadataWritePath(nextConfig ?? runtimeConfig, gitPaths.gitCommonDir, cwd) ??
|
|
2075
|
-
nextConfig;
|
|
2076
|
-
}
|
|
2077
|
-
|
|
2078
|
-
if (!nextConfig) return;
|
|
2079
|
-
applyRuntimeConfigForSession(ctx, nextConfig);
|
|
2080
|
-
}
|
|
2081
|
-
|
|
2082
|
-
function createSandboxedBashOps(options: SandboxedBashOpsOptions): BashOperations {
|
|
2083
|
-
const {
|
|
2084
|
-
pi,
|
|
2085
|
-
getContext,
|
|
2086
|
-
getSandboxConfig,
|
|
2087
|
-
getRuntimeConfig,
|
|
2088
|
-
getPromptMode,
|
|
2089
|
-
applyRuntimeConfigForSession,
|
|
2090
|
-
recordEvent,
|
|
2091
|
-
} = options;
|
|
2092
|
-
const pendingFilesystemPrompts = new Map<string, Promise<ViolationResolution | null>>();
|
|
2093
|
-
const pendingMachLookupPrompts = new Map<string, Promise<ViolationResolution | null>>();
|
|
2094
|
-
|
|
2095
|
-
let executionQueue: Promise<void> = Promise.resolve();
|
|
2096
|
-
|
|
2097
|
-
function runSerially<T>(task: () => Promise<T>): Promise<T> {
|
|
2098
|
-
const run = executionQueue.then(task, task);
|
|
2099
|
-
executionQueue = run.then(
|
|
2100
|
-
() => undefined,
|
|
2101
|
-
() => undefined,
|
|
2102
|
-
);
|
|
2103
|
-
return run;
|
|
2104
|
-
}
|
|
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
|
-
|
|
2112
|
-
async function runSandboxAttempt(
|
|
2113
|
-
command: string,
|
|
2114
|
-
wrappedCommand: string,
|
|
2115
|
-
cwd: string,
|
|
2116
|
-
runtimeConfig: SandboxRuntimeConfig | null,
|
|
2117
|
-
onData: (data: Buffer) => void,
|
|
2118
|
-
existingViolationCount: number,
|
|
2119
|
-
signal?: AbortSignal,
|
|
2120
|
-
timeout?: number,
|
|
2121
|
-
env?: NodeJS.ProcessEnv,
|
|
2122
|
-
): Promise<BashAttemptResult> {
|
|
2123
|
-
return new Promise((resolve, reject) => {
|
|
2124
|
-
const child = spawn("bash", ["-c", wrappedCommand], {
|
|
2125
|
-
cwd,
|
|
2126
|
-
env: withSandboxDefaultEnv(env),
|
|
2127
|
-
detached: true,
|
|
2128
|
-
stdio: ["ignore", "pipe", "pipe"],
|
|
2129
|
-
});
|
|
2130
|
-
|
|
2131
|
-
const chunks: Buffer[] = [];
|
|
2132
|
-
let timedOut = false;
|
|
2133
|
-
let interruptedByFilesystemViolation = false;
|
|
2134
|
-
let seenViolationCount = existingViolationCount;
|
|
2135
|
-
const runtimeProtectedWriteViolations = new Map<string, FilesystemViolation>();
|
|
2136
|
-
let timeoutHandle: NodeJS.Timeout | undefined;
|
|
2137
|
-
let timeoutEscalationHandle: NodeJS.Timeout | undefined;
|
|
2138
|
-
let filesystemStopEscalationHandle: NodeJS.Timeout | undefined;
|
|
2139
|
-
|
|
2140
|
-
const stopForFilesystemViolation = (): void => {
|
|
2141
|
-
if (interruptedByFilesystemViolation) return;
|
|
2142
|
-
|
|
2143
|
-
interruptedByFilesystemViolation = true;
|
|
2144
|
-
killProcessGroup(child, "SIGTERM");
|
|
2145
|
-
filesystemStopEscalationHandle = setTimeout(() => {
|
|
2146
|
-
killProcessGroup(child, "SIGKILL");
|
|
2147
|
-
}, 500);
|
|
2148
|
-
};
|
|
2149
|
-
|
|
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.
|
|
2153
|
-
const unsubscribeViolations =
|
|
2154
|
-
process.platform !== "darwin"
|
|
2155
|
-
? () => undefined
|
|
2156
|
-
: SandboxManager.getSandboxViolationStore().subscribe(() => {
|
|
2157
|
-
const violations =
|
|
2158
|
-
SandboxManager.getSandboxViolationStore().getViolationsForCommand(command);
|
|
2159
|
-
if (violations.length <= seenViolationCount) return;
|
|
2160
|
-
|
|
2161
|
-
const newViolations = violations.slice(seenViolationCount);
|
|
2162
|
-
seenViolationCount = violations.length;
|
|
2163
|
-
|
|
2164
|
-
for (const violation of newViolations) {
|
|
2165
|
-
const filesystemViolation = detectFilesystemViolationFromLine(violation.line);
|
|
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
|
-
}
|
|
2181
|
-
}
|
|
2182
|
-
});
|
|
2183
|
-
|
|
2184
|
-
if (timeout !== undefined && timeout > 0) {
|
|
2185
|
-
timeoutHandle = setTimeout(() => {
|
|
2186
|
-
timedOut = true;
|
|
2187
|
-
killProcessGroup(child, "SIGTERM");
|
|
2188
|
-
timeoutEscalationHandle = setTimeout(() => {
|
|
2189
|
-
killProcessGroup(child, "SIGKILL");
|
|
2190
|
-
}, 2000);
|
|
2191
|
-
}, timeout * 1000);
|
|
2192
|
-
}
|
|
2193
|
-
|
|
2194
|
-
child.stdout?.on("data", (data) => {
|
|
2195
|
-
chunks.push(data);
|
|
2196
|
-
onData(data);
|
|
2197
|
-
});
|
|
2198
|
-
child.stderr?.on("data", (data) => {
|
|
2199
|
-
chunks.push(data);
|
|
2200
|
-
onData(data);
|
|
2201
|
-
});
|
|
2202
|
-
|
|
2203
|
-
const onAbort = () => {
|
|
2204
|
-
killProcessGroup(child, "SIGKILL");
|
|
2205
|
-
};
|
|
2206
|
-
|
|
2207
|
-
child.on("error", (err) => {
|
|
2208
|
-
if (timeoutHandle) clearTimeout(timeoutHandle);
|
|
2209
|
-
if (timeoutEscalationHandle) clearTimeout(timeoutEscalationHandle);
|
|
2210
|
-
if (filesystemStopEscalationHandle) clearTimeout(filesystemStopEscalationHandle);
|
|
2211
|
-
unsubscribeViolations();
|
|
2212
|
-
signal?.removeEventListener("abort", onAbort);
|
|
2213
|
-
killProcessGroup(child, "SIGKILL");
|
|
2214
|
-
reject(err);
|
|
2215
|
-
});
|
|
2216
|
-
|
|
2217
|
-
if (signal) {
|
|
2218
|
-
if (signal.aborted) onAbort();
|
|
2219
|
-
else signal.addEventListener("abort", onAbort, { once: true });
|
|
2220
|
-
}
|
|
2221
|
-
|
|
2222
|
-
child.on("close", (code) => {
|
|
2223
|
-
if (timeoutHandle) clearTimeout(timeoutHandle);
|
|
2224
|
-
if (timeoutEscalationHandle) clearTimeout(timeoutEscalationHandle);
|
|
2225
|
-
if (filesystemStopEscalationHandle) clearTimeout(filesystemStopEscalationHandle);
|
|
2226
|
-
unsubscribeViolations();
|
|
2227
|
-
signal?.removeEventListener("abort", onAbort);
|
|
2228
|
-
|
|
2229
|
-
if (signal?.aborted) {
|
|
2230
|
-
reject(new Error("aborted"));
|
|
2231
|
-
return;
|
|
2232
|
-
}
|
|
2233
|
-
|
|
2234
|
-
if (timedOut) {
|
|
2235
|
-
reject(new Error(`timeout:${timeout}`));
|
|
2236
|
-
return;
|
|
2237
|
-
}
|
|
2238
|
-
|
|
2239
|
-
resolve({
|
|
2240
|
-
exitCode: interruptedByFilesystemViolation && code === null ? 1 : code,
|
|
2241
|
-
combinedOutput: Buffer.concat(chunks).toString("utf-8"),
|
|
2242
|
-
interruptedByFilesystemViolation,
|
|
2243
|
-
runtimeProtectedWriteViolations: Array.from(runtimeProtectedWriteViolations.values()),
|
|
2244
|
-
});
|
|
2245
|
-
});
|
|
2246
|
-
});
|
|
2247
|
-
}
|
|
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
|
-
|
|
2259
|
-
function getRemainingTimeout(timeout: number | undefined, startedAt: number): number | undefined {
|
|
2260
|
-
if (timeout === undefined) return undefined;
|
|
2261
|
-
|
|
2262
|
-
const remainingMs = timeout * 1000 - (Date.now() - startedAt);
|
|
2263
|
-
return Math.max(0, remainingMs / 1000);
|
|
2264
|
-
}
|
|
2265
|
-
|
|
2266
|
-
function reportPostProcessingError(error: unknown): void {
|
|
2267
|
-
const message = `[sandbox] Post-processing error: ${error instanceof Error ? error.message : error}`;
|
|
2268
|
-
const ctx = getContext();
|
|
2269
|
-
if (ctx) notify(ctx, message, "warning");
|
|
2270
|
-
else console.warn(message);
|
|
2271
|
-
}
|
|
2272
|
-
|
|
2273
|
-
async function prepareAndRunSandboxAttempt(options: {
|
|
2274
|
-
command: string;
|
|
2275
|
-
cwd: string;
|
|
2276
|
-
onData: (data: Buffer) => void;
|
|
2277
|
-
signal?: AbortSignal;
|
|
2278
|
-
timeout?: number;
|
|
2279
|
-
env?: NodeJS.ProcessEnv;
|
|
2280
|
-
}): Promise<PreparedSandboxAttempt> {
|
|
2281
|
-
const { command, cwd, onData, signal, timeout, env } = options;
|
|
2282
|
-
|
|
2283
|
-
maybeAllowGitMetadataWriteForSession({
|
|
2284
|
-
ctx: getContext(),
|
|
2285
|
-
cwd,
|
|
2286
|
-
runtimeConfig: getRuntimeConfig(),
|
|
2287
|
-
allowGitCommonDir: getSandboxConfig()?.filesystem.allowGitCommonDir === true,
|
|
2288
|
-
applyRuntimeConfigForSession,
|
|
2289
|
-
});
|
|
2290
|
-
|
|
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
|
-
);
|
|
2298
|
-
const existingViolationCount =
|
|
2299
|
-
SandboxManager.getSandboxViolationStore().getViolationsForCommand(command).length;
|
|
2300
|
-
|
|
2301
|
-
try {
|
|
2302
|
-
const attempt = await runSandboxAttempt(
|
|
2303
|
-
command,
|
|
2304
|
-
wrappedCommand,
|
|
2305
|
-
cwd,
|
|
2306
|
-
attemptRuntimeConfig,
|
|
2307
|
-
onData,
|
|
2308
|
-
existingViolationCount,
|
|
2309
|
-
signal,
|
|
2310
|
-
timeout,
|
|
2311
|
-
env,
|
|
2312
|
-
);
|
|
2313
|
-
return { attempt, existingViolationCount, runtimeConfig: attemptRuntimeConfig };
|
|
2314
|
-
} catch (err) {
|
|
2315
|
-
safeCleanupAfterCommand();
|
|
2316
|
-
throw err;
|
|
2317
|
-
}
|
|
2318
|
-
}
|
|
2319
|
-
|
|
2320
|
-
async function processSandboxAttempt(options: {
|
|
2321
|
-
attempt: BashAttemptResult;
|
|
2322
|
-
command: string;
|
|
2323
|
-
cwd: string;
|
|
2324
|
-
existingViolationCount: number;
|
|
2325
|
-
runtimeConfig: SandboxRuntimeConfig | null;
|
|
2326
|
-
autoRetryAvailable: boolean;
|
|
2327
|
-
}): Promise<ProcessedSandboxAttempt> {
|
|
2328
|
-
const { attempt, command, cwd, existingViolationCount, runtimeConfig, autoRetryAvailable } =
|
|
2329
|
-
options;
|
|
2330
|
-
const annotatedOutput = SandboxManager.annotateStderrWithSandboxFailures(
|
|
2331
|
-
command,
|
|
2332
|
-
attempt.combinedOutput,
|
|
2333
|
-
);
|
|
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(
|
|
2343
|
-
runtimeConfig,
|
|
2344
|
-
[...attempt.runtimeProtectedWriteViolations, ...storedFilesystemViolations],
|
|
2345
|
-
cwd,
|
|
2346
|
-
);
|
|
2347
|
-
const machLookupViolations = detectMachLookupViolations(storedViolationLines);
|
|
2348
|
-
let postamble = extractAppendedSandboxAnnotation(
|
|
2349
|
-
attempt.combinedOutput,
|
|
2350
|
-
annotatedOutput,
|
|
2351
|
-
existingViolationCount,
|
|
2352
|
-
);
|
|
2353
|
-
|
|
2354
|
-
if (runtimeProtectedWriteViolations.length > 0) {
|
|
2355
|
-
const notice = formatRuntimeProtectedWriteNotice(
|
|
2356
|
-
runtimeProtectedWriteViolations,
|
|
2357
|
-
!attempt.interruptedByFilesystemViolation,
|
|
2358
|
-
);
|
|
2359
|
-
postamble = appendOutputPostamble(postamble, notice, attempt.combinedOutput);
|
|
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
|
-
|
|
2409
|
-
resolution = await handleFilesystemViolation({
|
|
2410
|
-
pi,
|
|
2411
|
-
ctx: getContext(),
|
|
2412
|
-
promptMode: getPromptMode(),
|
|
2413
|
-
runtimeConfig: currentRuntimeConfig,
|
|
2414
|
-
output: annotatedOutput,
|
|
2415
|
-
rawOutput: attempt.combinedOutput,
|
|
2416
|
-
command,
|
|
2417
|
-
cwd,
|
|
2418
|
-
pendingPrompts: pendingFilesystemPrompts,
|
|
2419
|
-
applyRuntimeConfigForSession,
|
|
2420
|
-
existingViolationCount,
|
|
2421
|
-
recordEvent,
|
|
2422
|
-
autoRetryAvailable,
|
|
2423
|
-
runtimeProtectedWriteViolations,
|
|
2424
|
-
allowOutputFallback: machLookupViolations.length === 0,
|
|
2425
|
-
});
|
|
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
|
-
|
|
2443
|
-
if (resolution) {
|
|
2444
|
-
postamble = appendOutputPostamble(postamble, resolution.message, attempt.combinedOutput);
|
|
2445
|
-
}
|
|
2446
|
-
}
|
|
2447
|
-
|
|
2448
|
-
return {
|
|
2449
|
-
exitCode: effectiveExitCode,
|
|
2450
|
-
postamble,
|
|
2451
|
-
resolution,
|
|
2452
|
-
runtimeProtectedWriteViolations,
|
|
2453
|
-
};
|
|
2454
|
-
}
|
|
2455
|
-
|
|
2456
|
-
return {
|
|
2457
|
-
async exec(command, cwd, { onData, signal, timeout, env }) {
|
|
2458
|
-
validateTimeout(timeout);
|
|
2459
|
-
return runSerially(async () => {
|
|
2460
|
-
if (!existsSync(cwd)) {
|
|
2461
|
-
throw new Error(`Working directory does not exist: ${cwd}`);
|
|
2462
|
-
}
|
|
2463
|
-
|
|
2464
|
-
const startedAt = Date.now();
|
|
2465
|
-
const initialRun = await prepareAndRunSandboxAttempt({
|
|
2466
|
-
command,
|
|
2467
|
-
cwd,
|
|
2468
|
-
onData,
|
|
2469
|
-
signal,
|
|
2470
|
-
timeout,
|
|
2471
|
-
env,
|
|
2472
|
-
});
|
|
2473
|
-
|
|
2474
|
-
let processedAttempt: ProcessedSandboxAttempt;
|
|
2475
|
-
try {
|
|
2476
|
-
processedAttempt = await processSandboxAttempt({
|
|
2477
|
-
attempt: initialRun.attempt,
|
|
2478
|
-
command,
|
|
2479
|
-
cwd,
|
|
2480
|
-
existingViolationCount: initialRun.existingViolationCount,
|
|
2481
|
-
runtimeConfig: initialRun.runtimeConfig,
|
|
2482
|
-
autoRetryAvailable: true,
|
|
2483
|
-
});
|
|
2484
|
-
} catch (postProcessError) {
|
|
2485
|
-
reportPostProcessingError(postProcessError);
|
|
2486
|
-
safeCleanupAfterCommand();
|
|
2487
|
-
return { exitCode: initialRun.attempt.exitCode };
|
|
2488
|
-
}
|
|
2489
|
-
|
|
2490
|
-
const retryResolution = processedAttempt.resolution;
|
|
2491
|
-
if (retryResolution?.kind !== "allow-retry") {
|
|
2492
|
-
if (processedAttempt.postamble) onData(Buffer.from(processedAttempt.postamble));
|
|
2493
|
-
safeCleanupAfterCommand();
|
|
2494
|
-
return { exitCode: processedAttempt.exitCode };
|
|
2495
|
-
}
|
|
2496
|
-
|
|
2497
|
-
if (processedAttempt.postamble) {
|
|
2498
|
-
onData(Buffer.from(ensureTrailingNewline(processedAttempt.postamble)));
|
|
2499
|
-
}
|
|
2500
|
-
|
|
2501
|
-
initialRun.attempt.combinedOutput = "";
|
|
2502
|
-
safeCleanupAfterCommand();
|
|
2503
|
-
|
|
2504
|
-
const retryTimeout = getRemainingTimeout(timeout, startedAt);
|
|
2505
|
-
if (retryTimeout !== undefined && retryTimeout <= 0) {
|
|
2506
|
-
onData(Buffer.from(retryResolution.retrySkippedMessage));
|
|
2507
|
-
return { exitCode: processedAttempt.exitCode };
|
|
2508
|
-
}
|
|
2509
|
-
|
|
2510
|
-
const retryRun = await prepareAndRunSandboxAttempt({
|
|
2511
|
-
command,
|
|
2512
|
-
cwd,
|
|
2513
|
-
onData,
|
|
2514
|
-
signal,
|
|
2515
|
-
timeout: retryTimeout,
|
|
2516
|
-
env,
|
|
2517
|
-
});
|
|
2518
|
-
|
|
2519
|
-
let processedRetry: ProcessedSandboxAttempt;
|
|
2520
|
-
try {
|
|
2521
|
-
processedRetry = await processSandboxAttempt({
|
|
2522
|
-
attempt: retryRun.attempt,
|
|
2523
|
-
command,
|
|
2524
|
-
cwd,
|
|
2525
|
-
existingViolationCount: retryRun.existingViolationCount,
|
|
2526
|
-
runtimeConfig: retryRun.runtimeConfig,
|
|
2527
|
-
autoRetryAvailable: false,
|
|
2528
|
-
});
|
|
2529
|
-
} catch (postProcessError) {
|
|
2530
|
-
reportPostProcessingError(postProcessError);
|
|
2531
|
-
safeCleanupAfterCommand();
|
|
2532
|
-
return { exitCode: retryRun.attempt.exitCode };
|
|
2533
|
-
}
|
|
2534
|
-
|
|
2535
|
-
let retryPostamble = processedRetry.postamble;
|
|
2536
|
-
if (processedRetry.exitCode === 0) {
|
|
2537
|
-
retryPostamble = appendOutputPostamble(
|
|
2538
|
-
retryPostamble,
|
|
2539
|
-
retryResolution.retrySuccessMessage,
|
|
2540
|
-
retryRun.attempt.combinedOutput,
|
|
2541
|
-
);
|
|
2542
|
-
} else if (
|
|
2543
|
-
!processedRetry.resolution &&
|
|
2544
|
-
processedRetry.runtimeProtectedWriteViolations.length === 0
|
|
2545
|
-
) {
|
|
2546
|
-
retryPostamble = appendOutputPostamble(
|
|
2547
|
-
retryPostamble,
|
|
2548
|
-
retryResolution.retryFailureMessage,
|
|
2549
|
-
retryRun.attempt.combinedOutput,
|
|
2550
|
-
);
|
|
2551
|
-
}
|
|
2552
|
-
|
|
2553
|
-
if (retryPostamble) onData(Buffer.from(retryPostamble));
|
|
2554
|
-
safeCleanupAfterCommand();
|
|
2555
|
-
return { exitCode: processedRetry.exitCode };
|
|
2556
|
-
});
|
|
2557
|
-
},
|
|
2558
|
-
};
|
|
2559
|
-
}
|
|
2560
|
-
|
|
2561
|
-
function getSandboxRunMode(state: SandboxState): SandboxRunMode {
|
|
2562
|
-
if (state.status === "active" || state.status === "pending") return "sandbox";
|
|
2563
|
-
if (state.status === "suspended") return "user-disabled";
|
|
2564
|
-
return state.reason;
|
|
2565
|
-
}
|
|
2566
|
-
|
|
2567
|
-
function getStateRuntimeConfig(state: SandboxState): SandboxRuntimeConfig | null {
|
|
2568
|
-
if (state.status === "active") return state.runtimeConfig;
|
|
2569
|
-
return null;
|
|
2570
|
-
}
|
|
2571
|
-
|
|
2572
|
-
function requireRuntimeConfig(
|
|
2573
|
-
ctx: ExtensionContext,
|
|
2574
|
-
state: SandboxState,
|
|
2575
|
-
): SandboxRuntimeConfig | null {
|
|
2576
|
-
const runtimeConfig = getStateRuntimeConfig(state);
|
|
2577
|
-
if (!runtimeConfig) {
|
|
2578
|
-
notify(ctx, "Sandbox is not initialized", "info");
|
|
2579
|
-
return null;
|
|
2580
|
-
}
|
|
2581
|
-
return runtimeConfig;
|
|
2582
|
-
}
|
|
2583
|
-
|
|
2584
|
-
function mutateStringList(values: string[], op: ListOp, value: string): boolean {
|
|
2585
|
-
if (op === "add") {
|
|
2586
|
-
if (values.includes(value)) return false;
|
|
2587
|
-
values.push(value);
|
|
2588
|
-
return true;
|
|
2589
|
-
}
|
|
2590
|
-
|
|
2591
|
-
const index = values.indexOf(value);
|
|
2592
|
-
if (index === -1) return false;
|
|
2593
|
-
values.splice(index, 1);
|
|
2594
|
-
return true;
|
|
2595
|
-
}
|
|
2596
|
-
|
|
2597
|
-
function classifyFilesystemEventReason(
|
|
2598
|
-
runtimeConfig: SandboxRuntimeConfig,
|
|
2599
|
-
violation: FilesystemViolation,
|
|
2600
|
-
cwd?: string,
|
|
2601
|
-
alreadyApproved = false,
|
|
2602
|
-
): SandboxEventReason {
|
|
2603
|
-
if (alreadyApproved) return "already-approved-still-failed";
|
|
2604
|
-
|
|
2605
|
-
if (violation.path) {
|
|
2606
|
-
if (inferSandboxRuleMatch(violation.path, runtimeConfig.filesystem.denyWrite, cwd)) {
|
|
2607
|
-
return "explicit-deny-write";
|
|
2608
|
-
}
|
|
2609
|
-
if (inferSandboxRuleMatch(violation.path, runtimeConfig.filesystem.denyRead, cwd)) {
|
|
2610
|
-
return "explicit-deny-read";
|
|
2611
|
-
}
|
|
2612
|
-
}
|
|
2613
|
-
|
|
2614
|
-
if (violation.kind === "write" || violation.kind === "unknown") return "missing-allow-write";
|
|
2615
|
-
return "unknown";
|
|
2616
|
-
}
|
|
2617
|
-
|
|
2618
|
-
function describeFilesystemEventSummary(
|
|
2619
|
-
reason: SandboxEventReason,
|
|
2620
|
-
violation: FilesystemViolation,
|
|
2621
|
-
outcome: SandboxEventOutcome,
|
|
2622
|
-
): string {
|
|
2623
|
-
if (outcome === "allowed") {
|
|
2624
|
-
if (reason === "explicit-deny-read") return "user allowed filesystem read for this session";
|
|
2625
|
-
if (reason === "explicit-deny-write") return "user allowed filesystem write for this session";
|
|
2626
|
-
if (reason === "missing-allow-write") {
|
|
2627
|
-
return violation.kind === "unknown"
|
|
2628
|
-
? "user allowed filesystem access for this session"
|
|
2629
|
-
: "user allowed filesystem write path for this session";
|
|
2630
|
-
}
|
|
2631
|
-
return "user allowed filesystem access for this session";
|
|
2632
|
-
}
|
|
2633
|
-
|
|
2634
|
-
if (reason === "explicit-deny-read") return "filesystem read matched a deny-read rule";
|
|
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
|
-
}
|
|
2639
|
-
if (reason === "already-approved-still-failed") {
|
|
2640
|
-
return "filesystem access was previously allowed for this session but is still failing";
|
|
2641
|
-
}
|
|
2642
|
-
if (reason === "missing-allow-write") {
|
|
2643
|
-
return violation.kind === "unknown"
|
|
2644
|
-
? "filesystem access fell outside the current allow-write paths"
|
|
2645
|
-
: "filesystem write fell outside the current allow-write paths";
|
|
2646
|
-
}
|
|
2647
|
-
return "sandbox blocked filesystem access";
|
|
2648
|
-
}
|
|
2649
|
-
|
|
2650
|
-
function buildNetworkBlockCommand(reason: SandboxEventReason, host: string): string | undefined {
|
|
2651
|
-
if (reason === "explicit-deny-domain") {
|
|
2652
|
-
return `/sandbox network deny remove ${escapeSlashCommandArg(host)}`;
|
|
2653
|
-
}
|
|
2654
|
-
if (reason === "missing-allowed-domain") {
|
|
2655
|
-
return `/sandbox network allow add ${escapeSlashCommandArg(host)}`;
|
|
2656
|
-
}
|
|
2657
|
-
return undefined;
|
|
2658
|
-
}
|
|
2659
|
-
|
|
2660
|
-
function describeNetworkEventSummary(
|
|
2661
|
-
reason: SandboxEventReason,
|
|
2662
|
-
outcome: SandboxEventOutcome,
|
|
2663
|
-
): string {
|
|
2664
|
-
if (outcome === "allowed") return "user allowed network domain for this session";
|
|
2665
|
-
if (reason === "explicit-deny-domain") return "network access matched a deny list entry";
|
|
2666
|
-
if (reason === "missing-allowed-domain")
|
|
2667
|
-
return "network access target is not in the allowed domain list";
|
|
2668
|
-
return "sandbox blocked network access";
|
|
2669
|
-
}
|
|
2670
|
-
|
|
2671
|
-
function formatSandboxEventTimestamp(timestamp: number): string {
|
|
2672
|
-
const date = new Date(timestamp);
|
|
2673
|
-
const hours = `${date.getHours()}`.padStart(2, "0");
|
|
2674
|
-
const minutes = `${date.getMinutes()}`.padStart(2, "0");
|
|
2675
|
-
const seconds = `${date.getSeconds()}`.padStart(2, "0");
|
|
2676
|
-
return `${hours}:${minutes}:${seconds}`;
|
|
2677
|
-
}
|
|
2678
|
-
|
|
2679
|
-
function describeSandboxRuntimeState(state: SandboxState, promptMode: PromptMode): string {
|
|
2680
|
-
if (state.status === "active") return `active (${promptMode})`;
|
|
2681
|
-
if (state.status === "pending") return "pending";
|
|
2682
|
-
if (state.status === "suspended") return `suspended (${promptMode})`;
|
|
2683
|
-
if (state.status === "bypassed") {
|
|
2684
|
-
if (state.reason === "no-sandbox-flag") return "bypassed (--no-sandbox)";
|
|
2685
|
-
if (state.reason === "config-disabled") return "bypassed (config disabled)";
|
|
2686
|
-
return "bypassed (missing dependencies)";
|
|
2687
|
-
}
|
|
2688
|
-
return state.reason === "unsupported-platform"
|
|
2689
|
-
? "blocked (unsupported platform)"
|
|
2690
|
-
: "blocked (init failed)";
|
|
2691
|
-
}
|
|
2692
|
-
|
|
2693
|
-
function renderSandboxDoctorReport(options: {
|
|
2694
|
-
state: SandboxState;
|
|
2695
|
-
promptMode: PromptMode;
|
|
2696
|
-
configPaths: SandboxConfigPath[];
|
|
2697
|
-
events: SandboxEvent[];
|
|
2698
|
-
}): string {
|
|
2699
|
-
const { state, promptMode, configPaths, events } = options;
|
|
2700
|
-
const lines = ["Sandbox doctor", `- Runtime: ${describeSandboxRuntimeState(state, promptMode)}`];
|
|
2701
|
-
|
|
2702
|
-
if (configPaths.length === 0) {
|
|
2703
|
-
lines.push("- Config paths: (none loaded)");
|
|
2704
|
-
} else {
|
|
2705
|
-
lines.push("- Config paths:");
|
|
2706
|
-
for (const configPath of configPaths) {
|
|
2707
|
-
const status =
|
|
2708
|
-
configPath.status === "loaded"
|
|
2709
|
-
? "loaded"
|
|
2710
|
-
: configPath.status === "skipped-untrusted"
|
|
2711
|
-
? "skipped (project not trusted)"
|
|
2712
|
-
: "parse error";
|
|
2713
|
-
lines.push(` - ${configPath.label}: ${configPath.path} (${status})`);
|
|
2714
|
-
}
|
|
2715
|
-
}
|
|
2716
|
-
|
|
2717
|
-
lines.push(`- Events: ${events.length}`);
|
|
2718
|
-
|
|
2719
|
-
if (events.length === 0) {
|
|
2720
|
-
lines.push("", "- No sandbox events recorded in this session.");
|
|
2721
|
-
return lines.join("\n");
|
|
2722
|
-
}
|
|
2723
|
-
|
|
2724
|
-
for (const event of [...events].reverse()) {
|
|
2725
|
-
lines.push(
|
|
2726
|
-
"",
|
|
2727
|
-
`- [${formatSandboxEventTimestamp(event.timestamp)}] [${event.kind}] [${event.outcome}] ${event.reason}`,
|
|
2728
|
-
...(event.target ? [` Target: ${event.target}`] : []),
|
|
2729
|
-
...(event.command ? [` Command: ${event.command}`] : []),
|
|
2730
|
-
` Summary: ${event.summary}`,
|
|
2731
|
-
...(event.suggestedCommand
|
|
2732
|
-
? [" Suggested session fix:", ` ${event.suggestedCommand}`]
|
|
2733
|
-
: []),
|
|
2734
|
-
);
|
|
2735
|
-
}
|
|
2736
|
-
|
|
2737
|
-
return lines.join("\n");
|
|
2738
|
-
}
|
|
2739
|
-
|
|
2740
|
-
function getSandboxConfigParseErrors(paths: SandboxConfigPath[]): SandboxConfigPath[] {
|
|
2741
|
-
return paths.filter((configPath) => configPath.status === "parse-error");
|
|
2742
|
-
}
|
|
2743
|
-
|
|
2744
|
-
function getSkippedUntrustedProjectConfigPaths(paths: SandboxConfigPath[]): SandboxConfigPath[] {
|
|
2745
|
-
return paths.filter((configPath) => configPath.status === "skipped-untrusted");
|
|
2746
|
-
}
|
|
2747
|
-
|
|
2748
|
-
function notifySandboxConfigParseErrors(ctx: ExtensionContext, paths: SandboxConfigPath[]): void {
|
|
2749
|
-
const details = paths
|
|
2750
|
-
.map((configPath) => `${configPath.label.toLowerCase()} (${configPath.path})`)
|
|
2751
|
-
.join(", ");
|
|
2752
|
-
notify(ctx, `Could not parse sandbox config: ${details}`, "warning");
|
|
2753
|
-
}
|
|
2754
|
-
|
|
2755
|
-
function requestShutdownWithError(ctx: ExtensionContext, message: string): void {
|
|
2756
|
-
// Validate during session_start so we can use Pi's registered flag values and shutdown path.
|
|
2757
|
-
// This briefly renders startup UI, but Pi exits through its normal cleanup path instead of
|
|
2758
|
-
// leaving the terminal in a broken state.
|
|
2759
|
-
notify(ctx, message, "error");
|
|
2760
|
-
process.exitCode = 1;
|
|
2761
|
-
ctx.shutdown();
|
|
2762
|
-
}
|
|
2763
|
-
|
|
2764
|
-
function loadSandboxConfigForContext(
|
|
2765
|
-
ctx: ExtensionContext,
|
|
2766
|
-
cwd: string,
|
|
2767
|
-
overrideConfigPath: string | undefined,
|
|
2768
|
-
options: { exitOnError?: boolean } = {},
|
|
2769
|
-
): LoadedSandboxConfig | null {
|
|
2770
|
-
const { exitOnError = false } = options;
|
|
2771
|
-
|
|
2772
|
-
try {
|
|
2773
|
-
return loadConfig(cwd, overrideConfigPath, { projectTrusted: ctx.isProjectTrusted() });
|
|
2774
|
-
} catch (error) {
|
|
2775
|
-
if (!(error instanceof SandboxConfigLoadError)) throw error;
|
|
2776
|
-
|
|
2777
|
-
if (exitOnError) {
|
|
2778
|
-
requestShutdownWithError(ctx, error.message);
|
|
2779
|
-
} else {
|
|
2780
|
-
notify(ctx, error.message, "error");
|
|
2781
|
-
}
|
|
2782
|
-
return null;
|
|
2783
|
-
}
|
|
2784
|
-
}
|
|
2785
|
-
|
|
2786
|
-
function getSandboxDependencyErrors(config: SandboxConfig): string[] {
|
|
2787
|
-
return SandboxManager.checkDependencies(config.ripgrep).errors;
|
|
2788
|
-
}
|
|
58
|
+
createBashToolDefinition,
|
|
59
|
+
type ExtensionAPI,
|
|
60
|
+
type ExtensionContext,
|
|
61
|
+
} from "@earendil-works/pi-coding-agent";
|
|
62
|
+
import { Type } from "typebox";
|
|
2789
63
|
|
|
2790
|
-
|
|
2791
|
-
|
|
2792
|
-
}
|
|
64
|
+
import { createSandboxedBashOps, createUnsandboxedBashOps } from "./bash.js";
|
|
65
|
+
import { registerSandboxCommand } from "./command.js";
|
|
66
|
+
import { isUnsandboxedApproval, showUnsandboxedApproval } from "./permissions/unsandboxed.js";
|
|
67
|
+
import { createSandboxRuntime, getSandboxRunMode } from "./runtime.js";
|
|
2793
68
|
|
|
2794
|
-
export default function (pi: ExtensionAPI) {
|
|
69
|
+
export default function sandboxExtension(pi: ExtensionAPI): void {
|
|
2795
70
|
pi.registerFlag("no-sandbox", {
|
|
2796
71
|
description: "Disable OS-level sandboxing for bash commands",
|
|
2797
72
|
type: "boolean",
|
|
@@ -2804,265 +79,136 @@ export default function (pi: ExtensionAPI) {
|
|
|
2804
79
|
type: "string",
|
|
2805
80
|
});
|
|
2806
81
|
|
|
2807
|
-
|
|
2808
|
-
|
|
2809
|
-
|
|
2810
|
-
|
|
2811
|
-
|
|
2812
|
-
|
|
2813
|
-
|
|
2814
|
-
|
|
2815
|
-
|
|
2816
|
-
const pendingNetworkApprovals = new Map<string, Promise<boolean>>();
|
|
2817
|
-
|
|
2818
|
-
function recordSandboxEvent(event: SandboxEvent): void {
|
|
2819
|
-
sandboxEvents.push(event);
|
|
2820
|
-
if (sandboxEvents.length > SANDBOX_EVENT_LIMIT) {
|
|
2821
|
-
sandboxEvents.splice(0, sandboxEvents.length - SANDBOX_EVENT_LIMIT);
|
|
2822
|
-
}
|
|
2823
|
-
}
|
|
2824
|
-
|
|
2825
|
-
function recordNetworkEvent(
|
|
2826
|
-
outcome: SandboxEventOutcome,
|
|
2827
|
-
reason: SandboxEventReason,
|
|
2828
|
-
host: string,
|
|
2829
|
-
port?: number,
|
|
2830
|
-
): void {
|
|
2831
|
-
const target = port ? `${host}:${port}` : host;
|
|
2832
|
-
recordSandboxEvent({
|
|
2833
|
-
timestamp: Date.now(),
|
|
2834
|
-
kind: "network",
|
|
2835
|
-
outcome,
|
|
2836
|
-
reason,
|
|
2837
|
-
target,
|
|
2838
|
-
cwd: sessionCwd,
|
|
2839
|
-
summary: describeNetworkEventSummary(reason, outcome),
|
|
2840
|
-
suggestedCommand: outcome === "blocked" ? buildNetworkBlockCommand(reason, host) : undefined,
|
|
2841
|
-
});
|
|
2842
|
-
}
|
|
2843
|
-
|
|
2844
|
-
function recordRuntimeEvent(
|
|
2845
|
-
kind: SandboxEventKind,
|
|
2846
|
-
reason: SandboxEventReason,
|
|
2847
|
-
summary: string,
|
|
2848
|
-
): void {
|
|
2849
|
-
recordSandboxEvent({
|
|
2850
|
-
timestamp: Date.now(),
|
|
2851
|
-
kind,
|
|
2852
|
-
outcome: "blocked",
|
|
2853
|
-
reason,
|
|
2854
|
-
cwd: sessionCwd,
|
|
2855
|
-
summary,
|
|
2856
|
-
});
|
|
2857
|
-
}
|
|
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
|
-
|
|
2877
|
-
function applyRuntimeConfigForSession(
|
|
2878
|
-
ctx: ExtensionContext,
|
|
2879
|
-
runtimeConfig: SandboxRuntimeConfig,
|
|
2880
|
-
): void {
|
|
2881
|
-
const nextConfig = cloneRuntimeConfig(runtimeConfig);
|
|
2882
|
-
sandboxState = { status: "active", runtimeConfig: nextConfig };
|
|
2883
|
-
SandboxManager.updateConfig(nextConfig);
|
|
2884
|
-
setSandboxStatus(ctx, true, nextConfig, promptMode);
|
|
2885
|
-
}
|
|
2886
|
-
|
|
2887
|
-
const createNetworkAskCallback = (): SandboxAskCallback => {
|
|
2888
|
-
return async ({ host, port }) => {
|
|
2889
|
-
if (sandboxState.status === "suspended") return true;
|
|
2890
|
-
|
|
2891
|
-
const normalizedHost = host.toLowerCase();
|
|
2892
|
-
const key = normalizedHost;
|
|
2893
|
-
|
|
2894
|
-
const existingDecision = pendingNetworkApprovals.get(key);
|
|
2895
|
-
if (existingDecision) return existingDecision;
|
|
2896
|
-
|
|
2897
|
-
const decision = (async () => {
|
|
2898
|
-
try {
|
|
2899
|
-
const initialConfig = getStateRuntimeConfig(sandboxState);
|
|
2900
|
-
if (!initialConfig) return false;
|
|
2901
|
-
|
|
2902
|
-
if (initialConfig.network.allowedDomains.includes(normalizedHost)) return true;
|
|
2903
|
-
if (initialConfig.network.deniedDomains.includes(normalizedHost)) {
|
|
2904
|
-
recordNetworkEvent("blocked", "explicit-deny-domain", normalizedHost, port);
|
|
2905
|
-
return false;
|
|
2906
|
-
}
|
|
2907
|
-
|
|
2908
|
-
const suggestedCommand = buildNetworkBlockCommand(
|
|
2909
|
-
"missing-allowed-domain",
|
|
2910
|
-
normalizedHost,
|
|
2911
|
-
);
|
|
2912
|
-
const ctx = sessionContext;
|
|
2913
|
-
if (promptMode === "non-interactive" || !ctx || !ctx.hasUI) {
|
|
2914
|
-
recordNetworkEvent("blocked", "missing-allowed-domain", normalizedHost, port);
|
|
2915
|
-
const message = `Sandbox blocked network access to ${normalizedHost}. To temporarily allow for this session, run: ${suggestedCommand}`;
|
|
2916
|
-
if (ctx) notify(ctx, message, "warning");
|
|
2917
|
-
else console.warn(message);
|
|
2918
|
-
return false;
|
|
2919
|
-
}
|
|
2920
|
-
|
|
2921
|
-
const target = port ? `${normalizedHost}:${port}` : normalizedHost;
|
|
2922
|
-
const approved = await withPromptSignal(pi, () =>
|
|
2923
|
-
ctx.ui.confirm(
|
|
2924
|
-
`Sandbox blocked network access to ${target}`,
|
|
2925
|
-
"\nAllow for this session?",
|
|
2926
|
-
),
|
|
2927
|
-
);
|
|
2928
|
-
if (!approved) {
|
|
2929
|
-
recordNetworkEvent("blocked", "missing-allowed-domain", normalizedHost, port);
|
|
2930
|
-
return false;
|
|
2931
|
-
}
|
|
2932
|
-
|
|
2933
|
-
const latestConfig = getStateRuntimeConfig(sandboxState);
|
|
2934
|
-
if (!latestConfig) return false;
|
|
2935
|
-
if (latestConfig.network.deniedDomains.includes(normalizedHost)) {
|
|
2936
|
-
recordNetworkEvent("blocked", "explicit-deny-domain", normalizedHost, port);
|
|
2937
|
-
notify(
|
|
2938
|
-
ctx,
|
|
2939
|
-
`Network access to ${normalizedHost} remains denied by current sandbox policy. Remove it from deny list to allow.`,
|
|
2940
|
-
"warning",
|
|
2941
|
-
);
|
|
2942
|
-
return false;
|
|
2943
|
-
}
|
|
2944
|
-
if (latestConfig.network.allowedDomains.includes(normalizedHost)) {
|
|
2945
|
-
recordNetworkEvent("allowed", "missing-allowed-domain", normalizedHost, port);
|
|
2946
|
-
return true;
|
|
2947
|
-
}
|
|
2948
|
-
|
|
2949
|
-
const nextConfig = cloneRuntimeConfig(latestConfig);
|
|
2950
|
-
const changed = mutateStringList(
|
|
2951
|
-
nextConfig.network.allowedDomains,
|
|
2952
|
-
"add",
|
|
2953
|
-
normalizedHost,
|
|
2954
|
-
);
|
|
2955
|
-
if (changed) {
|
|
2956
|
-
applyRuntimeConfigForSession(ctx, nextConfig);
|
|
2957
|
-
}
|
|
2958
|
-
|
|
2959
|
-
recordNetworkEvent("allowed", "missing-allowed-domain", normalizedHost, port);
|
|
2960
|
-
notify(ctx, `Allowed network domain for this session: ${normalizedHost}`, "info");
|
|
2961
|
-
return true;
|
|
2962
|
-
} catch (error) {
|
|
2963
|
-
const ctx = sessionContext;
|
|
2964
|
-
const message = `Sandbox permission prompt failed for ${normalizedHost}: ${error instanceof Error ? error.message : error}`;
|
|
2965
|
-
if (ctx) notify(ctx, message, "warning");
|
|
2966
|
-
else console.warn(message);
|
|
2967
|
-
return false;
|
|
2968
|
-
}
|
|
2969
|
-
})();
|
|
2970
|
-
|
|
2971
|
-
pendingNetworkApprovals.set(key, decision);
|
|
2972
|
-
try {
|
|
2973
|
-
return await decision;
|
|
2974
|
-
} finally {
|
|
2975
|
-
pendingNetworkApprovals.delete(key);
|
|
2976
|
-
}
|
|
82
|
+
const runtime = createSandboxRuntime(pi);
|
|
83
|
+
pi.events.on("subagent:unsandboxed-approval", (data) => {
|
|
84
|
+
const request = data as {
|
|
85
|
+
ctx?: ExtensionContext;
|
|
86
|
+
title?: unknown;
|
|
87
|
+
choices?: unknown;
|
|
88
|
+
signal?: AbortSignal;
|
|
89
|
+
result?: Promise<string | undefined>;
|
|
2977
90
|
};
|
|
2978
|
-
|
|
2979
|
-
|
|
2980
|
-
|
|
2981
|
-
|
|
2982
|
-
|
|
2983
|
-
|
|
2984
|
-
|
|
2985
|
-
|
|
2986
|
-
const dependencyErrors = getSandboxDependencyErrors(config);
|
|
2987
|
-
if (dependencyErrors.length > 0) {
|
|
2988
|
-
promptMode = DEFAULT_PROMPT_MODE;
|
|
2989
|
-
pendingNetworkApprovals.clear();
|
|
2990
|
-
sandboxState = { status: "bypassed", reason: "missing-dependencies" };
|
|
2991
|
-
recordRuntimeEvent(
|
|
2992
|
-
"init",
|
|
2993
|
-
"missing-dependencies",
|
|
2994
|
-
`sandbox dependencies missing: ${dependencyErrors.join("; ")}`,
|
|
2995
|
-
);
|
|
2996
|
-
setSandboxStatus(ctx, false);
|
|
2997
|
-
notify(ctx, formatMissingSandboxDependenciesWarning(dependencyErrors), "warning");
|
|
2998
|
-
return null;
|
|
91
|
+
if (
|
|
92
|
+
!request?.ctx ||
|
|
93
|
+
typeof request.title !== "string" ||
|
|
94
|
+
!Array.isArray(request.choices) ||
|
|
95
|
+
!isUnsandboxedApproval(request.choices)
|
|
96
|
+
) {
|
|
97
|
+
return;
|
|
2999
98
|
}
|
|
3000
|
-
|
|
3001
|
-
const
|
|
3002
|
-
|
|
3003
|
-
|
|
3004
|
-
|
|
3005
|
-
|
|
3006
|
-
|
|
3007
|
-
|
|
3008
|
-
|
|
3009
|
-
|
|
3010
|
-
|
|
3011
|
-
|
|
3012
|
-
|
|
3013
|
-
|
|
3014
|
-
|
|
3015
|
-
|
|
3016
|
-
|
|
99
|
+
const { ctx, title } = request;
|
|
100
|
+
const choices = [...request.choices];
|
|
101
|
+
request.result = runtime
|
|
102
|
+
.withPermissionContext(ctx, request.signal, async (_ctx, signal) => {
|
|
103
|
+
if (
|
|
104
|
+
!ctx.hasUI ||
|
|
105
|
+
runtime.state.status !== "active" ||
|
|
106
|
+
runtime.promptMode !== "interactive"
|
|
107
|
+
) {
|
|
108
|
+
return undefined;
|
|
109
|
+
}
|
|
110
|
+
// The subagent already owns the parent's permission queue. Use the original UI,
|
|
111
|
+
// not the invocation wrapper, to avoid recursively entering that queue.
|
|
112
|
+
const choice =
|
|
113
|
+
ctx.mode === "tui"
|
|
114
|
+
? await showUnsandboxedApproval(ctx, title, signal)
|
|
115
|
+
: ctx.mode === "rpc"
|
|
116
|
+
? await ctx.ui.select(title, choices, { signal })
|
|
117
|
+
: undefined;
|
|
118
|
+
return signal.aborted ||
|
|
119
|
+
runtime.state.status !== "active" ||
|
|
120
|
+
runtime.promptMode !== "interactive"
|
|
121
|
+
? undefined
|
|
122
|
+
: choice;
|
|
123
|
+
})
|
|
124
|
+
.catch(() => undefined);
|
|
125
|
+
});
|
|
126
|
+
pi.events.on("subagent:sandbox", (data) => {
|
|
127
|
+
const handoff = data as { config?: unknown; extension?: string; error?: string };
|
|
128
|
+
const policy = runtime.getRuntimeConfig();
|
|
129
|
+
if (runtime.state.status === "active" && policy) {
|
|
130
|
+
const cloned = structuredClone(policy);
|
|
131
|
+
handoff.config = {
|
|
132
|
+
...cloned,
|
|
133
|
+
enabled: true,
|
|
134
|
+
mode: runtime.promptMode,
|
|
135
|
+
filesystem: {
|
|
136
|
+
...cloned.filesystem,
|
|
137
|
+
allowTempDirs: false,
|
|
138
|
+
allowGitCommonDir: runtime.config?.filesystem.allowGitCommonDir ?? false,
|
|
139
|
+
},
|
|
140
|
+
};
|
|
141
|
+
} else if (runtime.state.status === "bypassed" || runtime.state.status === "suspended") {
|
|
142
|
+
handoff.config = { enabled: false };
|
|
143
|
+
} else {
|
|
144
|
+
handoff.error = "Sandbox is not ready. Fix its setup before starting a subagent.";
|
|
3017
145
|
}
|
|
3018
|
-
|
|
3019
|
-
|
|
146
|
+
handoff.extension = fileURLToPath(import.meta.url);
|
|
147
|
+
});
|
|
3020
148
|
const sandboxedOps = createSandboxedBashOps({
|
|
3021
|
-
|
|
3022
|
-
|
|
3023
|
-
|
|
3024
|
-
|
|
3025
|
-
|
|
3026
|
-
|
|
3027
|
-
recordEvent: recordSandboxEvent,
|
|
149
|
+
getContext: () => runtime.context,
|
|
150
|
+
getSandboxConfig: () => runtime.config,
|
|
151
|
+
getRuntimeConfig: () => runtime.getRuntimeConfig(),
|
|
152
|
+
getPromptMode: () => runtime.promptMode,
|
|
153
|
+
applyRuntimeConfigForSession: runtime.applyRuntimeConfigForSession,
|
|
154
|
+
recordEvent: runtime.recordEvent,
|
|
3028
155
|
});
|
|
3029
156
|
|
|
3030
|
-
|
|
3031
|
-
|
|
3032
|
-
|
|
3033
|
-
const rebuildBashTools = (cwd: string): void => {
|
|
3034
|
-
sessionCwd = cwd;
|
|
3035
|
-
localBashTool = createBashTool(sessionCwd);
|
|
3036
|
-
sandboxedBashTool = createBashTool(sessionCwd, { operations: sandboxedOps });
|
|
3037
|
-
};
|
|
3038
|
-
|
|
3039
|
-
const resetRuntimeState = (): void => {
|
|
3040
|
-
sandboxState = { status: "pending" };
|
|
3041
|
-
sandboxConfig = null;
|
|
3042
|
-
promptMode = DEFAULT_PROMPT_MODE;
|
|
3043
|
-
sandboxConfigPaths = [];
|
|
3044
|
-
sandboxEvents = [];
|
|
3045
|
-
pendingNetworkApprovals.clear();
|
|
3046
|
-
};
|
|
3047
|
-
|
|
3048
|
-
const isSupportedPlatform = (): boolean =>
|
|
3049
|
-
process.platform === "darwin" || process.platform === "linux";
|
|
157
|
+
const localBashTool = createBashToolDefinition(process.cwd());
|
|
158
|
+
const sandboxedBashTool = createBashToolDefinition(process.cwd(), { operations: sandboxedOps });
|
|
3050
159
|
|
|
3051
160
|
pi.registerTool({
|
|
3052
161
|
...localBashTool,
|
|
3053
162
|
label: "bash (sandbox-aware)",
|
|
163
|
+
description:
|
|
164
|
+
localBashTool.description +
|
|
165
|
+
" Set requestUnsandboxed to true to request fresh human approval for this invocation outside the sandbox. Requires an active sandbox, interactive permission mode, and human UI. Never retries or changes sandbox policy.",
|
|
166
|
+
parameters: Type.Object({
|
|
167
|
+
...localBashTool.parameters.properties,
|
|
168
|
+
requestUnsandboxed: Type.Optional(
|
|
169
|
+
Type.Boolean({
|
|
170
|
+
description:
|
|
171
|
+
"Request human approval to run this command and its descendants once outside Tau's OS sandbox restrictions (default false).",
|
|
172
|
+
}),
|
|
173
|
+
),
|
|
174
|
+
}),
|
|
3054
175
|
async execute(id, params, signal, onUpdate, ctx) {
|
|
3055
|
-
|
|
3056
|
-
|
|
3057
|
-
|
|
176
|
+
const { command, timeout, requestUnsandboxed } = params;
|
|
177
|
+
if (requestUnsandboxed !== undefined && typeof requestUnsandboxed !== "boolean") {
|
|
178
|
+
throw new Error("requestUnsandboxed must be a boolean.");
|
|
179
|
+
}
|
|
180
|
+
if (requestUnsandboxed === true) {
|
|
181
|
+
return runtime.withPermissionContext(ctx, signal, (invocationContext, invocationSignal) => {
|
|
182
|
+
const operations = createUnsandboxedBashOps(
|
|
183
|
+
runtime,
|
|
184
|
+
sandboxedOps,
|
|
185
|
+
invocationContext,
|
|
186
|
+
invocationSignal,
|
|
187
|
+
);
|
|
188
|
+
const tool = createBashToolDefinition(ctx.cwd, { operations });
|
|
189
|
+
return tool.execute(
|
|
190
|
+
id,
|
|
191
|
+
{ command, timeout },
|
|
192
|
+
invocationSignal,
|
|
193
|
+
onUpdate,
|
|
194
|
+
invocationContext,
|
|
195
|
+
);
|
|
196
|
+
});
|
|
197
|
+
}
|
|
198
|
+
const state = runtime.state;
|
|
199
|
+
if (state.status !== "active") {
|
|
200
|
+
const allowsUnsandboxed = state.status === "bypassed" || state.status === "suspended";
|
|
3058
201
|
if (!allowsUnsandboxed) {
|
|
3059
|
-
const runMode = getSandboxRunMode(
|
|
202
|
+
const runMode = getSandboxRunMode(state);
|
|
3060
203
|
|
|
3061
204
|
let reason =
|
|
3062
205
|
"Sandbox is not active and unsandboxed execution is blocked. Fix sandbox setup and run /sandbox enable, or restart with --no-sandbox.";
|
|
3063
206
|
if (runMode === "unsupported-platform") {
|
|
3064
207
|
reason =
|
|
3065
208
|
"Sandbox is unsupported on this platform. Re-run with --no-sandbox to allow unsandboxed execution.";
|
|
209
|
+
} else if (runMode === "missing-dependencies") {
|
|
210
|
+
reason =
|
|
211
|
+
"Sandbox dependencies are missing. Fix sandbox setup and run /sandbox enable, or restart with --no-sandbox to allow unsandboxed execution.";
|
|
3066
212
|
} else if (runMode === "init-failed") {
|
|
3067
213
|
reason =
|
|
3068
214
|
"Sandbox initialization failed. Run /sandbox enable to retry, or restart with --no-sandbox.";
|
|
@@ -3073,373 +219,21 @@ export default function (pi: ExtensionAPI) {
|
|
|
3073
219
|
|
|
3074
220
|
throw new Error(reason);
|
|
3075
221
|
}
|
|
3076
|
-
return localBashTool.execute(id, params, signal, onUpdate);
|
|
222
|
+
return localBashTool.execute(id, params, signal, onUpdate, ctx);
|
|
3077
223
|
}
|
|
3078
224
|
|
|
3079
|
-
|
|
3080
|
-
return sandboxedBashTool.execute(id, params, signal, onUpdate);
|
|
225
|
+
runtime.captureContext(ctx);
|
|
226
|
+
return sandboxedBashTool.execute(id, params, signal, onUpdate, ctx);
|
|
3081
227
|
},
|
|
3082
228
|
});
|
|
3083
229
|
|
|
3084
230
|
pi.on("session_start", async (_event, ctx) => {
|
|
3085
|
-
|
|
3086
|
-
sessionContext = ctx;
|
|
3087
|
-
resetRuntimeState();
|
|
3088
|
-
rebuildBashTools(ctx.cwd);
|
|
3089
|
-
|
|
3090
|
-
const noSandbox = pi.getFlag("no-sandbox") as boolean;
|
|
3091
|
-
if (noSandbox) {
|
|
3092
|
-
sandboxState = { status: "bypassed", reason: "no-sandbox-flag" };
|
|
3093
|
-
notify(ctx, "Sandbox disabled via --no-sandbox", "warning");
|
|
3094
|
-
return;
|
|
3095
|
-
}
|
|
3096
|
-
|
|
3097
|
-
const sandboxConfigOverride = getStringFlag(pi, "sandbox-config");
|
|
3098
|
-
const loadedConfig = loadSandboxConfigForContext(ctx, ctx.cwd, sandboxConfigOverride, {
|
|
3099
|
-
exitOnError: true,
|
|
3100
|
-
});
|
|
3101
|
-
if (!loadedConfig) return;
|
|
3102
|
-
|
|
3103
|
-
sandboxConfigPaths = loadedConfig.paths;
|
|
3104
|
-
notifySkippedUntrustedProjectConfigs(ctx);
|
|
3105
|
-
const parseErrors = getSandboxConfigParseErrors(sandboxConfigPaths);
|
|
3106
|
-
if (parseErrors.length > 0) {
|
|
3107
|
-
notifySandboxConfigParseErrors(ctx, parseErrors);
|
|
3108
|
-
}
|
|
3109
|
-
const config = loadedConfig.config;
|
|
3110
|
-
sandboxConfig = config;
|
|
3111
|
-
|
|
3112
|
-
if (!config.enabled) {
|
|
3113
|
-
sandboxState = { status: "bypassed", reason: "config-disabled" };
|
|
3114
|
-
notify(ctx, "Sandbox disabled via config", "info");
|
|
3115
|
-
return;
|
|
3116
|
-
}
|
|
3117
|
-
|
|
3118
|
-
if (!isSupportedPlatform()) {
|
|
3119
|
-
sandboxState = { status: "blocked", reason: "unsupported-platform" };
|
|
3120
|
-
recordRuntimeEvent(
|
|
3121
|
-
"init",
|
|
3122
|
-
"unsupported-platform",
|
|
3123
|
-
`sandbox not supported on ${process.platform}`,
|
|
3124
|
-
);
|
|
3125
|
-
notify(ctx, `Sandbox not supported on ${process.platform}`, "warning");
|
|
3126
|
-
return;
|
|
3127
|
-
}
|
|
3128
|
-
|
|
3129
|
-
const runtimeConfig = await initializeSandboxRuntime(ctx, config);
|
|
3130
|
-
if (!runtimeConfig) return;
|
|
3131
|
-
|
|
3132
|
-
setSandboxStatus(ctx, true, runtimeConfig, promptMode);
|
|
3133
|
-
notify(ctx, "Sandbox initialized", "info");
|
|
231
|
+
await runtime.start(ctx);
|
|
3134
232
|
});
|
|
3135
233
|
|
|
3136
234
|
pi.on("session_shutdown", async (_event, ctx) => {
|
|
3137
|
-
|
|
3138
|
-
if (getStateRuntimeConfig(sandboxState)) {
|
|
3139
|
-
try {
|
|
3140
|
-
await SandboxManager.reset();
|
|
3141
|
-
} catch {
|
|
3142
|
-
// Ignore cleanup errors
|
|
3143
|
-
}
|
|
3144
|
-
}
|
|
3145
|
-
|
|
3146
|
-
resetRuntimeState();
|
|
3147
|
-
sessionContext = null;
|
|
3148
|
-
rebuildBashTools(process.cwd());
|
|
235
|
+
await runtime.shutdown(ctx);
|
|
3149
236
|
});
|
|
3150
237
|
|
|
3151
|
-
pi
|
|
3152
|
-
description: "Manage sandbox runtime overrides",
|
|
3153
|
-
getArgumentCompletions: (prefix) =>
|
|
3154
|
-
getSandboxArgumentCompletions(prefix, getStateRuntimeConfig(sandboxState)),
|
|
3155
|
-
handler: async (args, ctx) => {
|
|
3156
|
-
const tokens = parseCommandArgs(args);
|
|
3157
|
-
const subcommand = normalizeSubcommand(tokens[0]);
|
|
3158
|
-
|
|
3159
|
-
if (!subcommand || subcommand === "help") {
|
|
3160
|
-
showHelp(ctx);
|
|
3161
|
-
return;
|
|
3162
|
-
}
|
|
3163
|
-
|
|
3164
|
-
if (subcommand === "doctor") {
|
|
3165
|
-
if (tokens.length > 1) {
|
|
3166
|
-
notify(ctx, "Usage: /sandbox doctor", "warning");
|
|
3167
|
-
return;
|
|
3168
|
-
}
|
|
3169
|
-
|
|
3170
|
-
notify(
|
|
3171
|
-
ctx,
|
|
3172
|
-
renderSandboxDoctorReport({
|
|
3173
|
-
state: sandboxState,
|
|
3174
|
-
promptMode,
|
|
3175
|
-
configPaths: sandboxConfigPaths,
|
|
3176
|
-
events: sandboxEvents,
|
|
3177
|
-
}),
|
|
3178
|
-
"info",
|
|
3179
|
-
);
|
|
3180
|
-
return;
|
|
3181
|
-
}
|
|
3182
|
-
|
|
3183
|
-
if (subcommand === "enable") {
|
|
3184
|
-
if (tokens.length > 1) {
|
|
3185
|
-
notify(ctx, "Usage: /sandbox enable|on", "warning");
|
|
3186
|
-
return;
|
|
3187
|
-
}
|
|
3188
|
-
|
|
3189
|
-
if (sandboxState.status === "active") {
|
|
3190
|
-
notify(ctx, "Sandbox is already enabled", "info");
|
|
3191
|
-
return;
|
|
3192
|
-
}
|
|
3193
|
-
|
|
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
|
-
}
|
|
3204
|
-
|
|
3205
|
-
const sandboxConfigOverride = getStringFlag(pi, "sandbox-config");
|
|
3206
|
-
const loadedConfig = loadSandboxConfigForContext(ctx, ctx.cwd, sandboxConfigOverride);
|
|
3207
|
-
if (!loadedConfig) return;
|
|
3208
|
-
|
|
3209
|
-
sandboxConfigPaths = loadedConfig.paths;
|
|
3210
|
-
notifySkippedUntrustedProjectConfigs(ctx);
|
|
3211
|
-
const parseErrors = getSandboxConfigParseErrors(sandboxConfigPaths);
|
|
3212
|
-
if (parseErrors.length > 0) {
|
|
3213
|
-
notifySandboxConfigParseErrors(ctx, parseErrors);
|
|
3214
|
-
}
|
|
3215
|
-
sandboxConfig = loadedConfig.config;
|
|
3216
|
-
const runtimeConfig = await initializeSandboxRuntime(ctx, loadedConfig.config);
|
|
3217
|
-
if (!runtimeConfig) return;
|
|
3218
|
-
|
|
3219
|
-
setSandboxStatus(ctx, true, runtimeConfig, promptMode);
|
|
3220
|
-
|
|
3221
|
-
announceSandboxState(pi, ctx, true);
|
|
3222
|
-
return;
|
|
3223
|
-
}
|
|
3224
|
-
|
|
3225
|
-
if (subcommand === "disable") {
|
|
3226
|
-
if (tokens.length > 1) {
|
|
3227
|
-
notify(ctx, "Usage: /sandbox disable|off", "warning");
|
|
3228
|
-
return;
|
|
3229
|
-
}
|
|
3230
|
-
|
|
3231
|
-
if (sandboxState.status !== "active") {
|
|
3232
|
-
notify(ctx, `Sandbox is not active (mode: ${getSandboxRunMode(sandboxState)})`, "info");
|
|
3233
|
-
return;
|
|
3234
|
-
}
|
|
3235
|
-
|
|
3236
|
-
sandboxState = { status: "suspended" };
|
|
3237
|
-
pendingNetworkApprovals.clear();
|
|
3238
|
-
setSandboxStatus(ctx, false);
|
|
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);
|
|
3252
|
-
return;
|
|
3253
|
-
}
|
|
3254
|
-
|
|
3255
|
-
if (subcommand === "show") {
|
|
3256
|
-
if (tokens.length > 1) {
|
|
3257
|
-
notify(ctx, "Usage: /sandbox show", "warning");
|
|
3258
|
-
return;
|
|
3259
|
-
}
|
|
3260
|
-
|
|
3261
|
-
if (sandboxState.status !== "active") {
|
|
3262
|
-
notify(ctx, `Sandbox is disabled (mode: ${getSandboxRunMode(sandboxState)})`, "info");
|
|
3263
|
-
return;
|
|
3264
|
-
}
|
|
3265
|
-
|
|
3266
|
-
const runtimeConfig = sandboxState.runtimeConfig;
|
|
3267
|
-
const lines = [
|
|
3268
|
-
"Sandbox Configuration (session):",
|
|
3269
|
-
` State: enabled`,
|
|
3270
|
-
` Mode: ${promptMode}`,
|
|
3271
|
-
` Runtime state: ${getSandboxRunMode(sandboxState)}`,
|
|
3272
|
-
"",
|
|
3273
|
-
" Network:",
|
|
3274
|
-
` Allowed: ${runtimeConfig.network.allowedDomains.join(", ") || "(none)"}`,
|
|
3275
|
-
` Denied: ${runtimeConfig.network.deniedDomains.join(", ") || "(none)"}`,
|
|
3276
|
-
` allowLocalBinding: ${runtimeConfig.network.allowLocalBinding ? "true" : "false"}`,
|
|
3277
|
-
` allowAllUnixSockets: ${runtimeConfig.network.allowAllUnixSockets ? "true" : "false"}`,
|
|
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
|
-
: []),
|
|
3286
|
-
"",
|
|
3287
|
-
" Filesystem:",
|
|
3288
|
-
` Deny Read: ${runtimeConfig.filesystem.denyRead.join(", ") || "(none)"}`,
|
|
3289
|
-
` Allow Read: ${runtimeConfig.filesystem.allowRead?.join(", ") || "(none)"}`,
|
|
3290
|
-
` Allow Write: ${runtimeConfig.filesystem.allowWrite.join(", ") || "(none)"}`,
|
|
3291
|
-
` Deny Write: ${runtimeConfig.filesystem.denyWrite.join(", ") || "(none)"}`,
|
|
3292
|
-
` allowTempDirs: ${sandboxConfig?.filesystem.allowTempDirs ? "true" : "false"}`,
|
|
3293
|
-
` allowGitConfig: ${runtimeConfig.filesystem.allowGitConfig ? "true" : "false"}`,
|
|
3294
|
-
` allowGitCommonDir: ${sandboxConfig?.filesystem.allowGitCommonDir ? "true" : "false"}`,
|
|
3295
|
-
"",
|
|
3296
|
-
" Advanced:",
|
|
3297
|
-
` ignoreViolations: ${runtimeConfig.ignoreViolations ? "configured" : "(none)"}`,
|
|
3298
|
-
` enableWeakerNestedSandbox: ${runtimeConfig.enableWeakerNestedSandbox ? "true" : "false"}`,
|
|
3299
|
-
` enableWeakerNetworkIsolation: ${runtimeConfig.enableWeakerNetworkIsolation ? "true" : "false"}`,
|
|
3300
|
-
];
|
|
3301
|
-
|
|
3302
|
-
notify(ctx, lines.join("\n"), "info");
|
|
3303
|
-
return;
|
|
3304
|
-
}
|
|
3305
|
-
|
|
3306
|
-
if (subcommand === "mode") {
|
|
3307
|
-
if (tokens.length !== 2) {
|
|
3308
|
-
notify(ctx, "Usage: /sandbox mode <interactive|non-interactive>", "warning");
|
|
3309
|
-
return;
|
|
3310
|
-
}
|
|
3311
|
-
|
|
3312
|
-
const modeToken = tokens[1].toLowerCase();
|
|
3313
|
-
if (modeToken !== "interactive" && modeToken !== "non-interactive") {
|
|
3314
|
-
notify(ctx, "Usage: /sandbox mode <interactive|non-interactive>", "warning");
|
|
3315
|
-
return;
|
|
3316
|
-
}
|
|
3317
|
-
|
|
3318
|
-
promptMode = normalizePromptMode(modeToken);
|
|
3319
|
-
|
|
3320
|
-
if (sandboxState.status === "active") {
|
|
3321
|
-
setSandboxStatus(ctx, true, sandboxState.runtimeConfig, promptMode);
|
|
3322
|
-
}
|
|
3323
|
-
|
|
3324
|
-
notify(ctx, `Sandbox mode set to ${promptMode}`, "info");
|
|
3325
|
-
return;
|
|
3326
|
-
}
|
|
3327
|
-
|
|
3328
|
-
if (subcommand === "network") {
|
|
3329
|
-
const runtimeConfig = requireRuntimeConfig(ctx, sandboxState);
|
|
3330
|
-
if (!runtimeConfig) return;
|
|
3331
|
-
|
|
3332
|
-
const list = tokens[1]?.toLowerCase() as NetworkList | undefined;
|
|
3333
|
-
const op = tokens[2]?.toLowerCase() as ListOp | undefined;
|
|
3334
|
-
const domain = tokens[3]?.trim() ?? "";
|
|
3335
|
-
|
|
3336
|
-
if (
|
|
3337
|
-
(list !== "allow" && list !== "deny") ||
|
|
3338
|
-
(op !== "add" && op !== "remove") ||
|
|
3339
|
-
tokens.length !== 4 ||
|
|
3340
|
-
!domain ||
|
|
3341
|
-
/\s/.test(domain)
|
|
3342
|
-
) {
|
|
3343
|
-
notify(ctx, "Usage: /sandbox network <allow|deny> <add|remove> <domain>", "warning");
|
|
3344
|
-
return;
|
|
3345
|
-
}
|
|
3346
|
-
|
|
3347
|
-
const nextConfig = cloneRuntimeConfig(runtimeConfig);
|
|
3348
|
-
const values =
|
|
3349
|
-
list === "allow" ? nextConfig.network.allowedDomains : nextConfig.network.deniedDomains;
|
|
3350
|
-
const changed = mutateStringList(values, op, domain);
|
|
3351
|
-
if (!changed) {
|
|
3352
|
-
notify(
|
|
3353
|
-
ctx,
|
|
3354
|
-
`No change: network ${list} list already ${op === "add" ? "contains" : "omits"} ${domain}`,
|
|
3355
|
-
);
|
|
3356
|
-
return;
|
|
3357
|
-
}
|
|
3358
|
-
|
|
3359
|
-
applyRuntimeConfigForSession(ctx, nextConfig);
|
|
3360
|
-
notify(ctx, `Updated network ${list} list (${op}: ${domain})`, "info");
|
|
3361
|
-
return;
|
|
3362
|
-
}
|
|
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
|
-
|
|
3400
|
-
if (subcommand === "filesystem") {
|
|
3401
|
-
const runtimeConfig = requireRuntimeConfig(ctx, sandboxState);
|
|
3402
|
-
if (!runtimeConfig) return;
|
|
3403
|
-
|
|
3404
|
-
const list = tokens[1]?.toLowerCase() as FilesystemList | undefined;
|
|
3405
|
-
const op = tokens[2]?.toLowerCase() as ListOp | undefined;
|
|
3406
|
-
const targetPath = tokens.slice(3).join(" ").trim();
|
|
3407
|
-
|
|
3408
|
-
if (
|
|
3409
|
-
(list !== "deny-read" && list !== "allow-write" && list !== "deny-write") ||
|
|
3410
|
-
(op !== "add" && op !== "remove") ||
|
|
3411
|
-
!targetPath
|
|
3412
|
-
) {
|
|
3413
|
-
notify(
|
|
3414
|
-
ctx,
|
|
3415
|
-
"Usage: /sandbox filesystem <deny-read|allow-write|deny-write> <add|remove> <path>",
|
|
3416
|
-
"warning",
|
|
3417
|
-
);
|
|
3418
|
-
return;
|
|
3419
|
-
}
|
|
3420
|
-
|
|
3421
|
-
const nextConfig = cloneRuntimeConfig(runtimeConfig);
|
|
3422
|
-
const values =
|
|
3423
|
-
list === "deny-read"
|
|
3424
|
-
? nextConfig.filesystem.denyRead
|
|
3425
|
-
: list === "allow-write"
|
|
3426
|
-
? nextConfig.filesystem.allowWrite
|
|
3427
|
-
: nextConfig.filesystem.denyWrite;
|
|
3428
|
-
const changed = mutateStringList(values, op, targetPath);
|
|
3429
|
-
if (!changed) {
|
|
3430
|
-
notify(
|
|
3431
|
-
ctx,
|
|
3432
|
-
`No change: filesystem ${list} list already ${op === "add" ? "contains" : "omits"} ${targetPath}`,
|
|
3433
|
-
);
|
|
3434
|
-
return;
|
|
3435
|
-
}
|
|
3436
|
-
|
|
3437
|
-
applyRuntimeConfigForSession(ctx, nextConfig);
|
|
3438
|
-
notify(ctx, `Updated filesystem ${list} list (${op}: ${targetPath})`, "info");
|
|
3439
|
-
return;
|
|
3440
|
-
}
|
|
3441
|
-
|
|
3442
|
-
notify(ctx, `Unknown subcommand: ${subcommand}. Use /sandbox for help`, "error");
|
|
3443
|
-
},
|
|
3444
|
-
});
|
|
238
|
+
registerSandboxCommand(pi, runtime);
|
|
3445
239
|
}
|