@rosetears/aili-pi 0.1.5 → 0.1.7

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.
@@ -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
+ }