@andares/pi-toolkits 0.8.0 → 0.9.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.
@@ -0,0 +1,340 @@
1
+ /**
2
+ * `/ask-perm` integration over a captured gate and a mocked pi surface.
3
+ *
4
+ * The UI components are not rendered here (that needs a terminal); the
5
+ * dialog contract is exercised through `AskRequest.present` with a host
6
+ * selector, and the rest through the gate's verdicts and the command handler.
7
+ */
8
+ import { mkdtempSync, realpathSync } from "node:fs";
9
+ import { tmpdir } from "node:os";
10
+ import { join } from "node:path";
11
+ import { describe, expect, it } from "vitest";
12
+ import type {
13
+ ExtensionAPI,
14
+ ExtensionContext,
15
+ ToolCallEvent,
16
+ } from "@earendil-works/pi-coding-agent";
17
+ import type { AskDecision, GateHub, ToolGate } from "../../lib/gates.js";
18
+ import { registerPermission } from "./index.js";
19
+
20
+ function bashEvent(command: string): ToolCallEvent {
21
+ return {
22
+ type: "tool_call",
23
+ toolCallId: "call-1",
24
+ toolName: "bash",
25
+ input: { command },
26
+ } as unknown as ToolCallEvent;
27
+ }
28
+
29
+ function pathEvent(toolName: string, path: string): ToolCallEvent {
30
+ return {
31
+ type: "tool_call",
32
+ toolCallId: "call-1",
33
+ toolName,
34
+ input: { path },
35
+ } as unknown as ToolCallEvent;
36
+ }
37
+
38
+ function customEvent(toolName: string): ToolCallEvent {
39
+ return {
40
+ type: "tool_call",
41
+ toolCallId: "call-1",
42
+ toolName,
43
+ input: {},
44
+ } as unknown as ToolCallEvent;
45
+ }
46
+
47
+ interface Harness {
48
+ readonly cwd: string;
49
+ readonly ctx: ExtensionContext;
50
+ readonly gate: ToolGate;
51
+ readonly statuses: Array<[string, string | undefined]>;
52
+ readonly notes: Array<{ message: string; kind?: string }>;
53
+ readonly branch: Array<Record<string, unknown>>;
54
+ evaluate(event: ToolCallEvent): Promise<unknown>;
55
+ command(args: string): Promise<void>;
56
+ sessionStart(): Promise<void>;
57
+ banner(): { systemPrompt: string } | undefined;
58
+ }
59
+
60
+ function makeHarness(): Harness {
61
+ const cwd = realpathSync(mkdtempSync(join(tmpdir(), "pi-perm-test-")));
62
+ const statuses: Harness["statuses"] = [];
63
+ const notes: Harness["notes"] = [];
64
+ const branch: Harness["branch"] = [];
65
+ const ctx = {
66
+ cwd,
67
+ hasUI: true,
68
+ mode: "tui",
69
+ ui: {
70
+ notify: (message: string, kind?: string) => notes.push({ message, kind }),
71
+ setStatus: (key: string, value: string | undefined) => statuses.push([key, value]),
72
+ theme: { fg: (_color: string, text: string) => text },
73
+ custom: async () => undefined,
74
+ select: async () => undefined,
75
+ },
76
+ sessionManager: { getBranch: () => branch },
77
+ } as unknown as ExtensionContext;
78
+
79
+ const captured: ToolGate[] = [];
80
+ const hub = {
81
+ register(gate: ToolGate) {
82
+ captured.push(gate);
83
+ },
84
+ handle: async () => undefined,
85
+ install: () => {},
86
+ } as unknown as GateHub;
87
+
88
+ const handlers = new Map<string, (...args: unknown[]) => unknown>();
89
+ let commandHandler: ((args: string, ctx: ExtensionContext) => unknown) | undefined;
90
+ const pi = {
91
+ on(event: string, handler: (...args: unknown[]) => unknown) {
92
+ handlers.set(event, handler);
93
+ },
94
+ registerCommand(
95
+ name: string,
96
+ options: { handler: (args: string, ctx: ExtensionContext) => unknown },
97
+ ) {
98
+ if (name === "ask-perm") commandHandler = options.handler;
99
+ },
100
+ } as unknown as ExtensionAPI;
101
+
102
+ registerPermission(pi, hub);
103
+ const gate = captured[0];
104
+ if (!gate || !commandHandler) throw new Error("permission did not register");
105
+
106
+ return {
107
+ cwd,
108
+ ctx,
109
+ gate,
110
+ statuses,
111
+ notes,
112
+ branch,
113
+ async evaluate(event) {
114
+ return gate.evaluate(event, ctx);
115
+ },
116
+ async command(args) {
117
+ await commandHandler?.(args, ctx);
118
+ },
119
+ async sessionStart() {
120
+ await handlers.get("session_start")?.( { reason: "new" }, ctx);
121
+ },
122
+ banner() {
123
+ return handlers.get("before_agent_start")?.(
124
+ { systemPrompt: "base", type: "before_agent_start" },
125
+ ctx,
126
+ ) as { systemPrompt: string } | undefined;
127
+ },
128
+ };
129
+ }
130
+
131
+ interface AskVerdict {
132
+ kind: "ask";
133
+ request: {
134
+ details: readonly string[];
135
+ root: string;
136
+ present(ctx: ExtensionContext): Promise<AskDecision | undefined>;
137
+ apply(decision: AskDecision): void;
138
+ };
139
+ }
140
+
141
+ function asAsk(verdict: unknown): AskVerdict {
142
+ expect(verdict).toMatchObject({ kind: "ask" });
143
+ return verdict as AskVerdict;
144
+ }
145
+
146
+ describe("/ask-perm", () => {
147
+ it("is off by default", async () => {
148
+ const h = makeHarness();
149
+ expect(await h.evaluate(bashEvent("rm -rf /tmp/outside"))).toBeUndefined();
150
+ });
151
+
152
+ it("enables from arguments, reports status, and asks for an outside write", async () => {
153
+ const h = makeHarness();
154
+ await h.command("allow allow allow ask");
155
+ expect(h.statuses.at(-1)?.[0]).toBe("perm-mode");
156
+ expect(h.statuses.at(-1)?.[1]).toContain("perm");
157
+ const verdict = asAsk(await h.evaluate(bashEvent("rm -rf /tmp/outside")));
158
+ expect(verdict.request.details.join("\n")).toContain("/tmp/outside");
159
+ expect(verdict.request.root).toBe("/tmp");
160
+ });
161
+
162
+ it("blocks immediately when outside writes are denied", async () => {
163
+ const h = makeHarness();
164
+ await h.command("allow allow allow deny");
165
+ const verdict = (await h.evaluate(bashEvent("rm -rf /tmp/outside"))) as {
166
+ kind: "block";
167
+ reason: string;
168
+ };
169
+ expect(verdict.kind).toBe("block");
170
+ expect(verdict.reason).toContain("Permission denied");
171
+ expect(verdict.reason).toContain("outside-project write");
172
+ });
173
+
174
+ it("records a session grant and applies it to later calls", async () => {
175
+ const h = makeHarness();
176
+ await h.command("allow allow allow ask");
177
+ const first = asAsk(await h.evaluate(bashEvent("rm -rf /tmp/outside/x")));
178
+ first.request.apply("allow-session");
179
+ expect(await h.evaluate(bashEvent("rm -rf /tmp/outside/y"))).toBeUndefined();
180
+ });
181
+
182
+ it("records a session deny and blocks later calls", async () => {
183
+ const h = makeHarness();
184
+ await h.command("allow allow allow ask");
185
+ const first = asAsk(await h.evaluate(bashEvent("rm -rf /tmp/outside/x")));
186
+ first.request.apply("deny-session");
187
+ const later = (await h.evaluate(bashEvent("rm -rf /tmp/outside/y"))) as {
188
+ kind: "block";
189
+ reason: string;
190
+ };
191
+ expect(later.kind).toBe("block");
192
+ expect(later.reason).toContain("denied for this session");
193
+ });
194
+
195
+ it("toggles off with no arguments and clears the status", async () => {
196
+ const h = makeHarness();
197
+ await h.command("allow allow allow ask");
198
+ await h.command("");
199
+ expect(h.statuses.at(-1)?.[1]).toBeUndefined();
200
+ expect(await h.evaluate(bashEvent("rm -rf /tmp/outside"))).toBeUndefined();
201
+ });
202
+
203
+ it("keeps the mode off when the panel is cancelled", async () => {
204
+ const h = makeHarness();
205
+ await h.command("");
206
+ expect(h.notes.at(-1)?.message).toContain("cancelled");
207
+ expect(await h.evaluate(bashEvent("rm -rf /tmp/outside"))).toBeUndefined();
208
+ });
209
+
210
+ it("rejects invalid arguments without enabling", async () => {
211
+ const h = makeHarness();
212
+ await h.command("deny");
213
+ expect(h.notes.at(-1)?.kind).toBe("error");
214
+ expect(await h.evaluate(bashEvent("rm -rf /tmp/outside"))).toBeUndefined();
215
+ });
216
+
217
+ it("resets on session_start", async () => {
218
+ const h = makeHarness();
219
+ await h.command("allow allow allow ask");
220
+ await h.sessionStart();
221
+ expect(await h.evaluate(bashEvent("rm -rf /tmp/outside"))).toBeUndefined();
222
+ expect(h.statuses.at(-1)?.[1]).toBeUndefined();
223
+ });
224
+
225
+ it("treats add-dir directories as inside the project", async () => {
226
+ const h = makeHarness();
227
+ await h.command("allow allow allow ask");
228
+ expect(asAsk(await h.evaluate(bashEvent("rm -rf /tmp/added/x"))).kind).toBe("ask");
229
+
230
+ h.branch.push({
231
+ type: "custom",
232
+ customType: "add-dir:state",
233
+ data: { dirs: [{ absolutePath: "/tmp/added" }] },
234
+ });
235
+ expect(await h.evaluate(bashEvent("rm -rf /tmp/added/x"))).toBeUndefined();
236
+ });
237
+
238
+ it("treats a redirect write to a credential path as a fixed deny, not a write ask", async () => {
239
+ const h = makeHarness();
240
+ await h.command("allow allow allow ask");
241
+ const verdict = (await h.evaluate(
242
+ bashEvent("echo a > ~/.ssh/authorized_keys"),
243
+ )) as { kind: "block"; reason: string };
244
+ expect(verdict.kind).toBe("block");
245
+ expect(verdict.reason).toContain("fixed safety table");
246
+ });
247
+
248
+ it("judges git -C by the -C directory, not the working directory", async () => {
249
+ const h = makeHarness();
250
+ await h.command("allow allow allow ask");
251
+ expect(await h.evaluate(bashEvent("git -C ../outside log"))).toBeUndefined();
252
+ expect(asAsk(await h.evaluate(bashEvent("git -C ../outside push"))).kind).toBe("ask");
253
+ });
254
+
255
+ it("answers path-bearing tools by their own path", async () => {
256
+ const h = makeHarness();
257
+ await h.command("allow allow allow ask");
258
+ expect(await h.evaluate(pathEvent("write", "notes.txt"))).toBeUndefined();
259
+ expect(asAsk(await h.evaluate(pathEvent("write", "/tmp/outside.txt"))).kind).toBe("ask");
260
+ const denied = (await h.evaluate(pathEvent("read", "~/.ssh/id_rsa"))) as {
261
+ kind: "block";
262
+ };
263
+ expect(denied.kind).toBe("block");
264
+ });
265
+
266
+ it("judges a pathless tool by the working directory", async () => {
267
+ const h = makeHarness();
268
+ await h.command("allow allow allow ask");
269
+ expect(await h.evaluate(customEvent("mcp"))).toBeUndefined();
270
+ });
271
+
272
+ it("clears session grants when the mode is reconfigured", async () => {
273
+ const h = makeHarness();
274
+ await h.command("allow allow allow ask");
275
+ asAsk(await h.evaluate(bashEvent("rm -rf /tmp/outside/x"))).request.apply(
276
+ "allow-session",
277
+ );
278
+ expect(await h.evaluate(bashEvent("rm -rf /tmp/outside/y"))).toBeUndefined();
279
+
280
+ await h.command("allow allow allow ask");
281
+ expect(asAsk(await h.evaluate(bashEvent("rm -rf /tmp/outside/y"))).kind).toBe("ask");
282
+ });
283
+ });
284
+
285
+ describe("dialog contract", () => {
286
+ it("fails closed without a UI", async () => {
287
+ const h = makeHarness();
288
+ await h.command("allow allow allow ask");
289
+ const verdict = asAsk(await h.evaluate(bashEvent("rm -rf /tmp/outside")));
290
+ const noUi = { ...h.ctx, hasUI: false } as ExtensionContext;
291
+ await expect(verdict.request.present(noUi)).resolves.toBeUndefined();
292
+ });
293
+
294
+ it("falls back to the host selector in non-TUI modes", async () => {
295
+ const h = makeHarness();
296
+ await h.command("allow allow allow ask");
297
+ const verdict = asAsk(await h.evaluate(bashEvent("rm -rf /tmp/outside")));
298
+ const rpc = {
299
+ cwd: h.cwd,
300
+ hasUI: true,
301
+ mode: "rpc",
302
+ ui: {
303
+ select: async () => "Allow once (a)",
304
+ theme: { fg: (_color: string, text: string) => text },
305
+ },
306
+ } as unknown as ExtensionContext;
307
+ await expect(verdict.request.present(rpc)).resolves.toBe("allow");
308
+ });
309
+ });
310
+
311
+ describe("model-facing banner", () => {
312
+ it("appends the policy only while enabled", async () => {
313
+ const h = makeHarness();
314
+ expect(h.banner()).toBeUndefined();
315
+ await h.command("allow allow allow ask");
316
+ expect(h.banner()?.systemPrompt).toContain("Permission mode");
317
+ expect(h.banner()?.systemPrompt).toContain("in allow/allow, out allow/ask");
318
+ });
319
+ });
320
+
321
+ describe("session grants cover repeat asks", () => {
322
+ it("stops asking for a destructive in-project command once allowed", async () => {
323
+ const h = makeHarness();
324
+ await h.command("allow allow allow ask");
325
+ const first = asAsk(await h.evaluate(bashEvent("rm -rf .")));
326
+ expect(first.request.details.join("\n")).toContain("recursive delete");
327
+ first.request.apply("allow-session");
328
+ expect(await h.evaluate(bashEvent("rm -rf ."))).toBeUndefined();
329
+ });
330
+
331
+ it("stops asking for the same opaque command once allowed", async () => {
332
+ const h = makeHarness();
333
+ await h.command("allow allow allow ask");
334
+ const command = 'cd "$DIR" && rm x';
335
+ const first = asAsk(await h.evaluate(bashEvent(command)));
336
+ expect(first.request.details.join("\n")).toContain("this exact command");
337
+ first.request.apply("allow-session");
338
+ expect(await h.evaluate(bashEvent(command))).toBeUndefined();
339
+ });
340
+ });
@@ -0,0 +1,333 @@
1
+ /**
2
+ * permission feature — `/ask-perm`: session-scoped boundary permission.
3
+ *
4
+ * Design notes:
5
+ * - Off by default and never persisted. The mode, the modes, and every session
6
+ * grant live in this closure; `session_start` resets them and
7
+ * `session_shutdown` clears them. A forgotten toggle cannot follow the
8
+ * operator into the next session.
9
+ * - Toggling: no arguments flips on→off, off→panel. Arguments configure and
10
+ * enable in one step (`/ask-perm allow allow allow ask`), which is also the
11
+ * autostart path and never opens a dialog.
12
+ * - Enforcement is a gate on the shared bus, so it composes with `/ask`
13
+ * instead of replacing it: ask answers "does this mutate?", permission
14
+ * answers "does this cross the project boundary?", and either can block.
15
+ * - The boundary is the working directory plus directories added through
16
+ * pi-add-dir (read from its session entry).
17
+ */
18
+ import { isAbsolute, join } from "node:path";
19
+ import type {
20
+ ExtensionAPI,
21
+ ExtensionContext,
22
+ ToolCallEvent,
23
+ } from "@earendil-works/pi-coding-agent";
24
+ import type { AskRequest, GateHub } from "../../lib/gates.js";
25
+ import {
26
+ DEFAULT_MODES,
27
+ type PermissionModes,
28
+ PERMISSION_STATUS_KEY,
29
+ type Effect,
30
+ } from "./constants.js";
31
+ import {
32
+ formatModes,
33
+ parsePermissionArgs,
34
+ shortMode,
35
+ } from "./modes.js";
36
+ import { openPermissionPanel } from "./panel.js";
37
+ import {
38
+ canonicalize,
39
+ expandHome,
40
+ readAddedDirectories,
41
+ sessionRoots,
42
+ } from "./paths.js";
43
+ import {
44
+ decideProgram,
45
+ decisionEffect,
46
+ decisionGrantRoot,
47
+ formatDenial,
48
+ type PolicyDecision,
49
+ programKey,
50
+ } from "./policy.js";
51
+ import { askPermission } from "./prompt.js";
52
+ import { SessionRules } from "./session-rules.js";
53
+ import { parseCommandUnits, type CommandUnit } from "./units.js";
54
+
55
+ /** Tools that read one path. */
56
+ const READ_PATH_TOOLS = new Set(["read", "grep", "find", "ls"]);
57
+ /** Tools that write one path. */
58
+ const WRITE_PATH_TOOLS = new Set(["write", "edit"]);
59
+
60
+ function inputPath(input: unknown): string | undefined {
61
+ if (typeof input !== "object" || input === null) return undefined;
62
+ const value = (input as { path?: unknown }).path;
63
+ return typeof value === "string" ? value : undefined;
64
+ }
65
+
66
+ /** One synthetic unit for a non-bash tool call. */
67
+ function toolUnit(
68
+ command: string,
69
+ effect: Effect,
70
+ pathValue: string | undefined,
71
+ cwd: string,
72
+ ): CommandUnit {
73
+ const base = canonicalize(cwd);
74
+ return {
75
+ raw: command,
76
+ headWord: command,
77
+ command,
78
+ args: [],
79
+ effect,
80
+ opaque: WRITE_PATH_TOOLS.has(command) && pathValue === undefined,
81
+ base,
82
+ targets:
83
+ pathValue === undefined
84
+ ? []
85
+ : [
86
+ {
87
+ path: isAbsolute(expandHome(pathValue))
88
+ ? expandHome(pathValue)
89
+ : join(base, pathValue),
90
+ effect,
91
+ source: "operand",
92
+ },
93
+ ],
94
+ note: pathValue === undefined ? "the tool call names no path" : undefined,
95
+ };
96
+ }
97
+
98
+ export function registerPermission(pi: ExtensionAPI, gates: GateHub): void {
99
+ let enabled = false;
100
+ let modes: PermissionModes = { ...DEFAULT_MODES };
101
+ const rules = new SessionRules();
102
+ let rootsCache:
103
+ | { cwd: string; branchLength: number; roots: string[] }
104
+ | undefined;
105
+
106
+ function reset(): void {
107
+ enabled = false;
108
+ modes = { ...DEFAULT_MODES };
109
+ rules.clear();
110
+ rootsCache = undefined;
111
+ }
112
+
113
+ function syncStatus(ctx: ExtensionContext): void {
114
+ if (!enabled) {
115
+ ctx.ui.setStatus(PERMISSION_STATUS_KEY, undefined);
116
+ return;
117
+ }
118
+ const color = (mode: string, label: string): string => {
119
+ if (mode === "allow") return ctx.ui.theme.fg("success", label);
120
+ if (mode === "ask") return ctx.ui.theme.fg("warning", label);
121
+ return ctx.ui.theme.fg("error", label);
122
+ };
123
+ const part = (inProject: string, outside: string): string =>
124
+ `${color(inProject, shortMode(inProject as "allow"))}/${color(outside, shortMode(outside as "allow"))}`;
125
+ ctx.ui.setStatus(
126
+ PERMISSION_STATUS_KEY,
127
+ `${ctx.ui.theme.fg("muted", "perm")} i:${part(modes.inRead, modes.inWrite)} o:${part(modes.outRead, modes.outWrite)}`,
128
+ );
129
+ }
130
+
131
+ /** Session roots, refreshed only when the branch grew. */
132
+ function rootsFor(ctx: ExtensionContext): string[] {
133
+ try {
134
+ const branch = ctx.sessionManager.getBranch();
135
+ if (
136
+ !rootsCache ||
137
+ rootsCache.cwd !== ctx.cwd ||
138
+ rootsCache.branchLength !== branch.length
139
+ ) {
140
+ rootsCache = {
141
+ cwd: ctx.cwd,
142
+ branchLength: branch.length,
143
+ roots: sessionRoots(ctx.cwd, readAddedDirectories(branch)),
144
+ };
145
+ }
146
+ return rootsCache.roots;
147
+ } catch {
148
+ // No session manager (unexpected context): fail closed on the cwd only.
149
+ return [canonicalize(ctx.cwd)];
150
+ }
151
+ }
152
+
153
+ function unitsFor(event: ToolCallEvent, cwd: string): CommandUnit[] {
154
+ if (event.toolName === "bash") {
155
+ const command = (event.input as { command?: unknown }).command;
156
+ return parseCommandUnits(typeof command === "string" ? command : "", cwd);
157
+ }
158
+ if (event.toolName === "powershell") {
159
+ // PowerShell is not decomposed: a script's writes and exec targets stay
160
+ // invisible, so the call is asked about instead of judged by its cwd.
161
+ return [
162
+ {
163
+ raw: "powershell",
164
+ headWord: "powershell",
165
+ command: "powershell",
166
+ args: [],
167
+ effect: "write",
168
+ opaque: true,
169
+ base: canonicalize(cwd),
170
+ targets: [],
171
+ note: "PowerShell commands are not analysed",
172
+ },
173
+ ];
174
+ }
175
+ if (READ_PATH_TOOLS.has(event.toolName)) {
176
+ return [toolUnit(event.toolName, "read", inputPath(event.input), cwd)];
177
+ }
178
+ if (WRITE_PATH_TOOLS.has(event.toolName)) {
179
+ return [toolUnit(event.toolName, "write", inputPath(event.input), cwd)];
180
+ }
181
+ // Every other tool (MCP, subagent, …) has no path to judge: it is
182
+ // treated as an exec and answered by the working directory's scope.
183
+ return [toolUnit(event.toolName, "write", undefined, cwd)];
184
+ }
185
+
186
+ function askRequest(
187
+ decision: PolicyDecision,
188
+ fallbackCwd: string,
189
+ units: readonly CommandUnit[],
190
+ ): AskRequest {
191
+ const root = decisionGrantRoot(decision, () => fallbackCwd);
192
+ const effect = decisionEffect(decision);
193
+ const scope =
194
+ decision.target?.scope === "out" ? "outside project" : "inside project";
195
+ const details = [
196
+ `Command: ${decision.unit.raw || decision.unit.command}`,
197
+ ...(decision.target
198
+ ? [`Target: ${decision.target.path} (${scope}, ${effect})`]
199
+ : []),
200
+ ...(decision.reason ? [`Reason: ${decision.reason}`] : []),
201
+ decision.unit.opaque
202
+ ? "Session grant would cover: this exact command"
203
+ : `Session grant would cover: ${root}`,
204
+ ];
205
+ return {
206
+ title: "Permission required",
207
+ details,
208
+ root,
209
+ present: (ctx) =>
210
+ askPermission(ctx, { title: "Permission required", details }),
211
+ apply: (choice) => {
212
+ if (choice === "allow-session") {
213
+ if (decision.unit.opaque) rules.allowCommand(programKey(units));
214
+ else rules.allow({ effect, root });
215
+ }
216
+ if (choice === "deny-session") {
217
+ if (decision.unit.opaque) rules.denyCommand(programKey(units));
218
+ else rules.deny({ effect, root });
219
+ }
220
+ },
221
+ denyReason: (choice) =>
222
+ formatDenial({
223
+ ...decision,
224
+ reason:
225
+ choice === "deny-session"
226
+ ? `${decision.reason ?? "operation"} — denied for this session`
227
+ : (decision.reason ?? "denied by the operator"),
228
+ }),
229
+ };
230
+ }
231
+
232
+ // ── Lifecycle ──────────────────────────────────────────────────────────
233
+
234
+ pi.on("session_start", (_event, ctx) => {
235
+ reset();
236
+ syncStatus(ctx);
237
+ });
238
+ pi.on("session_shutdown", () => {
239
+ reset();
240
+ });
241
+
242
+ // ── Command ────────────────────────────────────────────────────────────
243
+
244
+ pi.registerCommand("ask-perm", {
245
+ description:
246
+ "Toggle permission checks; with arguments: /ask-perm [in-read] [in-write] [out-read] [out-write]",
247
+ handler: async (args, ctx) => {
248
+ const raw = args.trim();
249
+
250
+ if (raw !== "") {
251
+ const parsed = parsePermissionArgs(raw);
252
+ if ("error" in parsed) {
253
+ ctx.ui.notify(`ask-perm: ${parsed.error}`, "error");
254
+ return;
255
+ }
256
+ modes = parsed.modes;
257
+ // A new policy invalidates grants made under the old one.
258
+ rules.clear();
259
+ enabled = true;
260
+ syncStatus(ctx);
261
+ ctx.ui.notify(
262
+ `Permission ON — ${formatModes(modes)}. /ask-perm to turn off.`,
263
+ "info",
264
+ );
265
+ return;
266
+ }
267
+
268
+ if (enabled) {
269
+ enabled = false;
270
+ rules.clear();
271
+ syncStatus(ctx);
272
+ ctx.ui.notify(
273
+ "Permission OFF — no boundary checks in this session.",
274
+ "info",
275
+ );
276
+ return;
277
+ }
278
+
279
+ const chosen = await openPermissionPanel(ctx, modes);
280
+ if (!chosen) {
281
+ ctx.ui.notify(
282
+ "ask-perm: cancelled. Use /ask-perm allow allow allow ask to enable from the argument form.",
283
+ "info",
284
+ );
285
+ return;
286
+ }
287
+ modes = chosen;
288
+ enabled = true;
289
+ syncStatus(ctx);
290
+ ctx.ui.notify(
291
+ `Permission ON — ${formatModes(modes)}. /ask-perm to turn off.`,
292
+ "info",
293
+ );
294
+ },
295
+ });
296
+
297
+ // ── Gate ───────────────────────────────────────────────────────────────
298
+
299
+ gates.register({
300
+ name: "permission",
301
+ order: 20,
302
+ evaluate(event, ctx) {
303
+ if (!enabled) return undefined;
304
+ const units = unitsFor(event, ctx.cwd);
305
+ if (units.length === 0) return undefined;
306
+ const decision = decideProgram(units, {
307
+ modes,
308
+ roots: rootsFor(ctx),
309
+ rules,
310
+ });
311
+ if (decision.action === "allow") return undefined;
312
+ if (decision.action === "deny") {
313
+ return { kind: "block", reason: formatDenial(decision) };
314
+ }
315
+ return { kind: "ask", request: askRequest(decision, ctx.cwd, units) };
316
+ },
317
+ });
318
+
319
+ // ── Model-facing banner ────────────────────────────────────────────────
320
+
321
+ pi.on("before_agent_start", (event) => {
322
+ if (!enabled) return;
323
+ const banner = [
324
+ "",
325
+ "## Permission mode",
326
+ `Boundary checks are ON: ${formatModes(modes)}.`,
327
+ "Operations outside the project, destructive commands, and credential paths may require operator confirmation.",
328
+ "A denial is a policy decision — do not retry the same operation through an equivalent command.",
329
+ "",
330
+ ].join("\n");
331
+ return { systemPrompt: `${event.systemPrompt}${banner}` };
332
+ });
333
+ }