@rosetears/aili-pi 0.1.6 → 0.1.8
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 +16 -8
- package/THIRD_PARTY_NOTICES.md +7 -7
- package/extensions/header/index.ts +26 -16
- package/extensions/matrix/index.ts +390 -193
- package/extensions/zentui/config.ts +8 -6
- package/extensions/zentui/gradient.ts +37 -47
- package/extensions/zentui/thinking-message.ts +3 -3
- package/extensions/zentui/tool-execution.ts +6 -6
- package/extensions/zentui/ui.ts +3 -3
- package/licenses/pi-permission-modes-MIT.txt +21 -0
- package/manifests/adapter-evidence.json +10 -9
- package/manifests/live-verification.json +19 -11
- package/manifests/provenance.json +8 -8
- package/manifests/sbom.json +4 -4
- package/manifests/skill-compatibility.json +12 -12
- package/manifests/subagent-provenance.json +13 -3
- package/notices/pi-sakura-cyberdeck-NOTICE.txt +3 -3
- package/package.json +6 -4
- package/scripts/generate-provenance.ts +1 -1
- package/scripts/sync-permission-modes.ts +135 -0
- package/src/runtime/native-integrations.ts +5 -5
- package/src/runtime/package-resolution.ts +8 -0
- package/src/runtime/registry.ts +138 -9
- package/src/runtime/rose-theme.ts +26 -0
- package/src/runtime/subagents.ts +85 -10
- package/src/vendor/pi-permission-modes/index.ts +692 -0
- package/src/vendor/pi-permission-modes/resolve.ts +142 -0
- package/themes/rose-cyberdeck.json +35 -0
- package/upstream/pi-permission-modes.lock.json +47 -0
- package/themes/rem-cyberdeck.json +0 -32
- /package/src/runtime/{rem-head.txt → rose-head.txt} +0 -0
|
@@ -0,0 +1,692 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Generated AILI adaptation of pi-permission-modes@2.2.0.
|
|
3
|
+
* Source revision: 23d65d10a53b67043cae42322acf9044d6edb196.
|
|
4
|
+
* Regenerate with scripts/sync-permission-modes.ts; do not edit manually.
|
|
5
|
+
*/
|
|
6
|
+
/**
|
|
7
|
+
* Permission Mode extension
|
|
8
|
+
*
|
|
9
|
+
* Switchable, data-driven permission modes (cycle with alt+m or /perm). A MODE
|
|
10
|
+
* is a JSON bundle of a sandbox profile + an allow/ask/deny permission policy +
|
|
11
|
+
* UI metadata, defined in `schema.ts` (built-in defaults) and overridable in
|
|
12
|
+
* `permission-mode.json`. The four shipped modes:
|
|
13
|
+
*
|
|
14
|
+
* Default - confirm bash/edit/write; approved in-project bash runs sandboxed
|
|
15
|
+
* (writable), approved out-of-project runs UNSANDBOXED.
|
|
16
|
+
* Plan - planning mode. In-project bash runs sandboxed READ-ONLY; reads are
|
|
17
|
+
* free; only Markdown create/edit is allowed in-project. Out-of-project
|
|
18
|
+
* access prompts. A system prompt steers the model to write a plan file.
|
|
19
|
+
* Build - in-project reads/writes/bash run without confirmation; bash runs
|
|
20
|
+
* sandboxed. Prompts on out-of-project access or privilege escalation;
|
|
21
|
+
* approved out-of-project commands run UNSANDBOXED.
|
|
22
|
+
* YOLO - never prompts, never sandboxes; full user permissions.
|
|
23
|
+
*
|
|
24
|
+
* The OS sandbox (@anthropic-ai/sandbox-runtime) is the real enforcement for
|
|
25
|
+
* bash. If it's unavailable the sandboxed modes fall back to PROMPTING for
|
|
26
|
+
* in-project bash and the TUI indicator shows a warning.
|
|
27
|
+
*
|
|
28
|
+
* This entry file is wiring only — flags, commands, shortcuts, the tool_call
|
|
29
|
+
* dispatcher, the input/skill handler, and lifecycle. The adapted matcher lives locally; unchanged logic stays in pinned dependency
|
|
30
|
+
* modules:
|
|
31
|
+
* schema.ts mode definition types + plan prompt (defaults: permission-mode.defaults.json)
|
|
32
|
+
* resolve.ts surface resolution engine (allow/ask/deny)
|
|
33
|
+
* bash-enforce.ts bash gate + exec plan (sandbox composition)
|
|
34
|
+
* bash-parse.ts tree-sitter command extraction + heuristic fallback
|
|
35
|
+
* config-load.ts layered permission-mode.json loader
|
|
36
|
+
* approvals.ts session-scoped "Allow for session" store
|
|
37
|
+
* awareness.ts sandbox-boundary system-prompt section (injected each turn)
|
|
38
|
+
* network.ts live network session state (grants, denies, open toggle)
|
|
39
|
+
* paths.ts out-of-project / protected-path predicates
|
|
40
|
+
* heuristics.ts bash escape/privilege scan (tree-sitter fallback)
|
|
41
|
+
* sandbox.ts SandboxController (runtime lifecycle, per-mode profile)
|
|
42
|
+
* status.ts footer indicator
|
|
43
|
+
* show-plan.ts show_plan tool plan-render.ts its renderer
|
|
44
|
+
*
|
|
45
|
+
* Install (see README.md):
|
|
46
|
+
* pi install git:github.com/wynainfo/pi-permission-modes
|
|
47
|
+
* Linux also needs: bubblewrap, socat, ripgrep
|
|
48
|
+
*/
|
|
49
|
+
|
|
50
|
+
import { Type } from "@earendil-works/pi-ai";
|
|
51
|
+
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
52
|
+
import { createBashTool, getAgentDir } from "@earendil-works/pi-coding-agent";
|
|
53
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
54
|
+
import path from "node:path";
|
|
55
|
+
import { askWithSession, SessionApprovals } from "pi-permission-modes/src/approvals.ts";
|
|
56
|
+
import { sandboxAwarenessPrompt } from "pi-permission-modes/src/awareness.ts";
|
|
57
|
+
import { bashExecPlan, bashGate } from "pi-permission-modes/src/bash-enforce.ts";
|
|
58
|
+
import { analyzeBash } from "pi-permission-modes/src/bash-parse.ts";
|
|
59
|
+
import {
|
|
60
|
+
isUnsafeDomain,
|
|
61
|
+
loadModeConfig,
|
|
62
|
+
loadStockDefaults,
|
|
63
|
+
persistModeDomains,
|
|
64
|
+
persistModeRule,
|
|
65
|
+
profileToConfig,
|
|
66
|
+
stockDefaultsFile,
|
|
67
|
+
} from "pi-permission-modes/src/config-load.ts";
|
|
68
|
+
import type { PermState } from "pi-permission-modes/src/modes.ts";
|
|
69
|
+
import { type NetAskResult, NetworkSession, isHostAllowed, normalizeDomain } from "pi-permission-modes/src/network.ts";
|
|
70
|
+
import { isOutside, isProtectedWrite } from "pi-permission-modes/src/paths.ts";
|
|
71
|
+
import { decide, decideBashCommand, mostRestrictive } from "./resolve.ts";
|
|
72
|
+
import { SandboxController } from "pi-permission-modes/src/sandbox.ts";
|
|
73
|
+
import {
|
|
74
|
+
type Action,
|
|
75
|
+
type ModeDef,
|
|
76
|
+
PLAN_PROMPT_SENTINEL,
|
|
77
|
+
type PermissionModeConfig,
|
|
78
|
+
planModeSystemPrompt,
|
|
79
|
+
type Surface,
|
|
80
|
+
} from "pi-permission-modes/src/schema.ts";
|
|
81
|
+
import { createShowPlanTool } from "pi-permission-modes/src/show-plan.ts";
|
|
82
|
+
import { updateStatus } from "pi-permission-modes/src/status.ts";
|
|
83
|
+
|
|
84
|
+
/** Built-in file tools whose `input.path` is gated against the matching surface. */
|
|
85
|
+
const FILE_TOOL_SURFACE: Record<string, Surface> = {
|
|
86
|
+
read: "read",
|
|
87
|
+
edit: "edit",
|
|
88
|
+
write: "write",
|
|
89
|
+
ls: "ls",
|
|
90
|
+
grep: "grep",
|
|
91
|
+
find: "find",
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
/** Tools with a dedicated branch in the dispatcher (not gated via the `tool` surface). */
|
|
95
|
+
const BUILTIN_HANDLED = new Set([
|
|
96
|
+
"bash",
|
|
97
|
+
"read",
|
|
98
|
+
"edit",
|
|
99
|
+
"write",
|
|
100
|
+
"ls",
|
|
101
|
+
"grep",
|
|
102
|
+
"find",
|
|
103
|
+
"web_search",
|
|
104
|
+
"show_plan",
|
|
105
|
+
"request_network_access", // prompts on its own — gating it would double-prompt
|
|
106
|
+
]);
|
|
107
|
+
|
|
108
|
+
/** Our own tools that must never be hidden (show_plan is needed in Plan mode). */
|
|
109
|
+
const NEVER_HIDE = new Set(["show_plan"]);
|
|
110
|
+
|
|
111
|
+
export default async function (pi: ExtensionAPI) {
|
|
112
|
+
// The engine starts on the shipped stock defaults (permission-mode.defaults.json);
|
|
113
|
+
// session_start reloads the merged config (stock + global full-authority +
|
|
114
|
+
// project tighten-only) from permission-mode.json.
|
|
115
|
+
let config: PermissionModeConfig = loadStockDefaults();
|
|
116
|
+
let modeName = config.defaultMode;
|
|
117
|
+
// True when the current mode was auto-picked as the headless-child safety
|
|
118
|
+
// fallback (no --perm flag, session entry, or forwarded env). The mode's
|
|
119
|
+
// POLICY fully applies, but its systemPrompt is not injected — a planning
|
|
120
|
+
// prompt steering a headless worker to write plan files and ask about alt+m
|
|
121
|
+
// would misdirect it — and the fallback isn't exported to child processes
|
|
122
|
+
// as if it were an explicit choice (children re-derive their own fallback).
|
|
123
|
+
let fallbackMode = false;
|
|
124
|
+
const root = process.cwd();
|
|
125
|
+
|
|
126
|
+
const currentMode = (): ModeDef => config.modes[modeName] ?? config.modes[config.defaultMode];
|
|
127
|
+
|
|
128
|
+
const sandbox = new SandboxController();
|
|
129
|
+
// toolCallIds the user explicitly approved to run OUTSIDE the sandbox.
|
|
130
|
+
const approvedUnsandboxed = new Set<string>();
|
|
131
|
+
// "Allow for session" memory, keyed per-mode.
|
|
132
|
+
const approvals = new SessionApprovals();
|
|
133
|
+
// Live network state: session domain grants/denies and the open toggle. The
|
|
134
|
+
// sandbox-runtime proxy consults it (via net.decide) for every host outside
|
|
135
|
+
// the allowlist, so changes apply instantly — no sandbox re-init.
|
|
136
|
+
const net = new NetworkSession();
|
|
137
|
+
// Latest UI-capable context, for prompts that fire OUTSIDE an event handler
|
|
138
|
+
// (the network ask callback runs mid-bash, driven by the proxy).
|
|
139
|
+
let uiCtx: ExtensionContext | undefined;
|
|
140
|
+
|
|
141
|
+
/** Prompt the user about a blocked host (the connection waits meanwhile). */
|
|
142
|
+
const askNetHost = async (host: string, port: number | undefined): Promise<NetAskResult> => {
|
|
143
|
+
const ctx = uiCtx;
|
|
144
|
+
if (!ctx?.hasUI || currentMode().sandbox.askOnBlockedHost === false) return "dismiss";
|
|
145
|
+
const target = port !== undefined ? `${host}:${port}` : host;
|
|
146
|
+
const choice = await ctx.ui.select(`Network: bash wants to reach ${target} (not in the allowlist) — allow?`, [
|
|
147
|
+
"Allow for session",
|
|
148
|
+
"Allow forever",
|
|
149
|
+
"Deny",
|
|
150
|
+
]);
|
|
151
|
+
if (choice === "Allow for session") return "allow-session";
|
|
152
|
+
if (choice === "Allow forever") {
|
|
153
|
+
try {
|
|
154
|
+
const file = persistModeDomains(getAgentDir(), modeName, [host]);
|
|
155
|
+
config = loadModeConfig(ctx.cwd, getAgentDir(), (m) => ctx.ui.notify(m, "warning"));
|
|
156
|
+
ctx.ui.notify(`permission-mode: always allow ${host} in ${currentMode().label} — saved to ${file}`, "info");
|
|
157
|
+
} catch (e) {
|
|
158
|
+
ctx.ui.notify(`permission-mode: could not save domain: ${e}`, "error");
|
|
159
|
+
}
|
|
160
|
+
return "allow-forever";
|
|
161
|
+
}
|
|
162
|
+
if (choice === "Deny") return "deny";
|
|
163
|
+
return "dismiss"; // dismissed prompt: deny this request, but don't remember
|
|
164
|
+
};
|
|
165
|
+
|
|
166
|
+
/** Prompt to allow an `ask`, honoring session grants (a target list requires
|
|
167
|
+
* every entry to be granted; granting remembers all). Returns true to allow.
|
|
168
|
+
* When `onForever` is given, a fourth "Allow forever" option persists the rule. */
|
|
169
|
+
const promptAllow = (
|
|
170
|
+
ctx: ExtensionContext,
|
|
171
|
+
surface: Surface,
|
|
172
|
+
target: string | string[],
|
|
173
|
+
title: string,
|
|
174
|
+
onForever?: () => void | Promise<void>,
|
|
175
|
+
): Promise<boolean> =>
|
|
176
|
+
askWithSession(
|
|
177
|
+
{ hasUI: ctx.hasUI, select: (t, o) => ctx.ui.select(t, o) },
|
|
178
|
+
approvals,
|
|
179
|
+
modeName,
|
|
180
|
+
surface,
|
|
181
|
+
target,
|
|
182
|
+
title,
|
|
183
|
+
onForever,
|
|
184
|
+
);
|
|
185
|
+
|
|
186
|
+
/** "Allow forever": persist `<mode>.permission.<surface>.<key> = allow` to the
|
|
187
|
+
* global config and hot-reload so it applies now and in future sessions. */
|
|
188
|
+
const learnForever = (ctx: ExtensionContext, surface: Surface, key: string) => () => {
|
|
189
|
+
try {
|
|
190
|
+
const file = persistModeRule(getAgentDir(), modeName, surface, key, "allow");
|
|
191
|
+
config = loadModeConfig(ctx.cwd, getAgentDir(), (m) => ctx.ui.notify(m, "warning"));
|
|
192
|
+
ctx.ui.notify(`permission-mode: always allow ${surface} "${key}" in ${currentMode().label} — saved to ${file}`, "info");
|
|
193
|
+
} catch (e) {
|
|
194
|
+
ctx.ui.notify(`permission-mode: could not save rule: ${e}`, "error");
|
|
195
|
+
}
|
|
196
|
+
};
|
|
197
|
+
|
|
198
|
+
const localBash = createBashTool(root);
|
|
199
|
+
|
|
200
|
+
pi.registerFlag("perm", {
|
|
201
|
+
description: `Start in permission mode: ${config.cycleOrder.join(" | ")}`,
|
|
202
|
+
type: "string",
|
|
203
|
+
default: "",
|
|
204
|
+
});
|
|
205
|
+
pi.registerFlag("no-sandbox", {
|
|
206
|
+
description: "Disable OS-level sandboxing for the sandboxed modes (heuristics + prompts only)",
|
|
207
|
+
type: "boolean",
|
|
208
|
+
default: false,
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
// Hide a mode's `hideTools` from the model (pre-exposure), restoring the rest.
|
|
212
|
+
const applyToolVisibility = () => {
|
|
213
|
+
const hide = new Set((currentMode().hideTools ?? []).filter((n) => !NEVER_HIDE.has(n)));
|
|
214
|
+
const all = pi.getAllTools().map((t) => t.name);
|
|
215
|
+
pi.setActiveTools(hide.size ? all.filter((n) => !hide.has(n)) : all);
|
|
216
|
+
};
|
|
217
|
+
|
|
218
|
+
const setMode = async (name: string, ctx: ExtensionContext, persist = true, viaFallback = false) => {
|
|
219
|
+
if (!config.modes[name]) return;
|
|
220
|
+
uiCtx = ctx;
|
|
221
|
+
modeName = name;
|
|
222
|
+
fallbackMode = viaFallback;
|
|
223
|
+
// Forward to child pi processes (e.g. subagents) via inherited env. The child
|
|
224
|
+
// adopts it on session_start unless an explicit --perm flag overrides. The
|
|
225
|
+
// implicit headless fallback is NOT forwarded: a child with no explicit mode
|
|
226
|
+
// derives the same safe fallback itself (and skips the systemPrompt too).
|
|
227
|
+
if (!viaFallback) process.env.PI_PERMISSION_MODE = modeName;
|
|
228
|
+
const m = currentMode();
|
|
229
|
+
await sandbox.applyProfile(m.sandbox); // re-init runtime if the profile changed
|
|
230
|
+
applyToolVisibility();
|
|
231
|
+
updateStatus(ctx, m, sandbox, net.open);
|
|
232
|
+
ctx.ui.notify(`Permission mode: ${m.label}`, "info");
|
|
233
|
+
if (persist) pi.appendEntry<PermState>("perm-mode", { mode: modeName });
|
|
234
|
+
};
|
|
235
|
+
|
|
236
|
+
/** The most restrictive built-in-style mode for a headless child without a
|
|
237
|
+
* forwarded mode: a read-only sandboxed mode if any, else the default. */
|
|
238
|
+
const safeChildMode = (): string => {
|
|
239
|
+
const ro = config.cycleOrder.find((n) => config.modes[n]?.sandbox.enabled && !config.modes[n]?.sandbox.writable);
|
|
240
|
+
const sandboxed = config.cycleOrder.find((n) => config.modes[n]?.sandbox.enabled);
|
|
241
|
+
return ro ?? sandboxed ?? config.defaultMode;
|
|
242
|
+
};
|
|
243
|
+
|
|
244
|
+
const cycle = async (ctx: ExtensionContext) => {
|
|
245
|
+
const order = config.cycleOrder;
|
|
246
|
+
const idx = order.indexOf(modeName);
|
|
247
|
+
await setMode(order[(idx + 1) % order.length], ctx);
|
|
248
|
+
};
|
|
249
|
+
|
|
250
|
+
/**
|
|
251
|
+
* Resolve which mode to start in:
|
|
252
|
+
* 1. explicit --perm flag, then 2. persisted session entry (resume),
|
|
253
|
+
* 3. PI_PERMISSION_MODE env (forwarded from a parent / user-set),
|
|
254
|
+
* 4. headless child with none of the above → most restrictive (never YOLO),
|
|
255
|
+
* flagged as `fallback` (policy applies, systemPrompt is not injected),
|
|
256
|
+
* 5. the current/default mode.
|
|
257
|
+
*/
|
|
258
|
+
const pickMode = (ctx: ExtensionContext, useFlag: boolean): { name: string; fallback: boolean } => {
|
|
259
|
+
let resolved: string | undefined;
|
|
260
|
+
if (useFlag) {
|
|
261
|
+
const flag = String(pi.getFlag("perm") ?? "").toLowerCase();
|
|
262
|
+
if (config.modes[flag]) resolved = flag;
|
|
263
|
+
}
|
|
264
|
+
for (const entry of ctx.sessionManager.getEntries()) {
|
|
265
|
+
if (entry.type === "custom" && entry.customType === "perm-mode") {
|
|
266
|
+
const data = entry.data as PermState | undefined;
|
|
267
|
+
if (data?.mode && config.modes[data.mode]) resolved = data.mode;
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
if (!resolved) {
|
|
271
|
+
const env = process.env.PI_PERMISSION_MODE;
|
|
272
|
+
if (env && config.modes[env]) resolved = env;
|
|
273
|
+
}
|
|
274
|
+
if (!resolved && !ctx.hasUI) return { name: safeChildMode(), fallback: true }; // headless child, no forwarded mode
|
|
275
|
+
return { name: resolved ?? modeName, fallback: false };
|
|
276
|
+
};
|
|
277
|
+
|
|
278
|
+
pi.registerShortcut("alt+m", {
|
|
279
|
+
description: `Cycle permission mode (${config.cycleOrder.join(" -> ")})`,
|
|
280
|
+
handler: async (ctx) => cycle(ctx),
|
|
281
|
+
});
|
|
282
|
+
pi.registerCommand("perm", {
|
|
283
|
+
description: `Set or cycle permission mode: /perm [${config.cycleOrder.join("|")}|init|clear-approvals]`,
|
|
284
|
+
handler: async (args, ctx) => {
|
|
285
|
+
const arg = args.trim().toLowerCase();
|
|
286
|
+
if (arg === "clear-approvals") {
|
|
287
|
+
approvals.clearAll();
|
|
288
|
+
return ctx.ui.notify("permission-mode: cleared session approvals", "info");
|
|
289
|
+
}
|
|
290
|
+
if (arg === "init") {
|
|
291
|
+
// Scaffold an editable copy of the stock defaults at the global path.
|
|
292
|
+
const dest = path.join(getAgentDir(), "permission-mode", "permission-mode.json");
|
|
293
|
+
if (existsSync(dest)) {
|
|
294
|
+
return ctx.ui.notify(`permission-mode: ${dest} already exists — edit it directly`, "warning");
|
|
295
|
+
}
|
|
296
|
+
try {
|
|
297
|
+
mkdirSync(path.dirname(dest), { recursive: true });
|
|
298
|
+
writeFileSync(dest, readFileSync(stockDefaultsFile(), "utf-8"));
|
|
299
|
+
return ctx.ui.notify(`permission-mode: wrote ${dest} — edit it to customize your modes`, "info");
|
|
300
|
+
} catch (e) {
|
|
301
|
+
return ctx.ui.notify(`permission-mode: could not write ${dest}: ${e}`, "error");
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
if (config.modes[arg]) await setMode(arg, ctx);
|
|
305
|
+
else await cycle(ctx);
|
|
306
|
+
},
|
|
307
|
+
});
|
|
308
|
+
const networkEnforcing = () => currentMode().sandbox.enabled && sandbox.ready;
|
|
309
|
+
|
|
310
|
+
const toggleNetwork = async (ctx: ExtensionContext) => {
|
|
311
|
+
uiCtx = ctx;
|
|
312
|
+
if (!networkEnforcing()) {
|
|
313
|
+
return ctx.ui.notify("permission-mode: network is not filtered in this mode/state — nothing to toggle", "info");
|
|
314
|
+
}
|
|
315
|
+
net.open = !net.open;
|
|
316
|
+
updateStatus(ctx, currentMode(), sandbox, net.open);
|
|
317
|
+
ctx.ui.notify(
|
|
318
|
+
net.open
|
|
319
|
+
? "Network: OPEN for this session — domain filtering disabled (alt+n or /net restrict to re-enable)"
|
|
320
|
+
: "Network: filtered — domain allowlist active",
|
|
321
|
+
net.open ? "warning" : "info",
|
|
322
|
+
);
|
|
323
|
+
};
|
|
324
|
+
|
|
325
|
+
pi.registerShortcut("alt+n", {
|
|
326
|
+
description: "Toggle network filtering for this session (filtered <-> open)",
|
|
327
|
+
handler: toggleNetwork,
|
|
328
|
+
});
|
|
329
|
+
|
|
330
|
+
pi.registerCommand("net", {
|
|
331
|
+
description: "Network sandbox: /net [status|allow <domain…>|open|restrict|reset]",
|
|
332
|
+
handler: async (args, ctx) => {
|
|
333
|
+
uiCtx = ctx;
|
|
334
|
+
const m = currentMode();
|
|
335
|
+
const parts = args.trim().split(/\s+/).filter(Boolean);
|
|
336
|
+
const sub = (parts[0] ?? "status").toLowerCase();
|
|
337
|
+
if (sub === "open") {
|
|
338
|
+
if (!net.open) return toggleNetwork(ctx);
|
|
339
|
+
return ctx.ui.notify("permission-mode: network is already open for this session", "info");
|
|
340
|
+
}
|
|
341
|
+
if (sub === "restrict") {
|
|
342
|
+
if (net.open) return toggleNetwork(ctx);
|
|
343
|
+
return ctx.ui.notify("permission-mode: network filtering is already active", "info");
|
|
344
|
+
}
|
|
345
|
+
if (sub === "reset") {
|
|
346
|
+
net.clear();
|
|
347
|
+
updateStatus(ctx, m, sandbox, net.open);
|
|
348
|
+
return ctx.ui.notify("permission-mode: cleared session network grants/denies; filtering restored", "info");
|
|
349
|
+
}
|
|
350
|
+
if (sub === "allow") {
|
|
351
|
+
const domains = [...new Set(parts.slice(1).map(normalizeDomain).filter((d): d is string => d !== undefined))];
|
|
352
|
+
const safe = domains.filter((d) => !isUnsafeDomain(d));
|
|
353
|
+
if (safe.length === 0) {
|
|
354
|
+
return ctx.ui.notify("permission-mode: usage: /net allow <domain> [domain…] — concrete hosts or *.sub wildcards", "warning");
|
|
355
|
+
}
|
|
356
|
+
net.grant(safe);
|
|
357
|
+
const dropped = domains.length - safe.length;
|
|
358
|
+
return ctx.ui.notify(
|
|
359
|
+
`permission-mode: allowed for this session: ${safe.join(", ")}${dropped ? ` (${dropped} overly-broad pattern(s) rejected)` : ""}`,
|
|
360
|
+
"info",
|
|
361
|
+
);
|
|
362
|
+
}
|
|
363
|
+
// status (default)
|
|
364
|
+
const state = !networkEnforcing()
|
|
365
|
+
? "OPEN — nothing filters in this mode/state"
|
|
366
|
+
: net.open
|
|
367
|
+
? "OPEN for this session (/net restrict or alt+n to re-enable filtering)"
|
|
368
|
+
: "filtered (alt+n or /net open to disable)";
|
|
369
|
+
return ctx.ui.notify(
|
|
370
|
+
[
|
|
371
|
+
`Network (${m.label}): ${state}`,
|
|
372
|
+
`Mode allowlist: ${m.sandbox.network?.allowedDomains?.join(", ") || "(none)"}`,
|
|
373
|
+
`Session grants: ${net.grants().join(", ") || "(none)"}`,
|
|
374
|
+
`Session denies: ${net.denies().join(", ") || "(none)"}`,
|
|
375
|
+
].join("\n"),
|
|
376
|
+
"info",
|
|
377
|
+
);
|
|
378
|
+
},
|
|
379
|
+
});
|
|
380
|
+
|
|
381
|
+
pi.registerCommand("sandbox", {
|
|
382
|
+
description: "Show the active mode's sandbox status and configuration",
|
|
383
|
+
handler: async (_args, ctx) => {
|
|
384
|
+
const m = currentMode();
|
|
385
|
+
if (!m.sandbox.enabled) {
|
|
386
|
+
return ctx.ui.notify(`${m.label}: sandbox disabled for this mode (bash runs unsandboxed)`, "info");
|
|
387
|
+
}
|
|
388
|
+
if (sandbox.disabled) return ctx.ui.notify("Sandbox disabled via --no-sandbox", "info");
|
|
389
|
+
if (!sandbox.ready) return ctx.ui.notify(`Sandbox unavailable: ${sandbox.warn ?? "unknown"}`, "warning");
|
|
390
|
+
const c = profileToConfig(m.sandbox);
|
|
391
|
+
ctx.ui.notify(
|
|
392
|
+
[
|
|
393
|
+
`Sandbox: ACTIVE for ${m.label} (${m.sandbox.writable ? "project-writable" : "read-only"})`,
|
|
394
|
+
"",
|
|
395
|
+
`Network: ${net.open ? "OPEN for this session (alt+n)" : "filtered (alt+n)"}`,
|
|
396
|
+
`Network allowed: ${c.network?.allowedDomains?.join(", ") || "(none)"}`,
|
|
397
|
+
`Session grants: ${net.grants().join(", ") || "(none)"}`,
|
|
398
|
+
`Deny read: ${c.filesystem?.denyRead?.join(", ") || "(none)"}`,
|
|
399
|
+
`Allow write: ${c.filesystem?.allowWrite?.join(", ") || "(none)"}`,
|
|
400
|
+
`Deny write: ${c.filesystem?.denyWrite?.join(", ") || "(none)"}`,
|
|
401
|
+
].join("\n"),
|
|
402
|
+
"info",
|
|
403
|
+
);
|
|
404
|
+
},
|
|
405
|
+
});
|
|
406
|
+
|
|
407
|
+
// Replace built-in bash so the sandbox can wrap it. Sandboxing is recomputed at
|
|
408
|
+
// run time from the active mode's profile: disabled (YOLO), approved escapes,
|
|
409
|
+
// and the degraded fallback run unsandboxed; non-writable modes run read-only.
|
|
410
|
+
pi.registerTool({
|
|
411
|
+
...localBash,
|
|
412
|
+
async execute(id, params, signal, onUpdate, _ctx) {
|
|
413
|
+
const approved = approvedUnsandboxed.delete(id); // user granted an escape
|
|
414
|
+
const m = currentMode();
|
|
415
|
+
const plan = bashExecPlan(m.sandbox.enabled, m.sandbox.writable, sandbox.ready, approved);
|
|
416
|
+
const ops = plan.sandboxed ? sandbox.bashOps({ readOnly: plan.readOnly }) : null;
|
|
417
|
+
if (!ops) return localBash.execute(id, params, signal, onUpdate);
|
|
418
|
+
const sandboxed = createBashTool(root, { operations: ops });
|
|
419
|
+
return sandboxed.execute(id, params, signal, onUpdate);
|
|
420
|
+
},
|
|
421
|
+
});
|
|
422
|
+
|
|
423
|
+
// Plan Mode helper: render a written plan file to the user (display-only).
|
|
424
|
+
pi.registerTool(await createShowPlanTool(root));
|
|
425
|
+
|
|
426
|
+
// Model-initiated network grants: ask the user to allow domains outside the
|
|
427
|
+
// sandbox allowlist (batch-capable, so one prompt covers an install that
|
|
428
|
+
// needs several hosts). Grants land in the session state the ask callback
|
|
429
|
+
// reads — no sandbox re-init. Available in every mode; when nothing filters
|
|
430
|
+
// (YOLO, degraded, /net open) it says so instead of prompting.
|
|
431
|
+
pi.registerTool({
|
|
432
|
+
name: "request_network_access",
|
|
433
|
+
label: "Request network access",
|
|
434
|
+
description:
|
|
435
|
+
"Ask the user to allow bash network access to one or more domains outside the sandbox allowlist. " +
|
|
436
|
+
"Use it when a blocked host stops you (downloads, installs, API troubleshooting), or to get every needed " +
|
|
437
|
+
"domain approved up front before a command that hits several hosts. Accepts exact hosts (api.example.com) " +
|
|
438
|
+
"and subdomain wildcards (*.example.com).",
|
|
439
|
+
parameters: Type.Object({
|
|
440
|
+
domains: Type.Array(Type.String({ description: "Domain or subdomain wildcard, e.g. api.example.com or *.example.com" }), {
|
|
441
|
+
description: "The domains to request access to",
|
|
442
|
+
}),
|
|
443
|
+
reason: Type.String({ description: "One short line: why access is needed" }),
|
|
444
|
+
}),
|
|
445
|
+
async execute(_id, params, _signal, _onUpdate, ctx) {
|
|
446
|
+
const text = (t: string) => ({ content: [{ type: "text" as const, text: t }], details: {} });
|
|
447
|
+
const { domains = [], reason = "" } = params as { domains?: string[]; reason?: string };
|
|
448
|
+
const m = currentMode();
|
|
449
|
+
if (!m.sandbox.enabled || !sandbox.ready || net.open) {
|
|
450
|
+
return text("Network is not filtered right now — no grant needed, just run the command.");
|
|
451
|
+
}
|
|
452
|
+
const normalized = [...new Set(domains.map(normalizeDomain).filter((d): d is string => d !== undefined))];
|
|
453
|
+
const rejected = normalized.filter(isUnsafeDomain);
|
|
454
|
+
const modeList = m.sandbox.network?.allowedDomains ?? [];
|
|
455
|
+
const covered = (d: string) =>
|
|
456
|
+
modeList.includes(d) || net.grants().includes(d) || (!d.startsWith("*.") && (isHostAllowed(d, modeList) || net.isGranted(d)));
|
|
457
|
+
const need = normalized.filter((d) => !isUnsafeDomain(d) && !covered(d));
|
|
458
|
+
const rejectedNote = rejected.length ? ` Rejected as overly broad: ${rejected.join(", ")}.` : "";
|
|
459
|
+
if (need.length === 0) {
|
|
460
|
+
return text(
|
|
461
|
+
rejected.length && normalized.length === rejected.length
|
|
462
|
+
? `No grantable domains.${rejectedNote} Use a concrete host or a *.sub wildcard.`
|
|
463
|
+
: `Already allowed — just run the command.${rejectedNote}`,
|
|
464
|
+
);
|
|
465
|
+
}
|
|
466
|
+
const pctx = ctx ?? uiCtx;
|
|
467
|
+
if (!pctx?.hasUI) return text("Denied: no interactive user available to approve network access.");
|
|
468
|
+
const choice = await pctx.ui.select(
|
|
469
|
+
`Model requests network access to ${need.join(", ")} — ${reason.trim() || "no reason given"}. Allow?`,
|
|
470
|
+
["Allow for session", "Allow forever", "Deny"],
|
|
471
|
+
);
|
|
472
|
+
if (choice !== "Allow for session" && choice !== "Allow forever") {
|
|
473
|
+
return text(
|
|
474
|
+
`Denied by the user: ${need.join(", ")}. Don't retry these hosts — work within the allowlist or ask the user how to proceed.${rejectedNote}`,
|
|
475
|
+
);
|
|
476
|
+
}
|
|
477
|
+
net.grant(need);
|
|
478
|
+
let saved = "";
|
|
479
|
+
if (choice === "Allow forever") {
|
|
480
|
+
try {
|
|
481
|
+
const file = persistModeDomains(getAgentDir(), modeName, need);
|
|
482
|
+
config = loadModeConfig(pctx.cwd, getAgentDir(), (msg) => pctx.ui.notify(msg, "warning"));
|
|
483
|
+
saved = ` (saved permanently for ${currentMode().label})`;
|
|
484
|
+
pctx.ui.notify(`permission-mode: always allow ${need.join(", ")} in ${currentMode().label} — saved to ${file}`, "info");
|
|
485
|
+
} catch (e) {
|
|
486
|
+
pctx.ui.notify(`permission-mode: could not save domains: ${e}`, "error");
|
|
487
|
+
}
|
|
488
|
+
}
|
|
489
|
+
return text(`Granted${saved}: ${need.join(", ")}. Retry the blocked command now.${rejectedNote}`);
|
|
490
|
+
},
|
|
491
|
+
});
|
|
492
|
+
|
|
493
|
+
pi.on("session_start", async (_event, ctx) => {
|
|
494
|
+
uiCtx = ctx;
|
|
495
|
+
config = loadModeConfig(ctx.cwd, getAgentDir(), (m) =>
|
|
496
|
+
ctx.hasUI ? ctx.ui.notify(m, "warning") : console.error(m),
|
|
497
|
+
);
|
|
498
|
+
if (!config.modes[modeName]) modeName = config.defaultMode;
|
|
499
|
+
// Resolve the start mode BEFORE init so the sandbox initializes with the
|
|
500
|
+
// right profile, then setMode reconciles status (applyProfile is a no-op).
|
|
501
|
+
const picked = pickMode(ctx, true);
|
|
502
|
+
modeName = picked.name;
|
|
503
|
+
await sandbox.init({
|
|
504
|
+
cwd: ctx.cwd,
|
|
505
|
+
noSandbox: pi.getFlag("no-sandbox") === true,
|
|
506
|
+
hasUI: ctx.hasUI,
|
|
507
|
+
notify: (m) => ctx.ui.notify(m, "warning"),
|
|
508
|
+
profile: currentMode().sandbox,
|
|
509
|
+
// Live network gate: the proxy consults session state for every host
|
|
510
|
+
// outside the allowlist; unknown hosts prompt via askNetHost.
|
|
511
|
+
askHost: (host, port) => net.decide(host, port, askNetHost),
|
|
512
|
+
drainBlockedHosts: () => net.drainBlocked(),
|
|
513
|
+
});
|
|
514
|
+
await setMode(picked.name, ctx, false, picked.fallback);
|
|
515
|
+
});
|
|
516
|
+
pi.on("session_tree", async (_event, ctx) => {
|
|
517
|
+
uiCtx = ctx;
|
|
518
|
+
const picked = pickMode(ctx, false);
|
|
519
|
+
await setMode(picked.name, ctx, false, picked.fallback);
|
|
520
|
+
});
|
|
521
|
+
pi.on("session_shutdown", async () => {
|
|
522
|
+
approvals.clearAll(); // session-scoped grants don't outlive the session
|
|
523
|
+
net.clear(); // network grants/denies and the open toggle are session-scoped too
|
|
524
|
+
await sandbox.reset();
|
|
525
|
+
});
|
|
526
|
+
|
|
527
|
+
// Drop any unconsumed escape grant once the tool call resolves.
|
|
528
|
+
pi.on("tool_execution_end", async (event) => {
|
|
529
|
+
approvedUnsandboxed.delete(event.toolCallId);
|
|
530
|
+
});
|
|
531
|
+
|
|
532
|
+
// Inject the sandbox-awareness section and the active mode's system prompt
|
|
533
|
+
// (if any). The handler runs each turn and reads the live mode + sandbox
|
|
534
|
+
// state, so it auto-updates when the mode changes. The "@plan" sentinel
|
|
535
|
+
// resolves to the date-stamped Plan-mode prompt. A mode picked as the
|
|
536
|
+
// implicit headless fallback keeps the FACTUAL awareness section (boundary
|
|
537
|
+
// knowledge helps a worker avoid failing commands) but skips the mode's
|
|
538
|
+
// STEERING systemPrompt, which would misdirect a headless worker.
|
|
539
|
+
pi.on("before_agent_start", async (event) => {
|
|
540
|
+
applyToolVisibility(); // keep hidden tools hidden as the tool set evolves
|
|
541
|
+
const m = currentMode();
|
|
542
|
+
const parts: string[] = [];
|
|
543
|
+
const aware = sandboxAwarenessPrompt(m, {
|
|
544
|
+
active: sandbox.ready && !sandbox.disabled,
|
|
545
|
+
reason: sandbox.disabled ? "disabled via --no-sandbox" : sandbox.warn,
|
|
546
|
+
networkOpen: net.open,
|
|
547
|
+
sessionDomains: net.grants(),
|
|
548
|
+
});
|
|
549
|
+
if (aware) parts.push(aware);
|
|
550
|
+
const sp = m.systemPrompt;
|
|
551
|
+
if (sp && !fallbackMode) {
|
|
552
|
+
parts.push(sp === PLAN_PROMPT_SENTINEL ? planModeSystemPrompt(new Date().toISOString().slice(0, 10)) : sp);
|
|
553
|
+
}
|
|
554
|
+
if (parts.length === 0) return undefined;
|
|
555
|
+
return { systemPrompt: [event.systemPrompt, ...parts].join("\n\n") };
|
|
556
|
+
});
|
|
557
|
+
|
|
558
|
+
// Skill gating: skills aren't tools — they're invoked via `/skill:<name>` text,
|
|
559
|
+
// so intercept the input before expansion and resolve the `skill` surface.
|
|
560
|
+
pi.on("input", async (event, ctx) => {
|
|
561
|
+
const match = /^\/skill:([\w-]+)/.exec(event.text.trim());
|
|
562
|
+
if (!match) return undefined;
|
|
563
|
+
const name = match[1];
|
|
564
|
+
const action = decide(currentMode(), "skill", name);
|
|
565
|
+
if (action === "deny") {
|
|
566
|
+
if (ctx.hasUI) ctx.ui.notify(`Skill "${name}" blocked in ${currentMode().label}`, "warning");
|
|
567
|
+
return { action: "handled" as const };
|
|
568
|
+
}
|
|
569
|
+
if (
|
|
570
|
+
action === "ask" &&
|
|
571
|
+
!(await promptAllow(ctx, "skill", name, `Skill "${name}" — first use in ${currentMode().label}; allow?`, learnForever(ctx, "skill", name)))
|
|
572
|
+
) {
|
|
573
|
+
return { action: "handled" as const };
|
|
574
|
+
}
|
|
575
|
+
return undefined; // allow → continue to expansion
|
|
576
|
+
});
|
|
577
|
+
|
|
578
|
+
pi.on("tool_call", async (event, ctx) => {
|
|
579
|
+
uiCtx = ctx;
|
|
580
|
+
const { toolName } = event;
|
|
581
|
+
const m = currentMode();
|
|
582
|
+
// `input` is a discriminated union across tools; view it loosely (custom
|
|
583
|
+
// tools surface as Record<string, unknown> anyway) and guard each field.
|
|
584
|
+
const input = event.input as Record<string, unknown>;
|
|
585
|
+
const inPath = typeof input.path === "string" ? input.path : undefined;
|
|
586
|
+
|
|
587
|
+
// Hard backstop: never write to protected paths (file tools aren't sandboxed),
|
|
588
|
+
// unless the mode explicitly trusts everything (YOLO). Matched lexically AND
|
|
589
|
+
// on the symlink-resolved canonical path, so a link can't smuggle the write.
|
|
590
|
+
if (!m.bypassProtectedPaths && (toolName === "edit" || toolName === "write") && inPath !== undefined) {
|
|
591
|
+
if (isProtectedWrite(root, inPath)) {
|
|
592
|
+
if (ctx.hasUI) ctx.ui.notify(`Blocked write to protected path: ${inPath}`, "warning");
|
|
593
|
+
return { block: true, reason: `Path "${inPath}" is protected` };
|
|
594
|
+
}
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
// Bash.
|
|
598
|
+
if (toolName === "bash") {
|
|
599
|
+
const command = String(input.command ?? "");
|
|
600
|
+
// Fast path: non-sandboxing modes skip AST escape analysis, but still
|
|
601
|
+
// honor their explicit bash policy. YOLO remains silent because its
|
|
602
|
+
// policy is `allow`; an unsandboxed `ask` mode must still prompt.
|
|
603
|
+
if (!m.sandbox.enabled) {
|
|
604
|
+
const gate = bashGate(decide(m, "bash", command), undefined, false, sandbox.ready);
|
|
605
|
+
if (gate.kind === "block") return { block: true, reason: gate.reason };
|
|
606
|
+
if (gate.kind === "prompt") {
|
|
607
|
+
// Without an AST parse, scope a session grant to the exact command.
|
|
608
|
+
if (!(await promptAllow(ctx, "bash", command, gate.title))) {
|
|
609
|
+
return { block: true, reason: gate.reason };
|
|
610
|
+
}
|
|
611
|
+
if (gate.onApproveUnsandboxed) approvedUnsandboxed.add(event.toolCallId);
|
|
612
|
+
}
|
|
613
|
+
return undefined;
|
|
614
|
+
}
|
|
615
|
+
// Real AST when available: judge each (possibly nested) command against the
|
|
616
|
+
// bash surface AND the cross-cutting path gate (joined string + each token,
|
|
617
|
+
// see decideBashCommand), most-restrictive across the chain; detect
|
|
618
|
+
// escapes/privilege.
|
|
619
|
+
const analysis = await analyzeBash(command, root);
|
|
620
|
+
let action: Action;
|
|
621
|
+
if (analysis.commands.length > 0) {
|
|
622
|
+
action = analysis.commands
|
|
623
|
+
.map((c) => decideBashCommand(m, c.name, c.args) ?? "allow")
|
|
624
|
+
.reduce<Action>((a, b) => mostRestrictive(a, b) ?? "allow", "allow");
|
|
625
|
+
} else {
|
|
626
|
+
action = decide(m, "bash", command);
|
|
627
|
+
}
|
|
628
|
+
const gate = bashGate(action, analysis.outsideReason, m.sandbox.enabled, sandbox.ready);
|
|
629
|
+
if (gate.kind === "block") return { block: true, reason: gate.reason };
|
|
630
|
+
if (gate.kind === "prompt") {
|
|
631
|
+
// Session approvals are keyed on the extracted command names, and ALL
|
|
632
|
+
// names in a chain must be granted for it to pass silently — "allow git
|
|
633
|
+
// this session" must not cover `git status && curl ... | sh`. Granting
|
|
634
|
+
// remembers every name in the chain. Without a parse (heuristic
|
|
635
|
+
// fallback), the key is the exact command string.
|
|
636
|
+
const names = [...new Set(analysis.commands.map((c) => c.name).filter(Boolean))];
|
|
637
|
+
const keys = names.length > 0 ? names : [command];
|
|
638
|
+
if (!(await promptAllow(ctx, "bash", keys, gate.title))) return { block: true, reason: gate.reason };
|
|
639
|
+
if (gate.onApproveUnsandboxed) approvedUnsandboxed.add(event.toolCallId);
|
|
640
|
+
}
|
|
641
|
+
return undefined;
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
// File tools (read/edit/write/ls/grep/find).
|
|
645
|
+
const surface = FILE_TOOL_SURFACE[toolName];
|
|
646
|
+
if (surface && inPath !== undefined) {
|
|
647
|
+
const path = inPath;
|
|
648
|
+
const outside = isOutside(root, path);
|
|
649
|
+
const action = decide(m, surface, path, { isOutside: outside });
|
|
650
|
+
if (action === "deny") {
|
|
651
|
+
// Friendly message for the Plan-mode "Markdown only" case (read-only mode).
|
|
652
|
+
if ((surface === "edit" || surface === "write") && !m.sandbox.writable) {
|
|
653
|
+
if (ctx.hasUI) ctx.ui.notify(`${m.label} allows editing Markdown files only: ${path}`, "warning");
|
|
654
|
+
return { block: true, reason: `${m.label} allows editing Markdown files only` };
|
|
655
|
+
}
|
|
656
|
+
return { block: true, reason: `Path "${path}" is blocked by ${m.label}` };
|
|
657
|
+
}
|
|
658
|
+
if (action === "ask") {
|
|
659
|
+
const title = outside ? `Outside project — allow ${toolName}? (${path})` : `Allow ${toolName}? (${path})`;
|
|
660
|
+
const reason = outside ? "Access outside project blocked" : "blocked";
|
|
661
|
+
if (!(await promptAllow(ctx, surface, path, title))) return { block: true, reason };
|
|
662
|
+
}
|
|
663
|
+
return undefined; // allow
|
|
664
|
+
}
|
|
665
|
+
|
|
666
|
+
// web_search.
|
|
667
|
+
if (toolName === "web_search") {
|
|
668
|
+
const action = decide(m, "web_search", String(input.query ?? "(empty)"));
|
|
669
|
+
if (action === "deny") return { block: true, reason: `web search blocked by ${m.label}` };
|
|
670
|
+
// Session approval is surface-wide ("allow web search this session").
|
|
671
|
+
if (action === "ask" && !(await promptAllow(ctx, "web_search", "*", "Allow web search?"))) {
|
|
672
|
+
return { block: true, reason: "web search blocked" };
|
|
673
|
+
}
|
|
674
|
+
return undefined;
|
|
675
|
+
}
|
|
676
|
+
|
|
677
|
+
// Custom / extension tools (incl. any MCP-as-tool): gate by tool name. An
|
|
678
|
+
// unknown tool prompts first-use (allow once/session/forever/deny).
|
|
679
|
+
if (!BUILTIN_HANDLED.has(toolName)) {
|
|
680
|
+
const action = decide(m, "tool", toolName);
|
|
681
|
+
if (action === "deny") return { block: true, reason: `Tool "${toolName}" blocked by ${m.label}` };
|
|
682
|
+
if (
|
|
683
|
+
action === "ask" &&
|
|
684
|
+
!(await promptAllow(ctx, "tool", toolName, `Tool "${toolName}" — first use in ${m.label}; allow?`, learnForever(ctx, "tool", toolName)))
|
|
685
|
+
) {
|
|
686
|
+
return { block: true, reason: "tool blocked" };
|
|
687
|
+
}
|
|
688
|
+
}
|
|
689
|
+
|
|
690
|
+
return undefined;
|
|
691
|
+
});
|
|
692
|
+
}
|