@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.
@@ -1,81 +1,120 @@
1
1
  /**
2
- * autostart feature — run project-declared slash commands at session start.
2
+ * autostart feature — run declared slash commands at session start.
3
3
  *
4
- * Config lives at `<project root>/.pi/autostart.toml` (see autostart.ts for
5
- * the schema). Execution gates, in order:
4
+ * Two sources, executed in order, never merged:
6
5
  *
7
- * 1. reason filter — only startup/new/fork replay (see RUNNABLE_REASONS)
8
- * 2. config file exists — a missing file is a silent opt-in skip
9
- * 3. `ctx.isProjectTrusted()` — an untrusted project never auto-executes
10
- * 4. per-command pre-validation against `pi.getCommands()`: only commands
11
- * registered by an *extension* are dispatched. This is mandatory:
12
- * sendUserMessage with an unhandled "/x" falls through prompt() to the
13
- * model as a plain user message (pi agent-session.js), so an unknown
14
- * command must be skipped here, never sent.
6
+ * 1. `<agentDir>/autostart.toml` — the operator's own defaults. Runs in every
7
+ * project; no trust gate, because the file lives in pi's config directory
8
+ * and is not controlled by the project.
9
+ * 2. `<project root>/.pi/autostart.toml` — project-declared commands. Gated on
10
+ * `ctx.isProjectTrusted()`: an untrusted repository must not execute
11
+ * anything.
12
+ *
13
+ * Execution gates, in order:
14
+ *
15
+ * - reason filter — only startup/new/fork replay (see RUNNABLE_REASONS)
16
+ * - config file exists — a missing file is a silent opt-in skip
17
+ * - project trust (project source only)
18
+ * - per-command pre-validation against `pi.getCommands()`: only commands
19
+ * registered by an *extension* are dispatched. This is mandatory:
20
+ * sendUserMessage with an unhandled "/x" falls through prompt() to the
21
+ * model as a plain user message (pi agent-session.js), so an unknown
22
+ * command must be skipped here, never sent.
15
23
  *
16
24
  * Dispatch uses `sendUserMessage(cmd, { expandPromptTemplates: true })` —
17
25
  * that flag is what routes "/cmd args" through _tryExecuteExtensionCommand
18
- * instead of the model prompt path. The wrapper defaults it to false
19
- * (each byte-identical across every published version of the supported
20
- * window, 1.0.0 → 1.3.0; see AGENTS.md host-compat
21
- * discipline for the version matrix).
26
+ * instead of the model prompt path. The wrapper defaults it to false in this
27
+ * package (each byte-identical across every published version of the supported
28
+ * window, 1.0.0 → 1.3.0; see AGENTS.md host-compat discipline for the version
29
+ * matrix).
22
30
  *
23
31
  * Only extension commands can run this way. Built-in commands (/compact,
24
32
  * /reload, /resume, …) are dispatched at the TUI layer and cannot be
25
33
  * triggered from the extension layer; skill commands (/skill:name) expand
26
- * into prompts rather than execute. First-party use case: `/add-dir`
27
- * (pi-add-dir), which is idempotent ("Already added" check).
34
+ * into prompts rather than execute. First-party use cases: `/add-dir`
35
+ * (pi-add-dir, idempotent) and `/ask-perm` (session-scoped permission).
28
36
  */
29
37
  import { readFile } from "node:fs/promises";
30
38
  import { join } from "node:path";
31
- import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
39
+ import { getAgentDir } from "@earendil-works/pi-coding-agent";
40
+ import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
32
41
  import {
33
42
  AUTOSTART_CONFIG_PATH,
34
43
  AutostartParseError,
35
44
  commandNameOf,
45
+ GLOBAL_AUTOSTART_FILENAME,
36
46
  parseAutostartConfig,
37
47
  RUNNABLE_REASONS,
38
48
  } from "./autostart.js";
39
49
 
40
- export function registerAutostart(pi: ExtensionAPI): void {
41
- pi.on("session_start", async (event, ctx) => {
42
- if (!RUNNABLE_REASONS.has(event.reason)) return;
50
+ export interface AutostartOptions {
51
+ /** pi's config directory; defaults to `getAgentDir()`. */
52
+ readonly agentDir?: string;
53
+ }
54
+
55
+ interface Source {
56
+ readonly path: string;
57
+ readonly label: string;
58
+ /** Project sources require trust; the operator's global file does not. */
59
+ readonly requiresTrust: boolean;
60
+ }
61
+
62
+ function messageOf(error: unknown): string {
63
+ return error instanceof Error ? error.message : String(error);
64
+ }
65
+
66
+ export function registerAutostart(
67
+ pi: ExtensionAPI,
68
+ options: AutostartOptions = {},
69
+ ): void {
70
+ const agentDir = options.agentDir ?? getAgentDir();
43
71
 
72
+ async function readCommands(
73
+ ctx: ExtensionContext,
74
+ source: Source,
75
+ ): Promise<string[] | undefined> {
44
76
  let text: string;
45
77
  try {
46
- text = await readFile(join(ctx.cwd, AUTOSTART_CONFIG_PATH), "utf8");
78
+ text = await readFile(source.path, "utf8");
47
79
  } catch (err) {
48
80
  // Missing file = opt-in skip. Any other read failure (EACCES, EISDIR,
49
81
  // …) is a misconfiguration the operator should hear about.
50
82
  if ((err as NodeJS.ErrnoException).code !== "ENOENT") {
51
83
  ctx.ui.notify(
52
- `autostart: skipped — cannot read ${AUTOSTART_CONFIG_PATH}: ${err instanceof Error ? err.message : String(err)}`,
84
+ `autostart: skipped ${source.label} — cannot read ${source.path}: ${messageOf(err)}`,
53
85
  "warning",
54
86
  );
55
87
  }
56
- return;
88
+ return undefined;
57
89
  }
58
90
 
59
- let commands: string[];
60
91
  try {
61
- commands = parseAutostartConfig(text);
92
+ return parseAutostartConfig(text);
62
93
  } catch (err) {
63
94
  const message = err instanceof AutostartParseError ? err.message : String(err);
64
95
  ctx.ui.notify(
65
- `autostart: skipped — ${message} (${AUTOSTART_CONFIG_PATH})`,
96
+ `autostart: skipped ${source.label} — ${message} (${source.path})`,
66
97
  "error",
67
98
  );
68
- return;
99
+ return undefined;
69
100
  }
70
- if (commands.length === 0) return;
101
+ }
71
102
 
72
- if (!ctx.isProjectTrusted()) {
73
- ctx.ui.notify(
74
- `autostart: skipped — project not trusted (${AUTOSTART_CONFIG_PATH})`,
75
- "warning",
76
- );
77
- return;
78
- }
103
+ pi.on("session_start", async (event, ctx) => {
104
+ if (!RUNNABLE_REASONS.has(event.reason)) return;
105
+
106
+ const sources: Source[] = [
107
+ {
108
+ path: join(agentDir, GLOBAL_AUTOSTART_FILENAME),
109
+ label: "global config",
110
+ requiresTrust: false,
111
+ },
112
+ {
113
+ path: join(ctx.cwd, AUTOSTART_CONFIG_PATH),
114
+ label: "project config",
115
+ requiresTrust: true,
116
+ },
117
+ ];
79
118
 
80
119
  // Only extension-registered commands are dispatchable; skill and
81
120
  // prompt-template entries expand rather than execute.
@@ -87,23 +126,36 @@ export function registerAutostart(pi: ExtensionAPI): void {
87
126
  ),
88
127
  );
89
128
 
90
- for (const command of commands) {
91
- const name = commandNameOf(command);
92
- if (!name || !dispatchable.has(name)) {
129
+ for (const source of sources) {
130
+ const commands = await readCommands(ctx, source);
131
+ if (commands === undefined || commands.length === 0) continue;
132
+
133
+ if (source.requiresTrust && !ctx.isProjectTrusted()) {
93
134
  ctx.ui.notify(
94
- `autostart: skipped "${command}" — not a registered extension command`,
135
+ `autostart: skipped ${source.label} — project not trusted (${source.path})`,
95
136
  "warning",
96
137
  );
97
138
  continue;
98
139
  }
99
- // One failing command must not cancel the rest of the list.
100
- try {
101
- await pi.sendUserMessage(command, { expandPromptTemplates: true });
102
- } catch (err) {
103
- ctx.ui.notify(
104
- `autostart: "${command}" failed — ${err instanceof Error ? err.message : String(err)}`,
105
- "error",
106
- );
140
+
141
+ for (const command of commands) {
142
+ const name = commandNameOf(command);
143
+ if (!name || !dispatchable.has(name)) {
144
+ ctx.ui.notify(
145
+ `autostart: skipped "${command}" — not a registered extension command`,
146
+ "warning",
147
+ );
148
+ continue;
149
+ }
150
+ // One failing command must not cancel the rest of the list.
151
+ try {
152
+ await pi.sendUserMessage(command, { expandPromptTemplates: true });
153
+ } catch (err) {
154
+ ctx.ui.notify(
155
+ `autostart: "${command}" failed — ${messageOf(err)}`,
156
+ "error",
157
+ );
158
+ }
107
159
  }
108
160
  }
109
161
  });
@@ -0,0 +1,60 @@
1
+ /**
2
+ * Fixed safety tables: credential paths are denied in every mode, destructive
3
+ * commands always ask.
4
+ */
5
+ import { homedir } from "node:os";
6
+ import { join } from "node:path";
7
+ import { describe, expect, it } from "vitest";
8
+ import { dangerReason, secretReason } from "./constants.js";
9
+
10
+ const HOME = homedir();
11
+
12
+ describe("secrets table", () => {
13
+ it("covers the credential directories exactly", () => {
14
+ expect(secretReason(join(HOME, ".ssh", "id_rsa"))).toBe("credentials");
15
+ expect(secretReason(join(HOME, ".ssh"))).toBe("credentials");
16
+ expect(secretReason(join(HOME, ".gnupg", "private-keys-v1.d"))).toBe("credentials");
17
+ expect(secretReason(join(HOME, ".aws", "credentials"))).toBe("credentials");
18
+ });
19
+
20
+ it("does not over-match a sibling prefix", () => {
21
+ expect(secretReason(join(HOME, ".ssh-easy", "x"))).toBeUndefined();
22
+ expect(secretReason(join(HOME, ".ssh_evil", "x"))).toBeUndefined();
23
+ expect(secretReason(join(HOME, ".awsconfig", "x"))).toBeUndefined();
24
+ });
25
+
26
+ it("covers pi auth files but nothing else under the agent dir", () => {
27
+ expect(secretReason(join(HOME, ".pi", "agent", "auth.json"))).toBe("pi credentials");
28
+ expect(secretReason(join(HOME, ".pi", "agent", "auth-foo.json"))).toBe("pi credentials");
29
+ expect(secretReason(join(HOME, ".pi", "agent", "settings.json"))).toBeUndefined();
30
+ expect(secretReason(join(HOME, ".pi", "agent", "xauth", "auth.json"))).toBeUndefined();
31
+ });
32
+
33
+ it("covers .env files but not the templates", () => {
34
+ expect(secretReason("/proj/.env")).toBe("environment file");
35
+ expect(secretReason("/proj/.env.local")).toBe("environment file");
36
+ expect(secretReason("/proj/.env.example")).toBeUndefined();
37
+ });
38
+ });
39
+
40
+ describe("danger table", () => {
41
+ it("catches the destructive core", () => {
42
+ expect(dangerReason("rm", ["-rf", "/"], ["/"], "/proj")).toContain("recursive delete");
43
+ expect(dangerReason("rm", ["-rf", HOME], [HOME], "/proj")).toContain("recursive delete");
44
+ expect(dangerReason("rm", ["-rf", "/proj"], ["/proj"], "/proj")).toContain("recursive delete");
45
+ expect(dangerReason("git", ["push", "--force"], [], "/proj")).toBe("forced git push");
46
+ expect(dangerReason("git", ["push", "-f"], [], "/proj")).toBe("forced git push");
47
+ expect(dangerReason("git", ["reset", "--hard"], [], "/proj")).toBe("git reset --hard");
48
+ expect(dangerReason("git", ["clean", "-fd"], [], "/proj")).toBe("git clean -f");
49
+ expect(dangerReason("npm", ["publish"], [], "/proj")).toBe("package publish");
50
+ expect(dangerReason("dd", ["of=/dev/sda"], ["/dev/sda"], "/proj")).toBe("raw device write");
51
+ expect(dangerReason("sudo", ["rm", "x"], ["/proj/x"], "/proj")).toBe("privileged command");
52
+ });
53
+
54
+ it("does not flag ordinary commands", () => {
55
+ expect(dangerReason("rm", ["-rf", "/proj/build"], ["/proj/build"], "/proj")).toBeUndefined();
56
+ expect(dangerReason("git", ["push"], [], "/proj")).toBeUndefined();
57
+ expect(dangerReason("git", ["log"], [], "/proj")).toBeUndefined();
58
+ expect(dangerReason("npm", ["view", "react"], [], "/proj")).toBeUndefined();
59
+ });
60
+ });
@@ -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
+ }