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,118 @@
|
|
|
1
|
+
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
|
|
3
|
+
type PermissionDecision = "allow-retry" | "allow-adapt" | "deny";
|
|
4
|
+
|
|
5
|
+
export type PermissionResolution =
|
|
6
|
+
| {
|
|
7
|
+
kind: "allow-retry";
|
|
8
|
+
message: string;
|
|
9
|
+
retrySuccessMessage: string;
|
|
10
|
+
retryFailureMessage: string;
|
|
11
|
+
retrySkippedMessage: string;
|
|
12
|
+
}
|
|
13
|
+
| { kind: "allow-adapt"; message: string }
|
|
14
|
+
| { kind: "deny"; message: string };
|
|
15
|
+
|
|
16
|
+
const ALLOW_RETRY_OPTION = "Allow and retry now";
|
|
17
|
+
const ALLOW_ADAPT_OPTION = "Allow but adapt for side-effects";
|
|
18
|
+
const DENY_OPTION = "Deny";
|
|
19
|
+
|
|
20
|
+
export async function showPermissionDialog<T>(options: {
|
|
21
|
+
ctx: ExtensionContext;
|
|
22
|
+
title: string;
|
|
23
|
+
promptKey: string;
|
|
24
|
+
pendingDialogs?: Map<string, Promise<T | null>>;
|
|
25
|
+
autoRetryAvailable: boolean;
|
|
26
|
+
onDecision: (decision: PermissionDecision) => T | Promise<T>;
|
|
27
|
+
}): Promise<T | null> {
|
|
28
|
+
const { ctx, title, promptKey, pendingDialogs, autoRetryAvailable, onDecision } = options;
|
|
29
|
+
const existingDialog = pendingDialogs?.get(promptKey);
|
|
30
|
+
if (existingDialog) return existingDialog;
|
|
31
|
+
|
|
32
|
+
const dialogTask: Promise<T | null> = (async () => {
|
|
33
|
+
try {
|
|
34
|
+
const selection = await ctx.ui.select(title, getPermissionDialogOptions(autoRetryAvailable));
|
|
35
|
+
return await onDecision(parsePermissionDialogSelection(selection, autoRetryAvailable));
|
|
36
|
+
} catch {
|
|
37
|
+
return null;
|
|
38
|
+
}
|
|
39
|
+
})();
|
|
40
|
+
|
|
41
|
+
if (!pendingDialogs) return dialogTask;
|
|
42
|
+
|
|
43
|
+
pendingDialogs.set(promptKey, dialogTask);
|
|
44
|
+
try {
|
|
45
|
+
return await dialogTask;
|
|
46
|
+
} finally {
|
|
47
|
+
pendingDialogs.delete(promptKey);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function createPermissionResolution(
|
|
52
|
+
decision: PermissionDecision,
|
|
53
|
+
blockedTarget: string,
|
|
54
|
+
changed = true,
|
|
55
|
+
): PermissionResolution {
|
|
56
|
+
if (decision === "deny") {
|
|
57
|
+
return { kind: "deny", message: formatDeniedMessage(blockedTarget) };
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
if (decision === "allow-retry") {
|
|
61
|
+
return {
|
|
62
|
+
kind: "allow-retry",
|
|
63
|
+
message: formatAllowRetryMessage(blockedTarget),
|
|
64
|
+
retrySuccessMessage: formatRetrySucceededMessage(blockedTarget),
|
|
65
|
+
retryFailureMessage: formatRetryFailedMessage(blockedTarget),
|
|
66
|
+
retrySkippedMessage: formatRetrySkippedMessage(blockedTarget),
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
return {
|
|
71
|
+
kind: "allow-adapt",
|
|
72
|
+
message: changed
|
|
73
|
+
? formatAllowAdaptMessage(blockedTarget)
|
|
74
|
+
: formatAlreadyAllowedMessage(blockedTarget),
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function getPermissionDialogOptions(autoRetryAvailable: boolean): string[] {
|
|
79
|
+
if (!autoRetryAvailable) return [ALLOW_ADAPT_OPTION, DENY_OPTION];
|
|
80
|
+
return [ALLOW_RETRY_OPTION, ALLOW_ADAPT_OPTION, DENY_OPTION];
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function parsePermissionDialogSelection(
|
|
84
|
+
selection: string | undefined,
|
|
85
|
+
autoRetryAvailable: boolean,
|
|
86
|
+
): PermissionDecision {
|
|
87
|
+
if (selection === ALLOW_ADAPT_OPTION) return "allow-adapt";
|
|
88
|
+
if (selection === ALLOW_RETRY_OPTION && autoRetryAvailable) return "allow-retry";
|
|
89
|
+
return "deny";
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function formatAllowRetryMessage(blockedTarget: string): string {
|
|
93
|
+
return `\n${blockedTarget}\n\nGranting access and retrying the command per user request...\n\n`;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function formatAllowAdaptMessage(blockedTarget: string): string {
|
|
97
|
+
return `\n${blockedTarget}\n\nAccess granted for this session. Retry the command manually if appropriate.`;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function formatDeniedMessage(blockedTarget: string): string {
|
|
101
|
+
return `\n${blockedTarget}\n\nAccess remains denied for this session.`;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function formatAlreadyAllowedMessage(blockedTarget: string): string {
|
|
105
|
+
return `\n${blockedTarget}\n\nAccess had already been granted for this session. The remaining failure may be unrelated to sandbox policy.`;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function formatRetrySucceededMessage(blockedTarget: string): string {
|
|
109
|
+
return `\n${blockedTarget}\n\nAccess granted for this session and the command was retried successfully.`;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function formatRetryFailedMessage(blockedTarget: string): string {
|
|
113
|
+
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.`;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function formatRetrySkippedMessage(blockedTarget: string): string {
|
|
117
|
+
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.`;
|
|
118
|
+
}
|
|
@@ -0,0 +1,559 @@
|
|
|
1
|
+
import type { SandboxRuntimeConfig } from "@anthropic-ai/sandbox-runtime";
|
|
2
|
+
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
3
|
+
|
|
4
|
+
import {
|
|
5
|
+
cloneRuntimeConfig,
|
|
6
|
+
escapeSlashCommandArg,
|
|
7
|
+
inferExactSandboxRuleMatch,
|
|
8
|
+
inferSandboxRuleMatch,
|
|
9
|
+
isSandboxWritablePath,
|
|
10
|
+
mutateStringList,
|
|
11
|
+
type ListOp,
|
|
12
|
+
type PromptMode,
|
|
13
|
+
} from "../config.js";
|
|
14
|
+
import type { SandboxEvent, SandboxEventOutcome, SandboxEventReason } from "../runtime.js";
|
|
15
|
+
import {
|
|
16
|
+
createPermissionResolution,
|
|
17
|
+
showPermissionDialog,
|
|
18
|
+
type PermissionResolution,
|
|
19
|
+
} from "./dialog.js";
|
|
20
|
+
|
|
21
|
+
const READ_TRAVERSAL_PROCESSES = new Set(["find", "ls", "fd", "fdfind"]);
|
|
22
|
+
|
|
23
|
+
type FilesystemViolationKind = "read" | "write" | "unknown";
|
|
24
|
+
type FilesystemReadAccess = "metadata" | "data" | "unknown";
|
|
25
|
+
type FilesystemWriteAccess = "unlink" | "unknown";
|
|
26
|
+
|
|
27
|
+
export interface FilesystemViolation {
|
|
28
|
+
kind: FilesystemViolationKind;
|
|
29
|
+
path?: string;
|
|
30
|
+
processName?: string;
|
|
31
|
+
readAccess?: FilesystemReadAccess;
|
|
32
|
+
writeAccess?: FilesystemWriteAccess;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export type FilesystemList = "deny-read" | "allow-write" | "deny-write";
|
|
36
|
+
|
|
37
|
+
export function isRuntimeProtectedWriteViolation(
|
|
38
|
+
runtimeConfig: SandboxRuntimeConfig | null,
|
|
39
|
+
violation: FilesystemViolation,
|
|
40
|
+
cwd?: string,
|
|
41
|
+
): boolean {
|
|
42
|
+
if (
|
|
43
|
+
!runtimeConfig ||
|
|
44
|
+
runtimeConfig.filesystem.disabled ||
|
|
45
|
+
!violation.path ||
|
|
46
|
+
violation.kind !== "write"
|
|
47
|
+
) {
|
|
48
|
+
return false;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
return isSandboxWritablePath(runtimeConfig, violation.path, cwd);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function getRuntimeProtectedWriteViolations(
|
|
55
|
+
runtimeConfig: SandboxRuntimeConfig | null,
|
|
56
|
+
violations: FilesystemViolation[],
|
|
57
|
+
cwd?: string,
|
|
58
|
+
): FilesystemViolation[] {
|
|
59
|
+
const violationsByPath = new Map<string, FilesystemViolation>();
|
|
60
|
+
|
|
61
|
+
for (const violation of violations) {
|
|
62
|
+
if (!isRuntimeProtectedWriteViolation(runtimeConfig, violation, cwd) || !violation.path) {
|
|
63
|
+
continue;
|
|
64
|
+
}
|
|
65
|
+
if (!violationsByPath.has(violation.path)) {
|
|
66
|
+
violationsByPath.set(violation.path, violation);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
return Array.from(violationsByPath.values());
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function extractSandboxViolationLines(output: string): string[] {
|
|
74
|
+
// sandbox-runtime annotateStderrWithSandboxFailures wraps violations in this tag.
|
|
75
|
+
const match = output.match(/<sandbox_violations>([\s\S]*?)<\/sandbox_violations>/i);
|
|
76
|
+
if (!match?.[1]) return [];
|
|
77
|
+
|
|
78
|
+
return match[1]
|
|
79
|
+
.split("\n")
|
|
80
|
+
.map((line) => line.trim())
|
|
81
|
+
.filter(Boolean);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function stripSandboxViolationAnnotations(text: string): string {
|
|
85
|
+
return text
|
|
86
|
+
.replace(/\n?<sandbox_violations>[\s\S]*?<\/sandbox_violations>\n?/gi, "\n")
|
|
87
|
+
.replace(/^\n+|\n+$/g, "");
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export function extractAppendedSandboxAnnotation(original: string, annotated: string): string {
|
|
91
|
+
if (annotated === original) return "";
|
|
92
|
+
|
|
93
|
+
if (annotated.startsWith(original)) {
|
|
94
|
+
return stripSandboxViolationAnnotations(annotated.slice(original.length));
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const violationLines = extractSandboxViolationLines(annotated);
|
|
98
|
+
if (violationLines.length === 0) return "";
|
|
99
|
+
|
|
100
|
+
// Sandbox violations are summarized elsewhere via compact extension messages,
|
|
101
|
+
// so suppress the verbose synthetic annotation block.
|
|
102
|
+
return "";
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function sanitizeExtractedPath(path: string): string | undefined {
|
|
106
|
+
const trimmed = path.trim();
|
|
107
|
+
if (!trimmed) return undefined;
|
|
108
|
+
|
|
109
|
+
const withoutDelimiter = trimmed.replace(/:+$/g, "");
|
|
110
|
+
return withoutDelimiter.length > 0 ? withoutDelimiter : undefined;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function extractFilesystemErrorPath(line: string): string | undefined {
|
|
114
|
+
// Raw output must identify the denied target, not merely mention an error and some path.
|
|
115
|
+
const nodeError = line.match(
|
|
116
|
+
/^(?:Error:\s+)?EPERM:\s+operation not permitted,\s+(?:open|scandir|mkdir|rmdir|unlink|stat|lstat|access|chmod|chown|readlink|realpath|opendir)\s+(["'])((?:~\/|\/).+)\1$/i,
|
|
117
|
+
);
|
|
118
|
+
if (nodeError?.[2]) return sanitizeExtractedPath(nodeError[2]);
|
|
119
|
+
|
|
120
|
+
const shellError = line.match(/^(?:[^:\n]+:\s+)*((?:~\/|\/).+?):\s+Operation not permitted$/i);
|
|
121
|
+
return shellError?.[1] ? sanitizeExtractedPath(shellError[1]) : undefined;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function extractViolationProcessName(line: string): string | undefined {
|
|
125
|
+
const match = line.match(/^([^\s(]+)\(/);
|
|
126
|
+
const processName = match?.[1]?.trim();
|
|
127
|
+
if (!processName) return undefined;
|
|
128
|
+
return processName.split("/").pop() || processName;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
export function detectFilesystemViolationFromLine(line: string): FilesystemViolation | null {
|
|
132
|
+
// Runtime emits concrete op variants (e.g. file-write-create/unlink, file-read-data).
|
|
133
|
+
const lower = line.toLowerCase();
|
|
134
|
+
const match = line.match(/\bfile-(?:read|write)[^\s]*\s+((?:~\/|\/).+)$/i);
|
|
135
|
+
const path = match?.[1] ? sanitizeExtractedPath(match[1]) : undefined;
|
|
136
|
+
const processName = extractViolationProcessName(line);
|
|
137
|
+
|
|
138
|
+
if (lower.includes("file-write-unlink")) {
|
|
139
|
+
return { kind: "write", path, processName, writeAccess: "unlink" };
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
if (lower.includes("file-write")) {
|
|
143
|
+
return { kind: "write", path, processName, writeAccess: "unknown" };
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
if (lower.includes("file-read-metadata")) {
|
|
147
|
+
return { kind: "read", path, processName, readAccess: "metadata" };
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
if (lower.includes("file-read-data")) {
|
|
151
|
+
return { kind: "read", path, processName, readAccess: "data" };
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
if (lower.includes("file-read")) {
|
|
155
|
+
return { kind: "read", path, processName, readAccess: "unknown" };
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
return null;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function detectFilesystemViolations(
|
|
162
|
+
output: string,
|
|
163
|
+
fallbackOutput: string = output,
|
|
164
|
+
allowOutputFallback = true,
|
|
165
|
+
): FilesystemViolation[] {
|
|
166
|
+
const violations: FilesystemViolation[] = [];
|
|
167
|
+
const violationLines = extractSandboxViolationLines(output);
|
|
168
|
+
|
|
169
|
+
for (let index = violationLines.length - 1; index >= 0; index -= 1) {
|
|
170
|
+
const violation = detectFilesystemViolationFromLine(violationLines[index]);
|
|
171
|
+
if (violation) violations.push(violation);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
if (violations.length > 0 || !allowOutputFallback) return violations;
|
|
175
|
+
|
|
176
|
+
for (const line of fallbackOutput.split("\n").reverse()) {
|
|
177
|
+
const path = extractFilesystemErrorPath(line.trim());
|
|
178
|
+
if (path) {
|
|
179
|
+
violations.push({ kind: "unknown", path });
|
|
180
|
+
break;
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
return violations;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
export function isTraversalViolation(
|
|
188
|
+
runtimeConfig: SandboxRuntimeConfig | null,
|
|
189
|
+
violation: FilesystemViolation,
|
|
190
|
+
cwd?: string,
|
|
191
|
+
): boolean {
|
|
192
|
+
if (!runtimeConfig || !violation.path) return false;
|
|
193
|
+
if (!READ_TRAVERSAL_PROCESSES.has(violation.processName ?? "")) return false;
|
|
194
|
+
|
|
195
|
+
if (violation.kind === "read") {
|
|
196
|
+
return inferSandboxRuleMatch(violation.path, runtimeConfig.filesystem.denyRead, cwd) !== null;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
if (violation.kind !== "write" || violation.writeAccess !== "unlink") return false;
|
|
200
|
+
|
|
201
|
+
// Seatbelt can emit file-write-unlink checks for protected directory roots
|
|
202
|
+
// while traversal commands enumerate them. Only exact protected roots are
|
|
203
|
+
// treated as skipped traversal so writes under protected trees still prompt.
|
|
204
|
+
return (
|
|
205
|
+
inferExactSandboxRuleMatch(violation.path, runtimeConfig.filesystem.denyRead, cwd) !== null ||
|
|
206
|
+
inferExactSandboxRuleMatch(violation.path, runtimeConfig.filesystem.denyWrite, cwd) !== null
|
|
207
|
+
);
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
export function getTraversalPaths(options: {
|
|
211
|
+
runtimeConfig: SandboxRuntimeConfig | null;
|
|
212
|
+
output: string;
|
|
213
|
+
cwd?: string;
|
|
214
|
+
}): string[] | null {
|
|
215
|
+
const { runtimeConfig, output, cwd } = options;
|
|
216
|
+
if (!runtimeConfig) return null;
|
|
217
|
+
|
|
218
|
+
const violationLines = extractSandboxViolationLines(output);
|
|
219
|
+
if (violationLines.length === 0) return null;
|
|
220
|
+
|
|
221
|
+
const skippedPaths: string[] = [];
|
|
222
|
+
for (const line of violationLines) {
|
|
223
|
+
const violation = detectFilesystemViolationFromLine(line);
|
|
224
|
+
if (!violation || !isTraversalViolation(runtimeConfig, violation, cwd)) {
|
|
225
|
+
return null;
|
|
226
|
+
}
|
|
227
|
+
if (violation.path && !skippedPaths.includes(violation.path)) {
|
|
228
|
+
skippedPaths.push(violation.path);
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
return skippedPaths.length > 0 ? skippedPaths : null;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
export function formatTraversalNotice(paths: string[]): string {
|
|
236
|
+
if (paths.length === 0) return "";
|
|
237
|
+
|
|
238
|
+
const visiblePaths = paths.slice(0, 3).join(", ");
|
|
239
|
+
const suffix = paths.length > 3 ? ", ..." : "";
|
|
240
|
+
const label = paths.length === 1 ? "path" : "paths";
|
|
241
|
+
return `[sandbox] Continued after skipping protected ${label}: ${visiblePaths}${suffix}`;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
export function formatRuntimeProtectedWriteNotice(
|
|
245
|
+
violations: FilesystemViolation[],
|
|
246
|
+
continued: boolean,
|
|
247
|
+
): string {
|
|
248
|
+
const paths = violations
|
|
249
|
+
.map((violation) => violation.path)
|
|
250
|
+
.filter((path): path is string => path !== undefined);
|
|
251
|
+
if (paths.length === 0) return "";
|
|
252
|
+
|
|
253
|
+
const visiblePaths = paths.slice(0, 3).join(", ");
|
|
254
|
+
const suffix = paths.length > 3 ? ", ..." : "";
|
|
255
|
+
const label = paths.length === 1 ? "write" : "writes";
|
|
256
|
+
const prefix = continued ? "Continued after blocking" : "Blocked";
|
|
257
|
+
return `[sandbox] ${prefix} runtime-protected ${label} that sandbox config cannot override: ${visiblePaths}${suffix}`;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
interface FilesystemAllowAction {
|
|
261
|
+
list: FilesystemList;
|
|
262
|
+
op: ListOp;
|
|
263
|
+
value: string;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
function buildFilesystemAllowAction(
|
|
267
|
+
runtimeConfig: SandboxRuntimeConfig,
|
|
268
|
+
violation: FilesystemViolation,
|
|
269
|
+
cwd?: string,
|
|
270
|
+
): FilesystemAllowAction | null {
|
|
271
|
+
if (!violation.path) return null;
|
|
272
|
+
|
|
273
|
+
if (violation.kind === "read") {
|
|
274
|
+
const matchedRule = inferSandboxRuleMatch(
|
|
275
|
+
violation.path,
|
|
276
|
+
runtimeConfig.filesystem.denyRead,
|
|
277
|
+
cwd,
|
|
278
|
+
);
|
|
279
|
+
return { list: "deny-read", op: "remove", value: matchedRule ?? violation.path };
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
if (violation.kind === "write") {
|
|
283
|
+
const matchedDeny = inferSandboxRuleMatch(
|
|
284
|
+
violation.path,
|
|
285
|
+
runtimeConfig.filesystem.denyWrite,
|
|
286
|
+
cwd,
|
|
287
|
+
);
|
|
288
|
+
if (matchedDeny) {
|
|
289
|
+
return { list: "deny-write", op: "remove", value: matchedDeny };
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
return { list: "allow-write", op: "add", value: violation.path };
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
const matchedDenyWrite = inferSandboxRuleMatch(
|
|
296
|
+
violation.path,
|
|
297
|
+
runtimeConfig.filesystem.denyWrite,
|
|
298
|
+
cwd,
|
|
299
|
+
);
|
|
300
|
+
if (matchedDenyWrite) {
|
|
301
|
+
return { list: "deny-write", op: "remove", value: matchedDenyWrite };
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
const matchedDenyRead = inferSandboxRuleMatch(
|
|
305
|
+
violation.path,
|
|
306
|
+
runtimeConfig.filesystem.denyRead,
|
|
307
|
+
cwd,
|
|
308
|
+
);
|
|
309
|
+
if (matchedDenyRead) {
|
|
310
|
+
return { list: "deny-read", op: "remove", value: matchedDenyRead };
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
return { list: "allow-write", op: "add", value: violation.path };
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
function buildFilesystemAllowCommand(action: FilesystemAllowAction): string {
|
|
317
|
+
return `/sandbox filesystem ${action.list} ${action.op} ${escapeSlashCommandArg(action.value)}`;
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
function getFilesystemListValues(
|
|
321
|
+
runtimeConfig: SandboxRuntimeConfig,
|
|
322
|
+
list: FilesystemList,
|
|
323
|
+
): string[] {
|
|
324
|
+
if (list === "deny-read") return runtimeConfig.filesystem.denyRead;
|
|
325
|
+
if (list === "allow-write") return runtimeConfig.filesystem.allowWrite;
|
|
326
|
+
return runtimeConfig.filesystem.denyWrite;
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
function applyFilesystemAllowAction(
|
|
330
|
+
runtimeConfig: SandboxRuntimeConfig,
|
|
331
|
+
action: FilesystemAllowAction,
|
|
332
|
+
): boolean {
|
|
333
|
+
const values = getFilesystemListValues(runtimeConfig, action.list);
|
|
334
|
+
return mutateStringList(values, action.op, action.value);
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
function isFilesystemAllowActionAlreadyApplied(
|
|
338
|
+
runtimeConfig: SandboxRuntimeConfig,
|
|
339
|
+
action: FilesystemAllowAction,
|
|
340
|
+
): boolean {
|
|
341
|
+
const values = getFilesystemListValues(runtimeConfig, action.list);
|
|
342
|
+
return action.op === "add" ? values.includes(action.value) : !values.includes(action.value);
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
function describeFilesystemViolationTarget(violation: FilesystemViolation): string {
|
|
346
|
+
if (violation.kind === "read") {
|
|
347
|
+
if (violation.path) return `read from ${violation.path}`;
|
|
348
|
+
return "read";
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
if (violation.kind === "write") {
|
|
352
|
+
if (violation.path) return `write to ${violation.path}`;
|
|
353
|
+
return "write";
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
if (violation.path) return `access to ${violation.path}`;
|
|
357
|
+
return "access";
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
function formatFilesystemViolationSummary(violation: FilesystemViolation): string {
|
|
361
|
+
if (violation.kind === "read") {
|
|
362
|
+
if (violation.path) return `[sandbox] Blocked filesystem read: ${violation.path}`;
|
|
363
|
+
return "[sandbox] Blocked filesystem read.";
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
if (violation.kind === "write") {
|
|
367
|
+
if (violation.path) return `[sandbox] Blocked filesystem write: ${violation.path}`;
|
|
368
|
+
return "[sandbox] Blocked filesystem write.";
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
if (violation.path) return `[sandbox] Blocked filesystem access: ${violation.path}`;
|
|
372
|
+
return "[sandbox] Blocked filesystem access (EPERM).";
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
function classifyFilesystemEventReason(
|
|
376
|
+
runtimeConfig: SandboxRuntimeConfig,
|
|
377
|
+
violation: FilesystemViolation,
|
|
378
|
+
cwd?: string,
|
|
379
|
+
alreadyApproved = false,
|
|
380
|
+
): SandboxEventReason {
|
|
381
|
+
if (alreadyApproved) return "already-approved-still-failed";
|
|
382
|
+
|
|
383
|
+
if (violation.path) {
|
|
384
|
+
if (inferSandboxRuleMatch(violation.path, runtimeConfig.filesystem.denyWrite, cwd)) {
|
|
385
|
+
return "explicit-deny-write";
|
|
386
|
+
}
|
|
387
|
+
if (inferSandboxRuleMatch(violation.path, runtimeConfig.filesystem.denyRead, cwd)) {
|
|
388
|
+
return "explicit-deny-read";
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
if (violation.kind === "write" || violation.kind === "unknown") return "missing-allow-write";
|
|
393
|
+
return "unknown";
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
function describeFilesystemEventSummary(
|
|
397
|
+
reason: SandboxEventReason,
|
|
398
|
+
violation: FilesystemViolation,
|
|
399
|
+
outcome: SandboxEventOutcome,
|
|
400
|
+
): string {
|
|
401
|
+
if (outcome === "allowed") {
|
|
402
|
+
if (reason === "explicit-deny-read") return "user allowed filesystem read for this session";
|
|
403
|
+
if (reason === "explicit-deny-write") return "user allowed filesystem write for this session";
|
|
404
|
+
if (reason === "missing-allow-write") {
|
|
405
|
+
return violation.kind === "unknown"
|
|
406
|
+
? "user allowed filesystem access for this session"
|
|
407
|
+
: "user allowed filesystem write path for this session";
|
|
408
|
+
}
|
|
409
|
+
return "user allowed filesystem access for this session";
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
if (reason === "explicit-deny-read") return "filesystem read matched a deny-read rule";
|
|
413
|
+
if (reason === "explicit-deny-write") return "filesystem write matched a deny-write rule";
|
|
414
|
+
if (reason === "runtime-protected-write") {
|
|
415
|
+
return "filesystem write is protected by the sandbox runtime";
|
|
416
|
+
}
|
|
417
|
+
if (reason === "already-approved-still-failed") {
|
|
418
|
+
return "filesystem access was previously allowed for this session but is still failing";
|
|
419
|
+
}
|
|
420
|
+
if (reason === "missing-allow-write") {
|
|
421
|
+
return violation.kind === "unknown"
|
|
422
|
+
? "filesystem access fell outside the current allow-write paths"
|
|
423
|
+
: "filesystem write fell outside the current allow-write paths";
|
|
424
|
+
}
|
|
425
|
+
return "sandbox blocked filesystem access";
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
function formatFilesystemBlockedTarget(target: string): string {
|
|
429
|
+
return `Sandbox blocked filesystem ${target}.`;
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
export async function handleFilesystemViolation(options: {
|
|
433
|
+
ctx: ExtensionContext | null;
|
|
434
|
+
promptMode: PromptMode;
|
|
435
|
+
runtimeConfig: SandboxRuntimeConfig;
|
|
436
|
+
getRuntimeConfig: () => SandboxRuntimeConfig | null;
|
|
437
|
+
output: string;
|
|
438
|
+
rawOutput: string;
|
|
439
|
+
command: string;
|
|
440
|
+
cwd?: string;
|
|
441
|
+
pendingDialogs?: Map<string, Promise<PermissionResolution | null>>;
|
|
442
|
+
applyRuntimeConfigForSession?: (
|
|
443
|
+
ctx: ExtensionContext,
|
|
444
|
+
runtimeConfig: SandboxRuntimeConfig,
|
|
445
|
+
) => void;
|
|
446
|
+
recordEvent?: (event: SandboxEvent) => void;
|
|
447
|
+
autoRetryAvailable?: boolean;
|
|
448
|
+
runtimeProtectedWriteViolations?: FilesystemViolation[];
|
|
449
|
+
allowOutputFallback?: boolean;
|
|
450
|
+
}): Promise<PermissionResolution | null> {
|
|
451
|
+
const {
|
|
452
|
+
ctx,
|
|
453
|
+
promptMode,
|
|
454
|
+
runtimeConfig,
|
|
455
|
+
getRuntimeConfig,
|
|
456
|
+
output,
|
|
457
|
+
rawOutput,
|
|
458
|
+
command,
|
|
459
|
+
cwd,
|
|
460
|
+
pendingDialogs,
|
|
461
|
+
applyRuntimeConfigForSession,
|
|
462
|
+
recordEvent,
|
|
463
|
+
autoRetryAvailable = true,
|
|
464
|
+
runtimeProtectedWriteViolations = [],
|
|
465
|
+
allowOutputFallback = true,
|
|
466
|
+
} = options;
|
|
467
|
+
const violations = detectFilesystemViolations(output, rawOutput, allowOutputFallback);
|
|
468
|
+
const runtimeProtectedWritePaths = new Set(
|
|
469
|
+
runtimeProtectedWriteViolations.map((violation) => violation.path).filter(Boolean),
|
|
470
|
+
);
|
|
471
|
+
const actionableViolations = violations.filter((violation) => {
|
|
472
|
+
const isRuntimeProtectedWrite =
|
|
473
|
+
violation.kind !== "read" && runtimeProtectedWritePaths.has(violation.path);
|
|
474
|
+
return !isRuntimeProtectedWrite && !isTraversalViolation(runtimeConfig, violation, cwd);
|
|
475
|
+
});
|
|
476
|
+
if (actionableViolations.length === 0) return null;
|
|
477
|
+
|
|
478
|
+
const violation =
|
|
479
|
+
actionableViolations.find((candidate) => {
|
|
480
|
+
const candidateAction = buildFilesystemAllowAction(runtimeConfig, candidate, cwd);
|
|
481
|
+
if (!candidateAction) return false;
|
|
482
|
+
return !isFilesystemAllowActionAlreadyApplied(runtimeConfig, candidateAction);
|
|
483
|
+
}) ?? actionableViolations[0];
|
|
484
|
+
|
|
485
|
+
const summary = formatFilesystemViolationSummary(violation);
|
|
486
|
+
const target = describeFilesystemViolationTarget(violation);
|
|
487
|
+
const blockedTarget = formatFilesystemBlockedTarget(target);
|
|
488
|
+
const allowAction = buildFilesystemAllowAction(runtimeConfig, violation, cwd);
|
|
489
|
+
const allowCommand = allowAction ? buildFilesystemAllowCommand(allowAction) : null;
|
|
490
|
+
const alreadyApproved = allowAction
|
|
491
|
+
? isFilesystemAllowActionAlreadyApplied(runtimeConfig, allowAction)
|
|
492
|
+
: false;
|
|
493
|
+
const eventReason = classifyFilesystemEventReason(runtimeConfig, violation, cwd, alreadyApproved);
|
|
494
|
+
const blockedSuggestedCommand = alreadyApproved ? undefined : (allowCommand ?? undefined);
|
|
495
|
+
|
|
496
|
+
const recordFilesystemEvent = (outcome: SandboxEventOutcome): void => {
|
|
497
|
+
recordEvent?.({
|
|
498
|
+
timestamp: Date.now(),
|
|
499
|
+
kind: "filesystem",
|
|
500
|
+
outcome,
|
|
501
|
+
reason: eventReason,
|
|
502
|
+
target: violation.path,
|
|
503
|
+
command,
|
|
504
|
+
cwd,
|
|
505
|
+
summary: describeFilesystemEventSummary(eventReason, violation, outcome),
|
|
506
|
+
suggestedCommand: outcome === "blocked" ? blockedSuggestedCommand : undefined,
|
|
507
|
+
});
|
|
508
|
+
};
|
|
509
|
+
|
|
510
|
+
if (promptMode === "non-interactive" || !ctx?.hasUI) {
|
|
511
|
+
recordFilesystemEvent("blocked");
|
|
512
|
+
if (!allowCommand) return { kind: "deny", message: summary };
|
|
513
|
+
return {
|
|
514
|
+
kind: "deny",
|
|
515
|
+
message: `${summary}\n[sandbox] To temporarily allow for this session, run: ${allowCommand}`,
|
|
516
|
+
};
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
if (!allowAction || !allowCommand) {
|
|
520
|
+
recordFilesystemEvent("blocked");
|
|
521
|
+
return { kind: "deny", message: summary };
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
if (alreadyApproved) {
|
|
525
|
+
recordFilesystemEvent("blocked");
|
|
526
|
+
return createPermissionResolution("allow-adapt", blockedTarget, false);
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
const promptKey = `${allowCommand}:${autoRetryAvailable ? "retry" : "adapt"}`;
|
|
530
|
+
return showPermissionDialog({
|
|
531
|
+
ctx,
|
|
532
|
+
title: `Sandbox blocked filesystem ${target}`,
|
|
533
|
+
promptKey,
|
|
534
|
+
pendingDialogs,
|
|
535
|
+
autoRetryAvailable,
|
|
536
|
+
onDecision(decision) {
|
|
537
|
+
if (decision === "deny") {
|
|
538
|
+
recordFilesystemEvent("blocked");
|
|
539
|
+
return createPermissionResolution(decision, blockedTarget);
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
const latestConfig = getRuntimeConfig();
|
|
543
|
+
if (!latestConfig) {
|
|
544
|
+
recordFilesystemEvent("blocked");
|
|
545
|
+
return createPermissionResolution("deny", blockedTarget);
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
// Preserve policy edits made during the dialog, applying only the original exception.
|
|
549
|
+
const nextConfig = cloneRuntimeConfig(latestConfig);
|
|
550
|
+
const changed = applyFilesystemAllowAction(nextConfig, allowAction);
|
|
551
|
+
if (changed) {
|
|
552
|
+
applyRuntimeConfigForSession?.(ctx, nextConfig);
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
recordFilesystemEvent("allowed");
|
|
556
|
+
return createPermissionResolution(decision, blockedTarget, changed);
|
|
557
|
+
},
|
|
558
|
+
});
|
|
559
|
+
}
|