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
|
@@ -0,0 +1,775 @@
|
|
|
1
|
+
import { spawn, spawnSync } from "node:child_process";
|
|
2
|
+
import { existsSync } from "node:fs";
|
|
3
|
+
import { resolve } from "node:path";
|
|
4
|
+
import { fileURLToPath } from "node:url";
|
|
5
|
+
import { SandboxManager, type SandboxRuntimeConfig } from "@anthropic-ai/sandbox-runtime";
|
|
6
|
+
import {
|
|
7
|
+
createLocalBashOperations,
|
|
8
|
+
type BashOperations,
|
|
9
|
+
type ExtensionContext,
|
|
10
|
+
} from "@earendil-works/pi-coding-agent";
|
|
11
|
+
|
|
12
|
+
import {
|
|
13
|
+
cloneRuntimeConfig,
|
|
14
|
+
inferSandboxRuleMatch,
|
|
15
|
+
isSandboxWritablePath,
|
|
16
|
+
mutateStringList,
|
|
17
|
+
type PromptMode,
|
|
18
|
+
type SandboxConfig,
|
|
19
|
+
} from "./config.js";
|
|
20
|
+
import type { PermissionResolution } from "./permissions/dialog.js";
|
|
21
|
+
import {
|
|
22
|
+
detectFilesystemViolationFromLine,
|
|
23
|
+
extractAppendedSandboxAnnotation,
|
|
24
|
+
formatRuntimeProtectedWriteNotice,
|
|
25
|
+
formatTraversalNotice,
|
|
26
|
+
getRuntimeProtectedWriteViolations,
|
|
27
|
+
getTraversalPaths,
|
|
28
|
+
handleFilesystemViolation,
|
|
29
|
+
isRuntimeProtectedWriteViolation,
|
|
30
|
+
isTraversalViolation,
|
|
31
|
+
type FilesystemViolation,
|
|
32
|
+
} from "./permissions/filesystem.js";
|
|
33
|
+
import {
|
|
34
|
+
detectMachLookupViolations,
|
|
35
|
+
handleMachLookupViolation,
|
|
36
|
+
} from "./permissions/mach-lookup.js";
|
|
37
|
+
import {
|
|
38
|
+
formatUnsandboxedApproval,
|
|
39
|
+
UNSANDBOXED_APPROVAL_CHOICES,
|
|
40
|
+
} from "./permissions/unsandboxed.js";
|
|
41
|
+
import { notify, type SandboxEvent, type SandboxRuntime } from "./runtime.js";
|
|
42
|
+
|
|
43
|
+
const IS_MACOS = process.platform === "darwin";
|
|
44
|
+
const MACOS_SANDBOX_SHELL = fileURLToPath(new URL("./macos-sandbox-shell.mjs", import.meta.url));
|
|
45
|
+
const MAX_TIMEOUT_MS = 2_147_483_647;
|
|
46
|
+
const MAX_TIMEOUT_SECONDS = MAX_TIMEOUT_MS / 1000;
|
|
47
|
+
const GIT_FILESYSTEM_PATHS_CACHE = new Map<string, GitFilesystemPaths | null>();
|
|
48
|
+
|
|
49
|
+
interface GitFilesystemPaths {
|
|
50
|
+
gitDir: string;
|
|
51
|
+
gitCommonDir: string;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function resolveGitFilesystemPaths(cwd: string): GitFilesystemPaths | null {
|
|
55
|
+
if (GIT_FILESYSTEM_PATHS_CACHE.has(cwd)) {
|
|
56
|
+
return GIT_FILESYSTEM_PATHS_CACHE.get(cwd) ?? null;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const result = spawnSync(
|
|
60
|
+
"git",
|
|
61
|
+
["rev-parse", "--path-format=absolute", "--git-dir", "--git-common-dir"],
|
|
62
|
+
{
|
|
63
|
+
cwd,
|
|
64
|
+
encoding: "utf-8",
|
|
65
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
66
|
+
},
|
|
67
|
+
);
|
|
68
|
+
|
|
69
|
+
if (result.status !== 0) {
|
|
70
|
+
GIT_FILESYSTEM_PATHS_CACHE.set(cwd, null);
|
|
71
|
+
return null;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const [gitDir, gitCommonDir] = result.stdout
|
|
75
|
+
.split("\n")
|
|
76
|
+
.map((line) => line.trim())
|
|
77
|
+
.filter(Boolean);
|
|
78
|
+
|
|
79
|
+
if (!gitDir || !gitCommonDir) {
|
|
80
|
+
GIT_FILESYSTEM_PATHS_CACHE.set(cwd, null);
|
|
81
|
+
return null;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const gitPaths = { gitDir, gitCommonDir };
|
|
85
|
+
GIT_FILESYSTEM_PATHS_CACHE.set(cwd, gitPaths);
|
|
86
|
+
return gitPaths;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function maybeAddGitMetadataWritePath(
|
|
90
|
+
runtimeConfig: SandboxRuntimeConfig,
|
|
91
|
+
path: string,
|
|
92
|
+
cwd?: string,
|
|
93
|
+
): SandboxRuntimeConfig | null {
|
|
94
|
+
if (isSandboxWritablePath(runtimeConfig, path, cwd)) return null;
|
|
95
|
+
if (inferSandboxRuleMatch(path, runtimeConfig.filesystem.denyWrite, cwd)) return null;
|
|
96
|
+
|
|
97
|
+
const nextConfig = cloneRuntimeConfig(runtimeConfig);
|
|
98
|
+
if (!mutateStringList(nextConfig.filesystem.allowWrite, "add", path)) return null;
|
|
99
|
+
return nextConfig;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function appendOutputPostamble(postamble: string, addition: string, output: string): string {
|
|
103
|
+
if (!addition) return postamble;
|
|
104
|
+
|
|
105
|
+
const needsSeparator =
|
|
106
|
+
postamble.length > 0 ? !postamble.endsWith("\n") : output.length > 0 && !output.endsWith("\n");
|
|
107
|
+
|
|
108
|
+
return `${postamble}${needsSeparator ? "\n" : ""}${addition}`;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function ensureTrailingNewline(text: string): string {
|
|
112
|
+
if (!text || text.endsWith("\n")) return text;
|
|
113
|
+
return `${text}\n`;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
interface SandboxedBashOpsOptions {
|
|
117
|
+
getContext: () => ExtensionContext | null;
|
|
118
|
+
getSandboxConfig: () => SandboxConfig | null;
|
|
119
|
+
getRuntimeConfig: () => SandboxRuntimeConfig | null;
|
|
120
|
+
getPromptMode: () => PromptMode;
|
|
121
|
+
applyRuntimeConfigForSession: (
|
|
122
|
+
ctx: ExtensionContext,
|
|
123
|
+
runtimeConfig: SandboxRuntimeConfig,
|
|
124
|
+
) => void;
|
|
125
|
+
recordEvent?: (event: SandboxEvent) => void;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
interface SandboxedBashOperations extends BashOperations {
|
|
129
|
+
runSerially<T>(task: () => Promise<T>, signal?: AbortSignal): Promise<T>;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
interface BashAttemptResult {
|
|
133
|
+
exitCode: number | null;
|
|
134
|
+
combinedOutput: string;
|
|
135
|
+
interruptedByFilesystemViolation: boolean;
|
|
136
|
+
runtimeProtectedWriteViolations: FilesystemViolation[];
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
interface ProcessedSandboxAttempt {
|
|
140
|
+
exitCode: number | null;
|
|
141
|
+
postamble: string;
|
|
142
|
+
resolution: PermissionResolution | null;
|
|
143
|
+
runtimeProtectedWriteViolations: FilesystemViolation[];
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
interface PreparedSandboxAttempt {
|
|
147
|
+
attempt: BashAttemptResult;
|
|
148
|
+
commandId: string;
|
|
149
|
+
runtimeConfig: SandboxRuntimeConfig | null;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function killProcessGroup(
|
|
153
|
+
child: ReturnType<typeof spawn>,
|
|
154
|
+
signal: NodeJS.Signals = "SIGKILL",
|
|
155
|
+
): void {
|
|
156
|
+
if (!child.pid) return;
|
|
157
|
+
|
|
158
|
+
try {
|
|
159
|
+
process.kill(-child.pid, signal);
|
|
160
|
+
} catch {
|
|
161
|
+
try {
|
|
162
|
+
child.kill(signal);
|
|
163
|
+
} catch {
|
|
164
|
+
// Process likely already exited.
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
let sandboxAttemptSequence = 0;
|
|
170
|
+
|
|
171
|
+
function createSandboxCommandId(): string {
|
|
172
|
+
sandboxAttemptSequence += 1;
|
|
173
|
+
return `tau-${process.pid}-${sandboxAttemptSequence}`;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function safeCleanupAfterCommand(): void {
|
|
177
|
+
try {
|
|
178
|
+
SandboxManager.cleanupAfterCommand();
|
|
179
|
+
} catch {
|
|
180
|
+
// Ignore cleanup errors.
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
function maybeAllowGitMetadataWriteForSession(options: {
|
|
185
|
+
ctx: ExtensionContext | null;
|
|
186
|
+
cwd: string;
|
|
187
|
+
runtimeConfig: SandboxRuntimeConfig | null;
|
|
188
|
+
allowGitCommonDir: boolean;
|
|
189
|
+
applyRuntimeConfigForSession: (
|
|
190
|
+
ctx: ExtensionContext,
|
|
191
|
+
runtimeConfig: SandboxRuntimeConfig,
|
|
192
|
+
) => void;
|
|
193
|
+
}): void {
|
|
194
|
+
const { ctx, cwd, runtimeConfig, allowGitCommonDir, applyRuntimeConfigForSession } = options;
|
|
195
|
+
if (!ctx || !runtimeConfig) return;
|
|
196
|
+
if (!isSandboxWritablePath(runtimeConfig, cwd, cwd)) return;
|
|
197
|
+
|
|
198
|
+
const gitPaths = resolveGitFilesystemPaths(cwd);
|
|
199
|
+
if (!gitPaths) return;
|
|
200
|
+
|
|
201
|
+
let nextConfig = maybeAddGitMetadataWritePath(runtimeConfig, gitPaths.gitDir, cwd);
|
|
202
|
+
if (allowGitCommonDir && gitPaths.gitCommonDir !== gitPaths.gitDir) {
|
|
203
|
+
nextConfig =
|
|
204
|
+
maybeAddGitMetadataWritePath(nextConfig ?? runtimeConfig, gitPaths.gitCommonDir, cwd) ??
|
|
205
|
+
nextConfig;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
if (!nextConfig) return;
|
|
209
|
+
applyRuntimeConfigForSession(ctx, nextConfig);
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/** Create a single-invocation native backend whose approval covers the resolved spawn context. */
|
|
213
|
+
export function createUnsandboxedBashOps(
|
|
214
|
+
runtime: SandboxRuntime,
|
|
215
|
+
sandboxedOps: SandboxedBashOperations,
|
|
216
|
+
ctx: ExtensionContext,
|
|
217
|
+
invocationSignal: AbortSignal,
|
|
218
|
+
): BashOperations {
|
|
219
|
+
function assertAvailable(): void {
|
|
220
|
+
if (invocationSignal.aborted) throw new Error("aborted");
|
|
221
|
+
if (runtime.state.status !== "active") {
|
|
222
|
+
throw new Error("Unsandboxed request denied: the sandbox must be active.");
|
|
223
|
+
}
|
|
224
|
+
if (runtime.promptMode !== "interactive" || !ctx.hasUI || !["tui", "rpc"].includes(ctx.mode)) {
|
|
225
|
+
throw new Error("Unsandboxed request denied: interactive human approval is required.");
|
|
226
|
+
}
|
|
227
|
+
if (
|
|
228
|
+
ctx.mode === "rpc" &&
|
|
229
|
+
process.env.TAU_SUBAGENT_CHILD === "1" &&
|
|
230
|
+
process.env.TAU_SUBAGENT_UNSANDBOXED_APPROVAL !== "1"
|
|
231
|
+
) {
|
|
232
|
+
throw new Error(
|
|
233
|
+
"Unsandboxed request denied: the parent does not advertise a safe approval broker. Reload the parent and start a new subagent before requesting approval.",
|
|
234
|
+
);
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
assertAvailable();
|
|
239
|
+
const local = createLocalBashOperations();
|
|
240
|
+
return {
|
|
241
|
+
exec(command, cwd, options) {
|
|
242
|
+
const absoluteCwd = resolve(cwd);
|
|
243
|
+
return sandboxedOps.runSerially(async () => {
|
|
244
|
+
assertAvailable();
|
|
245
|
+
if (options.signal?.aborted) throw new Error("aborted");
|
|
246
|
+
const title = formatUnsandboxedApproval(command, absoluteCwd);
|
|
247
|
+
let choice: string | undefined;
|
|
248
|
+
try {
|
|
249
|
+
choice = await ctx.ui.select(title, [...UNSANDBOXED_APPROVAL_CHOICES], {
|
|
250
|
+
signal: invocationSignal,
|
|
251
|
+
});
|
|
252
|
+
} catch (error) {
|
|
253
|
+
throw new Error("Unsandboxed request denied: human approval was unavailable.", {
|
|
254
|
+
cause: error,
|
|
255
|
+
});
|
|
256
|
+
}
|
|
257
|
+
assertAvailable();
|
|
258
|
+
if (options.signal?.aborted) throw new Error("aborted");
|
|
259
|
+
if (choice !== "Run once outside sandbox") {
|
|
260
|
+
throw new Error("Unsandboxed request denied: this invocation was not approved.");
|
|
261
|
+
}
|
|
262
|
+
return local.exec(command, absoluteCwd, options);
|
|
263
|
+
}, invocationSignal);
|
|
264
|
+
},
|
|
265
|
+
};
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
export function createSandboxedBashOps(options: SandboxedBashOpsOptions): SandboxedBashOperations {
|
|
269
|
+
const {
|
|
270
|
+
getContext,
|
|
271
|
+
getSandboxConfig,
|
|
272
|
+
getRuntimeConfig,
|
|
273
|
+
getPromptMode,
|
|
274
|
+
applyRuntimeConfigForSession,
|
|
275
|
+
recordEvent,
|
|
276
|
+
} = options;
|
|
277
|
+
const pendingFilesystemDialogs = new Map<string, Promise<PermissionResolution | null>>();
|
|
278
|
+
const pendingMachLookupDialogs = new Map<string, Promise<PermissionResolution | null>>();
|
|
279
|
+
|
|
280
|
+
let executionQueue: Promise<void> = Promise.resolve();
|
|
281
|
+
|
|
282
|
+
function runSerially<T>(task: () => Promise<T>, signal?: AbortSignal): Promise<T> {
|
|
283
|
+
return new Promise<T>((resolve, reject) => {
|
|
284
|
+
let pending = true;
|
|
285
|
+
const cancel = () => {
|
|
286
|
+
if (!pending) return;
|
|
287
|
+
pending = false;
|
|
288
|
+
signal?.removeEventListener("abort", cancel);
|
|
289
|
+
reject(new Error("aborted"));
|
|
290
|
+
};
|
|
291
|
+
if (signal?.aborted) {
|
|
292
|
+
cancel();
|
|
293
|
+
return;
|
|
294
|
+
}
|
|
295
|
+
signal?.addEventListener("abort", cancel, { once: true });
|
|
296
|
+
const start = async () => {
|
|
297
|
+
if (!pending) return;
|
|
298
|
+
pending = false;
|
|
299
|
+
signal?.removeEventListener("abort", cancel);
|
|
300
|
+
try {
|
|
301
|
+
resolve(await task());
|
|
302
|
+
} catch (error) {
|
|
303
|
+
reject(error);
|
|
304
|
+
}
|
|
305
|
+
};
|
|
306
|
+
executionQueue = executionQueue.then(start, start);
|
|
307
|
+
});
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
function withSandboxDefaultEnv(env?: NodeJS.ProcessEnv): NodeJS.ProcessEnv {
|
|
311
|
+
const baseEnv = env ?? process.env;
|
|
312
|
+
if (baseEnv.GIT_OPTIONAL_LOCKS !== undefined) return baseEnv;
|
|
313
|
+
return { ...baseEnv, GIT_OPTIONAL_LOCKS: "0" };
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
async function runSandboxAttempt(
|
|
317
|
+
commandId: string,
|
|
318
|
+
wrappedCommand: string,
|
|
319
|
+
cwd: string,
|
|
320
|
+
runtimeConfig: SandboxRuntimeConfig | null,
|
|
321
|
+
onData: (data: Buffer) => void,
|
|
322
|
+
signal?: AbortSignal,
|
|
323
|
+
timeout?: number,
|
|
324
|
+
env?: NodeJS.ProcessEnv,
|
|
325
|
+
): Promise<BashAttemptResult> {
|
|
326
|
+
return new Promise((resolve, reject) => {
|
|
327
|
+
const child = spawn("bash", ["-c", wrappedCommand], {
|
|
328
|
+
cwd,
|
|
329
|
+
env: withSandboxDefaultEnv(env),
|
|
330
|
+
detached: true,
|
|
331
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
332
|
+
});
|
|
333
|
+
|
|
334
|
+
const chunks: Buffer[] = [];
|
|
335
|
+
let timedOut = false;
|
|
336
|
+
let interruptedByFilesystemViolation = false;
|
|
337
|
+
let seenViolationCount = 0;
|
|
338
|
+
const runtimeProtectedWriteViolations = new Map<string, FilesystemViolation>();
|
|
339
|
+
let timeoutHandle: NodeJS.Timeout | undefined;
|
|
340
|
+
let timeoutEscalationHandle: NodeJS.Timeout | undefined;
|
|
341
|
+
let filesystemStopEscalationHandle: NodeJS.Timeout | undefined;
|
|
342
|
+
|
|
343
|
+
const stopForFilesystemViolation = (): void => {
|
|
344
|
+
if (interruptedByFilesystemViolation) return;
|
|
345
|
+
|
|
346
|
+
interruptedByFilesystemViolation = true;
|
|
347
|
+
killProcessGroup(child, "SIGTERM");
|
|
348
|
+
filesystemStopEscalationHandle = setTimeout(() => {
|
|
349
|
+
killProcessGroup(child, "SIGKILL");
|
|
350
|
+
}, 500);
|
|
351
|
+
};
|
|
352
|
+
|
|
353
|
+
// The Linux monitor filters attempts against configured write rules and does not
|
|
354
|
+
// report mandatory runtime denies within already-allowed paths, so runtime-protected
|
|
355
|
+
// continuation remains macOS-only.
|
|
356
|
+
const unsubscribeViolations =
|
|
357
|
+
process.platform !== "darwin"
|
|
358
|
+
? () => undefined
|
|
359
|
+
: SandboxManager.getSandboxViolationStore().subscribe(() => {
|
|
360
|
+
const violations =
|
|
361
|
+
SandboxManager.getSandboxViolationStore().getViolationsForCommand(commandId);
|
|
362
|
+
if (violations.length <= seenViolationCount) return;
|
|
363
|
+
|
|
364
|
+
const newViolations = violations.slice(seenViolationCount);
|
|
365
|
+
seenViolationCount = violations.length;
|
|
366
|
+
|
|
367
|
+
for (const violation of newViolations) {
|
|
368
|
+
const filesystemViolation = detectFilesystemViolationFromLine(violation.line);
|
|
369
|
+
if (filesystemViolation) {
|
|
370
|
+
if (isTraversalViolation(runtimeConfig, filesystemViolation, cwd)) continue;
|
|
371
|
+
|
|
372
|
+
if (isRuntimeProtectedWriteViolation(runtimeConfig, filesystemViolation, cwd)) {
|
|
373
|
+
if (filesystemViolation.path) {
|
|
374
|
+
runtimeProtectedWriteViolations.set(
|
|
375
|
+
filesystemViolation.path,
|
|
376
|
+
filesystemViolation,
|
|
377
|
+
);
|
|
378
|
+
}
|
|
379
|
+
continue;
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
stopForFilesystemViolation();
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
});
|
|
386
|
+
|
|
387
|
+
if (timeout !== undefined && timeout > 0) {
|
|
388
|
+
timeoutHandle = setTimeout(() => {
|
|
389
|
+
timedOut = true;
|
|
390
|
+
killProcessGroup(child, "SIGTERM");
|
|
391
|
+
timeoutEscalationHandle = setTimeout(() => {
|
|
392
|
+
killProcessGroup(child, "SIGKILL");
|
|
393
|
+
}, 2000);
|
|
394
|
+
}, timeout * 1000);
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
child.stdout?.on("data", (data) => {
|
|
398
|
+
chunks.push(data);
|
|
399
|
+
onData(data);
|
|
400
|
+
});
|
|
401
|
+
child.stderr?.on("data", (data) => {
|
|
402
|
+
chunks.push(data);
|
|
403
|
+
onData(data);
|
|
404
|
+
});
|
|
405
|
+
|
|
406
|
+
const onAbort = () => {
|
|
407
|
+
killProcessGroup(child, "SIGKILL");
|
|
408
|
+
};
|
|
409
|
+
|
|
410
|
+
child.on("error", (err) => {
|
|
411
|
+
if (timeoutHandle) clearTimeout(timeoutHandle);
|
|
412
|
+
if (timeoutEscalationHandle) clearTimeout(timeoutEscalationHandle);
|
|
413
|
+
if (filesystemStopEscalationHandle) clearTimeout(filesystemStopEscalationHandle);
|
|
414
|
+
unsubscribeViolations();
|
|
415
|
+
signal?.removeEventListener("abort", onAbort);
|
|
416
|
+
killProcessGroup(child, "SIGKILL");
|
|
417
|
+
reject(err);
|
|
418
|
+
});
|
|
419
|
+
|
|
420
|
+
if (signal) {
|
|
421
|
+
if (signal.aborted) onAbort();
|
|
422
|
+
else signal.addEventListener("abort", onAbort, { once: true });
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
child.on("close", (code) => {
|
|
426
|
+
if (timeoutHandle) clearTimeout(timeoutHandle);
|
|
427
|
+
if (timeoutEscalationHandle) clearTimeout(timeoutEscalationHandle);
|
|
428
|
+
if (filesystemStopEscalationHandle) clearTimeout(filesystemStopEscalationHandle);
|
|
429
|
+
unsubscribeViolations();
|
|
430
|
+
signal?.removeEventListener("abort", onAbort);
|
|
431
|
+
|
|
432
|
+
if (signal?.aborted) {
|
|
433
|
+
reject(new Error("aborted"));
|
|
434
|
+
return;
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
if (timedOut) {
|
|
438
|
+
reject(new Error(`timeout:${timeout}`));
|
|
439
|
+
return;
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
resolve({
|
|
443
|
+
exitCode: interruptedByFilesystemViolation && code === null ? 1 : code,
|
|
444
|
+
combinedOutput: Buffer.concat(chunks).toString("utf-8"),
|
|
445
|
+
interruptedByFilesystemViolation,
|
|
446
|
+
runtimeProtectedWriteViolations: Array.from(runtimeProtectedWriteViolations.values()),
|
|
447
|
+
});
|
|
448
|
+
});
|
|
449
|
+
});
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
function validateTimeout(timeout: number | undefined): void {
|
|
453
|
+
if (timeout === undefined) return;
|
|
454
|
+
if (!Number.isFinite(timeout) || timeout <= 0) {
|
|
455
|
+
throw new Error("Invalid timeout: must be a finite number of seconds");
|
|
456
|
+
}
|
|
457
|
+
if (timeout * 1000 > MAX_TIMEOUT_MS) {
|
|
458
|
+
throw new Error(`Invalid timeout: maximum is ${MAX_TIMEOUT_SECONDS} seconds`);
|
|
459
|
+
}
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
function getRemainingTimeout(timeout: number | undefined, startedAt: number): number | undefined {
|
|
463
|
+
if (timeout === undefined) return undefined;
|
|
464
|
+
|
|
465
|
+
return Math.max(0, timeout - (Date.now() - startedAt) / 1000);
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
function reportPostProcessingError(error: unknown): void {
|
|
469
|
+
const message = `[sandbox] Post-processing error: ${error instanceof Error ? error.message : error}`;
|
|
470
|
+
const ctx = getContext();
|
|
471
|
+
if (ctx) notify(ctx, message, "warning");
|
|
472
|
+
else console.warn(message);
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
async function prepareAndRunSandboxAttempt(options: {
|
|
476
|
+
command: string;
|
|
477
|
+
cwd: string;
|
|
478
|
+
onData: (data: Buffer) => void;
|
|
479
|
+
signal?: AbortSignal;
|
|
480
|
+
timeout?: number;
|
|
481
|
+
env?: NodeJS.ProcessEnv;
|
|
482
|
+
}): Promise<PreparedSandboxAttempt> {
|
|
483
|
+
const { command, cwd, onData, signal, timeout, env } = options;
|
|
484
|
+
|
|
485
|
+
maybeAllowGitMetadataWriteForSession({
|
|
486
|
+
ctx: getContext(),
|
|
487
|
+
cwd,
|
|
488
|
+
runtimeConfig: getRuntimeConfig(),
|
|
489
|
+
allowGitCommonDir: getSandboxConfig()?.filesystem.allowGitCommonDir === true,
|
|
490
|
+
applyRuntimeConfigForSession,
|
|
491
|
+
});
|
|
492
|
+
|
|
493
|
+
const runtimeConfig = getRuntimeConfig();
|
|
494
|
+
const attemptRuntimeConfig = runtimeConfig ? cloneRuntimeConfig(runtimeConfig) : null;
|
|
495
|
+
const commandId = createSandboxCommandId();
|
|
496
|
+
|
|
497
|
+
try {
|
|
498
|
+
const wrappedCommand = await SandboxManager.wrapWithSandbox(
|
|
499
|
+
command,
|
|
500
|
+
IS_MACOS ? MACOS_SANDBOX_SHELL : undefined,
|
|
501
|
+
attemptRuntimeConfig ?? undefined,
|
|
502
|
+
signal,
|
|
503
|
+
{ commandId, commandText: command },
|
|
504
|
+
);
|
|
505
|
+
const attempt = await runSandboxAttempt(
|
|
506
|
+
commandId,
|
|
507
|
+
wrappedCommand,
|
|
508
|
+
cwd,
|
|
509
|
+
attemptRuntimeConfig,
|
|
510
|
+
onData,
|
|
511
|
+
signal,
|
|
512
|
+
timeout,
|
|
513
|
+
env,
|
|
514
|
+
);
|
|
515
|
+
return { attempt, commandId, runtimeConfig: attemptRuntimeConfig };
|
|
516
|
+
} catch (err) {
|
|
517
|
+
safeCleanupAfterCommand();
|
|
518
|
+
throw err;
|
|
519
|
+
}
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
async function processSandboxAttempt(options: {
|
|
523
|
+
attempt: BashAttemptResult;
|
|
524
|
+
command: string;
|
|
525
|
+
commandId: string;
|
|
526
|
+
cwd: string;
|
|
527
|
+
runtimeConfig: SandboxRuntimeConfig | null;
|
|
528
|
+
autoRetryAvailable: boolean;
|
|
529
|
+
}): Promise<ProcessedSandboxAttempt> {
|
|
530
|
+
const { attempt, command, commandId, cwd, runtimeConfig, autoRetryAvailable } = options;
|
|
531
|
+
const annotatedOutput = SandboxManager.annotateStderrWithSandboxFailures(
|
|
532
|
+
commandId,
|
|
533
|
+
attempt.combinedOutput,
|
|
534
|
+
);
|
|
535
|
+
// Capture violations delivered after the child closed but before post-processing.
|
|
536
|
+
const storedViolationLines = SandboxManager.getSandboxViolationStore()
|
|
537
|
+
.getViolationsForCommand(commandId)
|
|
538
|
+
.map((violation) => violation.line);
|
|
539
|
+
const storedFilesystemViolations = storedViolationLines
|
|
540
|
+
.map((line) => detectFilesystemViolationFromLine(line))
|
|
541
|
+
.filter((violation): violation is FilesystemViolation => violation !== null);
|
|
542
|
+
const runtimeProtectedWriteViolations = getRuntimeProtectedWriteViolations(
|
|
543
|
+
runtimeConfig,
|
|
544
|
+
[...attempt.runtimeProtectedWriteViolations, ...storedFilesystemViolations],
|
|
545
|
+
cwd,
|
|
546
|
+
);
|
|
547
|
+
const machLookupViolations = detectMachLookupViolations(storedViolationLines);
|
|
548
|
+
let postamble = extractAppendedSandboxAnnotation(attempt.combinedOutput, annotatedOutput);
|
|
549
|
+
|
|
550
|
+
if (runtimeProtectedWriteViolations.length > 0) {
|
|
551
|
+
const notice = formatRuntimeProtectedWriteNotice(
|
|
552
|
+
runtimeProtectedWriteViolations,
|
|
553
|
+
!attempt.interruptedByFilesystemViolation,
|
|
554
|
+
);
|
|
555
|
+
postamble = appendOutputPostamble(postamble, notice, attempt.combinedOutput);
|
|
556
|
+
|
|
557
|
+
for (const violation of runtimeProtectedWriteViolations) {
|
|
558
|
+
recordEvent?.({
|
|
559
|
+
timestamp: Date.now(),
|
|
560
|
+
kind: "filesystem",
|
|
561
|
+
outcome: "blocked",
|
|
562
|
+
reason: "runtime-protected-write",
|
|
563
|
+
target: violation.path,
|
|
564
|
+
command,
|
|
565
|
+
cwd,
|
|
566
|
+
summary: "filesystem write is protected by the sandbox runtime",
|
|
567
|
+
});
|
|
568
|
+
}
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
const commandSucceeded = attempt.exitCode === 0 && !attempt.interruptedByFilesystemViolation;
|
|
572
|
+
if (commandSucceeded) {
|
|
573
|
+
return {
|
|
574
|
+
exitCode: attempt.exitCode,
|
|
575
|
+
postamble,
|
|
576
|
+
resolution: null,
|
|
577
|
+
runtimeProtectedWriteViolations,
|
|
578
|
+
};
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
if (
|
|
582
|
+
/\bsandbox-exec:\s+sandbox_apply:\s+Operation not permitted\b/i.test(attempt.combinedOutput)
|
|
583
|
+
) {
|
|
584
|
+
const notice =
|
|
585
|
+
"[sandbox] macOS sandbox initialization failed in a child process. An inherited sandbox can cause this.";
|
|
586
|
+
recordEvent?.({
|
|
587
|
+
timestamp: Date.now(),
|
|
588
|
+
kind: "runtime",
|
|
589
|
+
outcome: "blocked",
|
|
590
|
+
reason: "init-failed",
|
|
591
|
+
command,
|
|
592
|
+
cwd,
|
|
593
|
+
summary: "command reported a sandbox initialization failure",
|
|
594
|
+
});
|
|
595
|
+
return {
|
|
596
|
+
exitCode: attempt.exitCode,
|
|
597
|
+
postamble: appendOutputPostamble(postamble, notice, attempt.combinedOutput),
|
|
598
|
+
resolution: null,
|
|
599
|
+
runtimeProtectedWriteViolations,
|
|
600
|
+
};
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
const traversalPaths = getTraversalPaths({
|
|
604
|
+
runtimeConfig,
|
|
605
|
+
output: annotatedOutput,
|
|
606
|
+
cwd,
|
|
607
|
+
});
|
|
608
|
+
const continuedTraversal = machLookupViolations.length === 0 ? traversalPaths : null;
|
|
609
|
+
const effectiveExitCode = continuedTraversal ? 0 : attempt.exitCode;
|
|
610
|
+
let resolution: PermissionResolution | null = null;
|
|
611
|
+
|
|
612
|
+
if (continuedTraversal) {
|
|
613
|
+
const notice = formatTraversalNotice(continuedTraversal);
|
|
614
|
+
postamble = appendOutputPostamble(postamble, notice, attempt.combinedOutput);
|
|
615
|
+
} else {
|
|
616
|
+
const currentRuntimeConfig = getRuntimeConfig();
|
|
617
|
+
if (!currentRuntimeConfig) {
|
|
618
|
+
return {
|
|
619
|
+
exitCode: effectiveExitCode,
|
|
620
|
+
postamble,
|
|
621
|
+
resolution,
|
|
622
|
+
runtimeProtectedWriteViolations,
|
|
623
|
+
};
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
resolution = await handleFilesystemViolation({
|
|
627
|
+
ctx: getContext(),
|
|
628
|
+
promptMode: getPromptMode(),
|
|
629
|
+
runtimeConfig: currentRuntimeConfig,
|
|
630
|
+
getRuntimeConfig,
|
|
631
|
+
output: annotatedOutput,
|
|
632
|
+
rawOutput: attempt.combinedOutput,
|
|
633
|
+
command,
|
|
634
|
+
cwd,
|
|
635
|
+
pendingDialogs: pendingFilesystemDialogs,
|
|
636
|
+
applyRuntimeConfigForSession,
|
|
637
|
+
recordEvent,
|
|
638
|
+
autoRetryAvailable,
|
|
639
|
+
runtimeProtectedWriteViolations,
|
|
640
|
+
allowOutputFallback: machLookupViolations.length === 0,
|
|
641
|
+
});
|
|
642
|
+
|
|
643
|
+
if (!resolution) {
|
|
644
|
+
resolution = await handleMachLookupViolation({
|
|
645
|
+
ctx: getContext(),
|
|
646
|
+
promptMode: getPromptMode(),
|
|
647
|
+
runtimeConfig: currentRuntimeConfig,
|
|
648
|
+
violations: machLookupViolations,
|
|
649
|
+
command,
|
|
650
|
+
cwd,
|
|
651
|
+
pendingDialogs: pendingMachLookupDialogs,
|
|
652
|
+
applyRuntimeConfigForSession,
|
|
653
|
+
recordEvent,
|
|
654
|
+
autoRetryAvailable,
|
|
655
|
+
});
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
if (resolution) {
|
|
659
|
+
postamble = appendOutputPostamble(postamble, resolution.message, attempt.combinedOutput);
|
|
660
|
+
}
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
return {
|
|
664
|
+
exitCode: effectiveExitCode,
|
|
665
|
+
postamble,
|
|
666
|
+
resolution,
|
|
667
|
+
runtimeProtectedWriteViolations,
|
|
668
|
+
};
|
|
669
|
+
}
|
|
670
|
+
|
|
671
|
+
return {
|
|
672
|
+
runSerially,
|
|
673
|
+
async exec(command, cwd, { onData, signal, timeout, env }) {
|
|
674
|
+
validateTimeout(timeout);
|
|
675
|
+
return runSerially(async () => {
|
|
676
|
+
if (!existsSync(cwd)) {
|
|
677
|
+
throw new Error(`Working directory does not exist: ${cwd}`);
|
|
678
|
+
}
|
|
679
|
+
|
|
680
|
+
const attemptStartedAt = Date.now();
|
|
681
|
+
const initialRun = await prepareAndRunSandboxAttempt({
|
|
682
|
+
command,
|
|
683
|
+
cwd,
|
|
684
|
+
onData,
|
|
685
|
+
signal,
|
|
686
|
+
timeout,
|
|
687
|
+
env,
|
|
688
|
+
});
|
|
689
|
+
const retryTimeout = getRemainingTimeout(timeout, attemptStartedAt);
|
|
690
|
+
|
|
691
|
+
let processedAttempt: ProcessedSandboxAttempt;
|
|
692
|
+
try {
|
|
693
|
+
processedAttempt = await processSandboxAttempt({
|
|
694
|
+
attempt: initialRun.attempt,
|
|
695
|
+
command,
|
|
696
|
+
commandId: initialRun.commandId,
|
|
697
|
+
cwd,
|
|
698
|
+
runtimeConfig: initialRun.runtimeConfig,
|
|
699
|
+
autoRetryAvailable: true,
|
|
700
|
+
});
|
|
701
|
+
} catch (postProcessError) {
|
|
702
|
+
reportPostProcessingError(postProcessError);
|
|
703
|
+
safeCleanupAfterCommand();
|
|
704
|
+
return { exitCode: initialRun.attempt.exitCode };
|
|
705
|
+
}
|
|
706
|
+
|
|
707
|
+
const retryResolution = processedAttempt.resolution;
|
|
708
|
+
if (retryResolution?.kind !== "allow-retry") {
|
|
709
|
+
if (processedAttempt.postamble) onData(Buffer.from(processedAttempt.postamble));
|
|
710
|
+
safeCleanupAfterCommand();
|
|
711
|
+
return { exitCode: processedAttempt.exitCode };
|
|
712
|
+
}
|
|
713
|
+
|
|
714
|
+
if (processedAttempt.postamble) {
|
|
715
|
+
onData(Buffer.from(ensureTrailingNewline(processedAttempt.postamble)));
|
|
716
|
+
}
|
|
717
|
+
|
|
718
|
+
initialRun.attempt.combinedOutput = "";
|
|
719
|
+
safeCleanupAfterCommand();
|
|
720
|
+
|
|
721
|
+
if (retryTimeout !== undefined && retryTimeout <= 0) {
|
|
722
|
+
onData(Buffer.from(retryResolution.retrySkippedMessage));
|
|
723
|
+
return { exitCode: processedAttempt.exitCode };
|
|
724
|
+
}
|
|
725
|
+
|
|
726
|
+
const retryRun = await prepareAndRunSandboxAttempt({
|
|
727
|
+
command,
|
|
728
|
+
cwd,
|
|
729
|
+
onData,
|
|
730
|
+
signal,
|
|
731
|
+
timeout: retryTimeout,
|
|
732
|
+
env,
|
|
733
|
+
});
|
|
734
|
+
|
|
735
|
+
let processedRetry: ProcessedSandboxAttempt;
|
|
736
|
+
try {
|
|
737
|
+
processedRetry = await processSandboxAttempt({
|
|
738
|
+
attempt: retryRun.attempt,
|
|
739
|
+
command,
|
|
740
|
+
commandId: retryRun.commandId,
|
|
741
|
+
cwd,
|
|
742
|
+
runtimeConfig: retryRun.runtimeConfig,
|
|
743
|
+
autoRetryAvailable: false,
|
|
744
|
+
});
|
|
745
|
+
} catch (postProcessError) {
|
|
746
|
+
reportPostProcessingError(postProcessError);
|
|
747
|
+
safeCleanupAfterCommand();
|
|
748
|
+
return { exitCode: retryRun.attempt.exitCode };
|
|
749
|
+
}
|
|
750
|
+
|
|
751
|
+
let retryPostamble = processedRetry.postamble;
|
|
752
|
+
if (processedRetry.exitCode === 0) {
|
|
753
|
+
retryPostamble = appendOutputPostamble(
|
|
754
|
+
retryPostamble,
|
|
755
|
+
retryResolution.retrySuccessMessage,
|
|
756
|
+
retryRun.attempt.combinedOutput,
|
|
757
|
+
);
|
|
758
|
+
} else if (
|
|
759
|
+
!processedRetry.resolution &&
|
|
760
|
+
processedRetry.runtimeProtectedWriteViolations.length === 0
|
|
761
|
+
) {
|
|
762
|
+
retryPostamble = appendOutputPostamble(
|
|
763
|
+
retryPostamble,
|
|
764
|
+
retryResolution.retryFailureMessage,
|
|
765
|
+
retryRun.attempt.combinedOutput,
|
|
766
|
+
);
|
|
767
|
+
}
|
|
768
|
+
|
|
769
|
+
if (retryPostamble) onData(Buffer.from(retryPostamble));
|
|
770
|
+
safeCleanupAfterCommand();
|
|
771
|
+
return { exitCode: processedRetry.exitCode };
|
|
772
|
+
}, signal);
|
|
773
|
+
},
|
|
774
|
+
};
|
|
775
|
+
}
|