@andares/pi-toolkits 0.7.3 → 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.
Files changed (35) hide show
  1. package/README.md +78 -6
  2. package/README.zh-CN.md +77 -6
  3. package/package.json +13 -7
  4. package/src/features/ask/index.test.ts +118 -0
  5. package/src/features/ask/index.ts +31 -13
  6. package/src/features/ask/reasons.ts +2 -2
  7. package/src/features/autostart/autostart.test.ts +45 -2
  8. package/src/features/autostart/autostart.ts +4 -2
  9. package/src/features/autostart/index.ts +100 -48
  10. package/src/features/compact/index.ts +3 -3
  11. package/src/features/compact/prompt.ts +2 -2
  12. package/src/features/favorites/constants.ts +3 -3
  13. package/src/features/favorites/patch-cycle.ts +6 -6
  14. package/src/features/favorites/patch-selector.ts +4 -4
  15. package/src/features/permission/constants.test.ts +60 -0
  16. package/src/features/permission/constants.ts +158 -0
  17. package/src/features/permission/index.test.ts +340 -0
  18. package/src/features/permission/index.ts +333 -0
  19. package/src/features/permission/modes.test.ts +66 -0
  20. package/src/features/permission/modes.ts +82 -0
  21. package/src/features/permission/panel.ts +118 -0
  22. package/src/features/permission/paths.test.ts +111 -0
  23. package/src/features/permission/paths.ts +126 -0
  24. package/src/features/permission/policy.test.ts +237 -0
  25. package/src/features/permission/policy.ts +338 -0
  26. package/src/features/permission/prompt.ts +146 -0
  27. package/src/features/permission/session-rules.ts +99 -0
  28. package/src/features/permission/units.test.ts +205 -0
  29. package/src/features/permission/units.ts +995 -0
  30. package/src/features/stash/stash.ts +1 -1
  31. package/src/index.ts +15 -2
  32. package/src/lib/gates.test.ts +171 -0
  33. package/src/lib/gates.ts +153 -0
  34. package/src/lib/model-config.ts +2 -2
  35. package/src/lib/model-picker.ts +2 -2
@@ -0,0 +1,158 @@
1
+ /**
2
+ * permission feature — modes, labels, and the fixed safety tables.
3
+ *
4
+ * The four modes are deliberately small: project boundary (in/out) crossed
5
+ * with operation kind (read/write, exec folded into write). There is no rule
6
+ * matrix — session grants cover the exceptions.
7
+ *
8
+ * Two tables sit outside the four modes on purpose:
9
+ * - {@link SECRET_ROOTS} / {@link isSecretFile}: reads and writes of
10
+ * credential material are denied in every mode. Without this, the default
11
+ * `outside read = allow` would hand the agent `~/.ssh/id_rsa`.
12
+ * - {@link DANGER_REASON}: destructive commands are always asked about, even
13
+ * when their scope says allow (`rm -rf .` inside the project is still a
14
+ * guarded operation). Scope alone cannot express "destructive".
15
+ */
16
+ import { homedir } from "node:os";
17
+ import { basename, join, sep } from "node:path";
18
+
19
+ /** Three-way mode, used for outside-project operations. */
20
+ export type TriMode = "ask" | "allow" | "deny";
21
+
22
+ /** Two-way mode, used for inside-project operations. */
23
+ export type PairMode = "ask" | "allow";
24
+
25
+ /** What one tool call does to the filesystem. */
26
+ export type Effect = "read" | "write";
27
+
28
+ /** The four permission modes, all at once. */
29
+ export interface PermissionModes {
30
+ inRead: PairMode;
31
+ inWrite: PairMode;
32
+ outRead: TriMode;
33
+ outWrite: TriMode;
34
+ }
35
+
36
+ /**
37
+ * Defaults: project-internal work is uninterrupted; outside reads are allowed
38
+ * (the secrets table is what protects credentials); outside writes ask.
39
+ */
40
+ export const DEFAULT_MODES: PermissionModes = {
41
+ inRead: "allow",
42
+ inWrite: "allow",
43
+ outRead: "allow",
44
+ outWrite: "ask",
45
+ };
46
+
47
+ /** Footer status key (extension-scoped; `/ask` uses its own). */
48
+ export const PERMISSION_STATUS_KEY = "perm-mode";
49
+
50
+ /** Values offered by the mode panel, in cycle order. */
51
+ export const IN_VALUES: readonly PairMode[] = ["allow", "ask"];
52
+ export const OUT_VALUES: readonly TriMode[] = ["allow", "ask", "deny"];
53
+
54
+ /**
55
+ * Credential locations denied in every mode. `~` is expanded at match time so
56
+ * the table survives a moved home directory.
57
+ */
58
+ export const SECRET_ROOTS: readonly string[] = [
59
+ "~/.ssh",
60
+ "~/.gnupg",
61
+ "~/.aws",
62
+ "~/.pi/agent/auth.json",
63
+ ];
64
+
65
+ /** `.env` family: secrets by convention, except the sample templates. */
66
+ const ENV_TEMPLATES = new Set([".env.example", ".env.sample", ".env.template"]);
67
+
68
+ /** pi's own credential files other than the exact `auth.json` entry. */
69
+ function isPiAuthFile(pathValue: string): boolean {
70
+ const authPrefix = join(homedir(), ".pi", "agent", "auth");
71
+ return pathValue.startsWith(authPrefix) && basename(pathValue).endsWith(".json");
72
+ }
73
+
74
+ /** Containment: `pathValue` is `root` itself or under it (with sep boundaries). */
75
+ function pathWithin(root: string, pathValue: string): boolean {
76
+ const a = process.platform === "win32" ? root.toLowerCase() : root;
77
+ const b = process.platform === "win32" ? pathValue.toLowerCase() : pathValue;
78
+ return b === a || b.startsWith(a.endsWith(sep) ? a : `${a}${sep}`);
79
+ }
80
+
81
+ /** `true` when `pathValue` is `.env`-shaped and not a template. */
82
+ function isSecretEnvFile(pathValue: string): boolean {
83
+ const name = basename(pathValue);
84
+ if (ENV_TEMPLATES.has(name)) return false;
85
+ return name === ".env" || name.startsWith(".env.");
86
+ }
87
+
88
+ /** Absolute secret prefixes, `~` expanded. */
89
+ export function secretRoots(): string[] {
90
+ return SECRET_ROOTS.map((root) =>
91
+ root.startsWith("~") ? join(homedir(), root.slice(1)) : root,
92
+ );
93
+ }
94
+
95
+ /** Why `pathValue` is secret, or `undefined` when it is not. */
96
+ export function secretReason(pathValue: string): string | undefined {
97
+ if (isPiAuthFile(pathValue)) return "pi credentials";
98
+ if (isSecretEnvFile(pathValue)) return "environment file";
99
+ for (const root of secretRoots()) {
100
+ if (pathWithin(root, pathValue)) return "credentials";
101
+ }
102
+ return undefined;
103
+ }
104
+
105
+ /**
106
+ * Commands always confirmed regardless of mode. Returns the human-facing
107
+ * reason, or `undefined` for an ordinary command.
108
+ *
109
+ * `targets` are the unit's already-resolved absolute paths.
110
+ */
111
+ export function dangerReason(
112
+ command: string,
113
+ args: readonly string[],
114
+ targets: readonly string[],
115
+ base: string | null,
116
+ ): string | undefined {
117
+ const home = homedir();
118
+ const inDangerousRoot = (pathValue: string): boolean =>
119
+ pathValue === "/" ||
120
+ pathValue === home ||
121
+ (base !== null && pathValue === base);
122
+
123
+ if (command === "sudo" || command === "doas") return "privileged command";
124
+ if (command === "rm" && targets.some(inDangerousRoot)) {
125
+ return "recursive delete of /, the home directory, or the working directory";
126
+ }
127
+ if (command === "git") {
128
+ const [verb, ...rest] = args;
129
+ if (verb === "reset" && rest.includes("--hard")) return "git reset --hard";
130
+ if (verb === "clean" && rest.some((a) => /^-[a-zA-Z]*f/.test(a))) {
131
+ return "git clean -f";
132
+ }
133
+ if (verb === "push" && rest.some((a) => a === "-f" || a === "--force")) {
134
+ return "forced git push";
135
+ }
136
+ }
137
+ if (
138
+ ["npm", "pnpm", "yarn", "bun"].includes(command) &&
139
+ args[0] === "publish"
140
+ ) {
141
+ return "package publish";
142
+ }
143
+ if (command === "dd" && targets.some((t) => t.startsWith("/dev/"))) {
144
+ return "raw device write";
145
+ }
146
+ if (command.startsWith("mkfs")) return "filesystem creation";
147
+ if (["shutdown", "reboot", "poweroff", "halt"].includes(command)) {
148
+ return "system power command";
149
+ }
150
+ if (
151
+ command === "chmod" &&
152
+ args.some((a) => /^-[a-zA-Z]*R/.test(a)) &&
153
+ args.includes("777")
154
+ ) {
155
+ return "recursive world-writable chmod";
156
+ }
157
+ return undefined;
158
+ }
@@ -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
+ });