@andares/pi-toolkits 0.8.0 → 0.9.1

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,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
+ }
@@ -0,0 +1,66 @@
1
+ /**
2
+ * Mode defaults, argument parsing, and formatting.
3
+ */
4
+ import { describe, expect, it } from "vitest";
5
+ import { DEFAULT_MODES, IN_VALUES, OUT_VALUES } from "./constants.js";
6
+ import { formatModes, parsePermissionArgs, shortMode } from "./modes.js";
7
+
8
+ describe("modes", () => {
9
+ it("ships the documented defaults", () => {
10
+ expect(DEFAULT_MODES).toEqual({
11
+ inRead: "allow",
12
+ inWrite: "allow",
13
+ outRead: "allow",
14
+ outWrite: "ask",
15
+ });
16
+ expect(IN_VALUES).toEqual(["allow", "ask"]);
17
+ expect(OUT_VALUES).toEqual(["allow", "ask", "deny"]);
18
+ });
19
+
20
+ it("merges missing arguments from the defaults", () => {
21
+ expect(parsePermissionArgs("ask")).toEqual({
22
+ modes: { inRead: "ask", inWrite: "allow", outRead: "allow", outWrite: "ask" },
23
+ });
24
+ expect(parsePermissionArgs("ask ask")).toEqual({
25
+ modes: { inRead: "ask", inWrite: "ask", outRead: "allow", outWrite: "ask" },
26
+ });
27
+ expect(parsePermissionArgs("ask allow deny deny")).toEqual({
28
+ modes: { inRead: "ask", inWrite: "allow", outRead: "deny", outWrite: "deny" },
29
+ });
30
+ });
31
+
32
+ it("uses the current defaults, not the previous mode, for omitted arguments", () => {
33
+ const parsed = parsePermissionArgs("allow");
34
+ expect("modes" in parsed ? parsed.modes : undefined).toEqual(DEFAULT_MODES);
35
+ });
36
+
37
+ it("rejects deny for in-project modes", () => {
38
+ expect(parsePermissionArgs("deny")).toEqual({
39
+ error: expect.stringContaining("in-read"),
40
+ });
41
+ });
42
+
43
+ it("rejects unknown values and too many arguments", () => {
44
+ expect(parsePermissionArgs("maybe")).toEqual({
45
+ error: expect.stringContaining("in-read"),
46
+ });
47
+ expect(parsePermissionArgs("allow allow allow nope")).toEqual({
48
+ error: expect.stringContaining("out-write"),
49
+ });
50
+ expect(parsePermissionArgs("allow allow allow ask extra")).toEqual({
51
+ error: expect.stringContaining("at most 4"),
52
+ });
53
+ expect(parsePermissionArgs("")).toEqual({
54
+ error: expect.stringContaining("at least one"),
55
+ });
56
+ });
57
+
58
+ it("formats the four modes and their short letters", () => {
59
+ expect(formatModes(DEFAULT_MODES)).toBe("in allow/allow, out allow/ask");
60
+ expect([shortMode("allow"), shortMode("ask"), shortMode("deny")]).toEqual([
61
+ "A",
62
+ "a",
63
+ "D",
64
+ ]);
65
+ });
66
+ });
@@ -0,0 +1,82 @@
1
+ /**
2
+ * permission feature — argument parsing and display formatting.
3
+ *
4
+ * `/ask-perm` with no argument toggles: on → off, off → panel. With arguments
5
+ * it configures and enables in one step (this is the autostart path, and it
6
+ * never opens a dialog):
7
+ *
8
+ * /ask-perm [in-read] [in-write] [out-read] [out-write]
9
+ *
10
+ * Missing arguments take their default value — not the current one — so
11
+ * `/ask-perm allow` always means "defaults, which also happens to be all
12
+ * allow on the inside".
13
+ */
14
+ import {
15
+ DEFAULT_MODES,
16
+ IN_VALUES,
17
+ OUT_VALUES,
18
+ type PermissionModes,
19
+ type TriMode,
20
+ } from "./constants.js";
21
+
22
+ export type ParsedArgs =
23
+ | { readonly modes: PermissionModes }
24
+ | { readonly error: string };
25
+
26
+ function isPair(value: string): boolean {
27
+ return (IN_VALUES as readonly string[]).includes(value);
28
+ }
29
+
30
+ function isTri(value: string): boolean {
31
+ return (OUT_VALUES as readonly string[]).includes(value);
32
+ }
33
+
34
+ /**
35
+ * Parse the command arguments. An empty string yields an error — callers must
36
+ * check for "no arguments" before calling this.
37
+ */
38
+ export function parsePermissionArgs(raw: string): ParsedArgs {
39
+ const words = raw.trim().split(/\s+/).filter(Boolean);
40
+ if (words.length === 0) return { error: "expected at least one mode" };
41
+ if (words.length > 4) {
42
+ return { error: "expected at most 4 modes (in-read in-write out-read out-write)" };
43
+ }
44
+ const [inRead, inWrite, outRead, outWrite] = words;
45
+ if (inRead !== undefined && !isPair(inRead)) {
46
+ return { error: `in-read must be one of ${IN_VALUES.join("|")} (got "${inRead}")` };
47
+ }
48
+ if (inWrite !== undefined && !isPair(inWrite)) {
49
+ return { error: `in-write must be one of ${IN_VALUES.join("|")} (got "${inWrite}")` };
50
+ }
51
+ if (outRead !== undefined && !isTri(outRead)) {
52
+ return { error: `out-read must be one of ${OUT_VALUES.join("|")} (got "${outRead}")` };
53
+ }
54
+ if (outWrite !== undefined && !isTri(outWrite)) {
55
+ return { error: `out-write must be one of ${OUT_VALUES.join("|")} (got "${outWrite}")` };
56
+ }
57
+ return {
58
+ modes: {
59
+ inRead: (inRead ?? DEFAULT_MODES.inRead) as PermissionModes["inRead"],
60
+ inWrite: (inWrite ?? DEFAULT_MODES.inWrite) as PermissionModes["inWrite"],
61
+ outRead: (outRead ?? DEFAULT_MODES.outRead) as TriMode,
62
+ outWrite: (outWrite ?? DEFAULT_MODES.outWrite) as TriMode,
63
+ },
64
+ };
65
+ }
66
+
67
+ /** `allow/allow · allow/ask`, the human-readable summary used in notices. */
68
+ export function formatModes(modes: PermissionModes): string {
69
+ return `in ${modes.inRead}/${modes.inWrite}, out ${modes.outRead}/${modes.outWrite}`;
70
+ }
71
+
72
+ /** Single-letter form for the footer status (`A` allow, `a` ask, `D` deny). */
73
+ export function shortMode(mode: TriMode | "allow"): string {
74
+ switch (mode) {
75
+ case "allow":
76
+ return "A";
77
+ case "ask":
78
+ return "a";
79
+ case "deny":
80
+ return "D";
81
+ }
82
+ }
@@ -0,0 +1,118 @@
1
+ /**
2
+ * permission feature — the mode panel.
3
+ *
4
+ * A `SettingsList` (the same component the built-in settings dialogs use)
5
+ * supplies rendering, ↑↓ navigation, space-cycle and Esc-cancel. A thin
6
+ * wrapper adds the one behaviour this dialog needs on top: **Enter anywhere
7
+ * applies the current values**, so typing `/ask-perm` then Enter enables the
8
+ * defaults without navigating.
9
+ *
10
+ * TUI only: `SettingsList` is a custom component, and rpc/print modes cannot
11
+ * host one. Callers fall back to the argument form there.
12
+ */
13
+ import { getSettingsListTheme } from "@earendil-works/pi-coding-agent";
14
+ import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
15
+ import {
16
+ type Component,
17
+ matchesKey,
18
+ type SettingItem,
19
+ SettingsList,
20
+ } from "@earendil-works/pi-tui";
21
+ import {
22
+ IN_VALUES,
23
+ OUT_VALUES,
24
+ type PermissionModes,
25
+ } from "./constants.js";
26
+
27
+ /** Intercepts Enter globally; everything else belongs to the list. */
28
+ class ApplyOnEnter implements Component {
29
+ constructor(
30
+ private readonly list: SettingsList,
31
+ private readonly snapshot: () => PermissionModes,
32
+ private readonly done: (value: PermissionModes | undefined) => void,
33
+ ) {}
34
+
35
+ handleInput(data: string): void {
36
+ if (matchesKey(data, "enter")) {
37
+ this.done(this.snapshot());
38
+ return;
39
+ }
40
+ this.list.handleInput(data);
41
+ }
42
+
43
+ render(width: number): string[] {
44
+ return this.list.render(width);
45
+ }
46
+
47
+ invalidate(): void {
48
+ this.list.invalidate();
49
+ }
50
+ }
51
+
52
+ /** Build the settings rows for one mode set. */
53
+ function buildItems(modes: PermissionModes): SettingItem[] {
54
+ return [
55
+ {
56
+ id: "apply",
57
+ label: "Enable with these modes",
58
+ description: "Press Enter to enable permission checks",
59
+ currentValue: "enter",
60
+ },
61
+ {
62
+ id: "inRead",
63
+ label: "In-project read",
64
+ description: "Reads inside the working directory and added directories",
65
+ currentValue: modes.inRead,
66
+ values: [...IN_VALUES],
67
+ },
68
+ {
69
+ id: "inWrite",
70
+ label: "In-project write / exec",
71
+ description: "Writes and command execution inside the project",
72
+ currentValue: modes.inWrite,
73
+ values: [...IN_VALUES],
74
+ },
75
+ {
76
+ id: "outRead",
77
+ label: "Outside read",
78
+ description: "Reads outside the project boundary",
79
+ currentValue: modes.outRead,
80
+ values: [...OUT_VALUES],
81
+ },
82
+ {
83
+ id: "outWrite",
84
+ label: "Outside write / exec",
85
+ description: "Writes and execution outside the project boundary",
86
+ currentValue: modes.outWrite,
87
+ values: [...OUT_VALUES],
88
+ },
89
+ ];
90
+ }
91
+
92
+ /**
93
+ * Open the mode panel. Resolves with the chosen modes, or `undefined` when the
94
+ * operator cancels (Esc) or the host cannot render a custom component.
95
+ */
96
+ export function openPermissionPanel(
97
+ ctx: ExtensionContext,
98
+ modes: PermissionModes,
99
+ ): Promise<PermissionModes | undefined> {
100
+ if (!ctx.hasUI || ctx.mode !== "tui") return Promise.resolve(undefined);
101
+
102
+ return ctx.ui.custom<PermissionModes | undefined>(
103
+ (_tui, _theme, _keybindings, done) => {
104
+ let current: PermissionModes = { ...modes };
105
+ const items = buildItems(current);
106
+ const list = new SettingsList(
107
+ items,
108
+ 8,
109
+ getSettingsListTheme(),
110
+ (id, value) => {
111
+ current = { ...current, [id]: value } as PermissionModes;
112
+ },
113
+ () => done(undefined),
114
+ );
115
+ return new ApplyOnEnter(list, () => current, done);
116
+ },
117
+ );
118
+ }