@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,146 @@
1
+ /**
2
+ * permission feature — the confirmation dialog.
3
+ *
4
+ * Four answers, hotkey-first because this dialog appears mid-run and should be
5
+ * answerable with one keystroke:
6
+ *
7
+ * a — allow once
8
+ * s — allow this directory for the session
9
+ * d — deny once
10
+ * f — deny this directory for the session
11
+ *
12
+ * `SelectList` provides rendering, ↑↓ and Enter; the wrapper maps the four
13
+ * letters. Non-TUI dialog modes get the host's plain selector; with no UI at
14
+ * all the caller's `present` resolves `undefined` (fail closed).
15
+ */
16
+ import { getSelectListTheme } from "@earendil-works/pi-coding-agent";
17
+ import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
18
+ import {
19
+ type Component,
20
+ type KeyId,
21
+ matchesKey,
22
+ SelectList,
23
+ Text,
24
+ } from "@earendil-works/pi-tui";
25
+ import type { AskDecision } from "../../lib/gates.js";
26
+
27
+ export interface PermissionPromptInput {
28
+ readonly title: string;
29
+ readonly details: readonly string[];
30
+ }
31
+
32
+ interface Option {
33
+ readonly value: AskDecision;
34
+ readonly key: KeyId;
35
+ readonly label: string;
36
+ readonly description: string;
37
+ }
38
+
39
+ function optionsFor(): Option[] {
40
+ return [
41
+ {
42
+ value: "allow",
43
+ key: "a",
44
+ label: "Allow once (a)",
45
+ description: "Run this call only",
46
+ },
47
+ {
48
+ value: "allow-session",
49
+ key: "s",
50
+ label: "Allow for this session (s)",
51
+ description: "Stop asking for this operation until the session ends",
52
+ },
53
+ {
54
+ value: "deny",
55
+ key: "d",
56
+ label: "Deny (d)",
57
+ description: "Block this call",
58
+ },
59
+ {
60
+ value: "deny-session",
61
+ key: "f",
62
+ label: "Deny for this session (f)",
63
+ description: "Block this operation until the session ends",
64
+ },
65
+ ];
66
+ }
67
+
68
+ /** Header plus a hotkey-aware `SelectList`. */
69
+ class PromptComponent implements Component {
70
+ constructor(
71
+ private readonly header: Text,
72
+ private readonly list: SelectList,
73
+ private readonly options: readonly Option[],
74
+ private readonly choose: (value: AskDecision) => void,
75
+ ) {}
76
+
77
+ handleInput(data: string): void {
78
+ const hit = this.options.find((option) => matchesKey(data, option.key));
79
+ if (hit) {
80
+ this.choose(hit.value);
81
+ return;
82
+ }
83
+ this.list.handleInput(data);
84
+ }
85
+
86
+ render(width: number): string[] {
87
+ return [...this.header.render(width), ...this.list.render(width)];
88
+ }
89
+
90
+ invalidate(): void {
91
+ this.header.invalidate();
92
+ this.list.invalidate();
93
+ }
94
+ }
95
+
96
+ /**
97
+ * Ask the operator. Resolves `undefined` when there is no way to ask or the
98
+ * dialog was cancelled.
99
+ */
100
+ export async function askPermission(
101
+ ctx: ExtensionContext,
102
+ input: PermissionPromptInput,
103
+ ): Promise<AskDecision | undefined> {
104
+ if (!ctx.hasUI) return undefined;
105
+ const options = optionsFor();
106
+
107
+ if (ctx.mode !== "tui") {
108
+ const labels = options.map((option) => option.label);
109
+ const choice = await ctx.ui.select(input.title, labels);
110
+ if (choice === undefined) return undefined;
111
+ return options[labels.indexOf(choice)]?.value;
112
+ }
113
+
114
+ return ctx.ui.custom<AskDecision | undefined>(
115
+ (_tui, theme, _keybindings, done) => {
116
+ let settled = false;
117
+ const finish = (value: AskDecision | undefined): void => {
118
+ if (settled) return;
119
+ settled = true;
120
+ done(value);
121
+ };
122
+ const header = new Text(
123
+ [
124
+ theme.fg("accent", input.title),
125
+ ...input.details.map((line) => theme.fg("muted", line)),
126
+ theme.fg(
127
+ "dim",
128
+ "a allow · s allow session · d deny · f deny session · esc cancel",
129
+ ),
130
+ ].join("\n"),
131
+ );
132
+ const list = new SelectList(
133
+ options.map((option) => ({
134
+ value: option.value,
135
+ label: option.label,
136
+ description: option.description,
137
+ })),
138
+ 6,
139
+ getSelectListTheme(),
140
+ );
141
+ list.onSelect = (item) => finish(item.value as AskDecision);
142
+ list.onCancel = () => finish(undefined);
143
+ return new PromptComponent(header, list, options, finish);
144
+ },
145
+ );
146
+ }
@@ -0,0 +1,99 @@
1
+ /**
2
+ * permission feature — session-scoped grants.
3
+ *
4
+ * Ephemeral by design: an allow/deny recorded here lives only in this process
5
+ * and is cleared when the mode is switched off or the session ends. Nothing is
6
+ * written to disk, so a forgotten toggle cannot outlive the session it was
7
+ * meant for.
8
+ *
9
+ * A grant is `(effect, directory root)`. Read grants do not imply write grants.
10
+ * Matching is containment — no globs — because the operator grants "this
11
+ * directory", not "this pattern".
12
+ */
13
+ import type { Effect } from "./constants.js";
14
+ import { insideAnyRoot } from "./paths.js";
15
+
16
+ /** One session-scoped allow or deny. */
17
+ export interface Grant {
18
+ readonly effect: Effect;
19
+ /** Canonical directory root; the file that triggered it was folded in. */
20
+ readonly root: string;
21
+ }
22
+
23
+ export class SessionRules {
24
+ private readonly allowList: Grant[] = [];
25
+ private readonly denyList: Grant[] = [];
26
+ /**
27
+ * Grants for commands that cannot be resolved to a path (an opaque unit).
28
+ * Keyed on the command text, so only the exact command is covered.
29
+ */
30
+ private readonly allowedCommands = new Set<string>();
31
+ private readonly deniedCommands = new Set<string>();
32
+
33
+ /** Record an allow for `(effect, root)`; replaces an identical grant. */
34
+ allow(grant: Grant): void {
35
+ this.add(this.allowList, grant);
36
+ }
37
+
38
+ /** Record a deny for `(effect, root)`; replaces an identical grant. */
39
+ deny(grant: Grant): void {
40
+ this.add(this.denyList, grant);
41
+ }
42
+
43
+ private add(list: Grant[], grant: Grant): void {
44
+ const index = list.findIndex(
45
+ (entry) => entry.effect === grant.effect && entry.root === grant.root,
46
+ );
47
+ if (index >= 0) list[index] = grant;
48
+ else list.push(grant);
49
+ }
50
+
51
+ /** `true` when an allow covers this access. */
52
+ allows(effect: Effect, pathValue: string): boolean {
53
+ return this.allowList.some(
54
+ (grant) =>
55
+ grant.effect === effect && insideAnyRoot([grant.root], pathValue),
56
+ );
57
+ }
58
+
59
+ /** `true` when a deny covers this access. */
60
+ denies(effect: Effect, pathValue: string): boolean {
61
+ return this.denyList.some(
62
+ (grant) =>
63
+ grant.effect === effect && insideAnyRoot([grant.root], pathValue),
64
+ );
65
+ }
66
+
67
+ /** Record an allow for an opaque command's exact text. */
68
+ allowCommand(command: string): void {
69
+ this.allowedCommands.add(command);
70
+ }
71
+
72
+ /** Record a deny for an opaque command's exact text. */
73
+ denyCommand(command: string): void {
74
+ this.deniedCommands.add(command);
75
+ }
76
+
77
+ /** `true` when this exact command was allowed for the session. */
78
+ allowsCommand(command: string): boolean {
79
+ return this.allowedCommands.has(command);
80
+ }
81
+
82
+ /** `true` when this exact command was denied for the session. */
83
+ deniesCommand(command: string): boolean {
84
+ return this.deniedCommands.has(command);
85
+ }
86
+
87
+ /** Drop every grant (mode off, session boundary). */
88
+ clear(): void {
89
+ this.allowList.length = 0;
90
+ this.denyList.length = 0;
91
+ this.allowedCommands.clear();
92
+ this.deniedCommands.clear();
93
+ }
94
+
95
+ /** Counts for `/ask-perm status` and tests. */
96
+ counts(): { allow: number; deny: number } {
97
+ return { allow: this.allowList.length, deny: this.denyList.length };
98
+ }
99
+ }
@@ -0,0 +1,205 @@
1
+ /**
2
+ * Bash decomposition: segments, wrappers, substitution, cd folding, redirects,
3
+ * read/write classification, and the fail-closed cases.
4
+ */
5
+ import { homedir } from "node:os";
6
+ import { describe, expect, it } from "vitest";
7
+ import { parseCommandUnits, stripHeredocs, type CommandUnit } from "./units.js";
8
+
9
+ const CWD = "/proj";
10
+ const HOME = homedir();
11
+
12
+ function summary(command: string) {
13
+ return parseCommandUnits(command, CWD).map((unit) => ({
14
+ command: unit.command,
15
+ head: unit.headWord,
16
+ effect: unit.effect,
17
+ opaque: unit.opaque,
18
+ base: unit.base,
19
+ targets: unit.targets.map((target) => `${target.effect}:${target.path}`),
20
+ }));
21
+ }
22
+
23
+ function first(command: string): CommandUnit {
24
+ return parseCommandUnits(command, CWD)[0];
25
+ }
26
+
27
+ describe("segments and cd folding", () => {
28
+ it("splits on && and folds cd into the following command", () => {
29
+ const units = summary("cd /tmp && rm x");
30
+ expect(units).toHaveLength(2);
31
+ expect(units[0]).toMatchObject({ command: "cd", base: CWD, targets: ["read:/tmp"] });
32
+ expect(units[1]).toMatchObject({ command: "rm", base: "/tmp", targets: ["write:/tmp/x"] });
33
+ });
34
+
35
+ it("splits pipelines and keeps quoted operators as data", () => {
36
+ expect(summary("cat a | grep x")).toHaveLength(2);
37
+ expect(summary('echo "a && b"')).toHaveLength(1);
38
+ });
39
+
40
+ it("strips leading VAR=value assignments", () => {
41
+ expect(first("FOO=bar rm x").command).toBe("rm");
42
+ expect(first("FOO=bar rm x").headWord).toBe("rm");
43
+ });
44
+
45
+ it("returns no units for an empty command", () => {
46
+ expect(parseCommandUnits(" ", CWD)).toEqual([]);
47
+ });
48
+ });
49
+
50
+ describe("wrappers and nested execution", () => {
51
+ it("peels sudo and keeps the wrapper visible", () => {
52
+ const unit = first("sudo -u root rm -rf /");
53
+ expect(unit).toMatchObject({ command: "rm", headWord: "sudo" });
54
+ expect(unit.targets).toEqual([{ path: "/", effect: "write", source: "operand" }]);
55
+ });
56
+
57
+ it("peels xargs and env", () => {
58
+ expect(first("xargs -I{} rm {}").command).toBe("rm");
59
+ expect(first("env FOO=bar rm x").command).toBe("rm");
60
+ });
61
+
62
+ it("recurses into bash -c payloads", () => {
63
+ const unit = first("bash -c 'rm -rf /tmp/x'");
64
+ expect(unit).toMatchObject({ command: "rm", opaque: false });
65
+ expect(unit.targets[0]?.path).toBe("/tmp/x");
66
+ });
67
+
68
+ it("finds a substitution inside a quoted word", () => {
69
+ const units = summary('echo "x$(rm y)"');
70
+ expect(units[0]).toMatchObject({ command: "rm", targets: ["write:/proj/y"] });
71
+ expect(units[1]?.command).toBe("echo");
72
+ });
73
+
74
+ it("recurses into command substitution", () => {
75
+ const units = summary("echo $(rm -rf /tmp/x)");
76
+ expect(units[0]).toMatchObject({
77
+ command: "rm",
78
+ effect: "write",
79
+ targets: ["write:/tmp/x"],
80
+ });
81
+ expect(units[1]).toMatchObject({ command: "echo", effect: "read" });
82
+ });
83
+
84
+ it("recurses into find -exec", () => {
85
+ const units = summary("find . -name x -exec rm {} \\;");
86
+ expect(units.map((unit) => unit.command)).toContain("rm");
87
+ });
88
+ });
89
+
90
+ describe("redirects and heredocs", () => {
91
+ it("classifies redirect targets and ignores /dev/null", () => {
92
+ expect(summary("ls 2>/dev/null")[0]?.targets).toEqual([]);
93
+ expect(summary("echo hi > /tmp/x")[0]?.targets).toEqual(["write:/tmp/x"]);
94
+ expect(summary("cat < input > output")[0]?.targets).toEqual([
95
+ "read:/proj/input",
96
+ "write:/proj/output",
97
+ ]);
98
+ });
99
+
100
+ it("treats a redirect to a credential path as a write to it", () => {
101
+ expect(summary("echo a > ~/.ssh/authorized_keys")[0]?.targets).toEqual([
102
+ `write:${HOME}/.ssh/authorized_keys`,
103
+ ]);
104
+ });
105
+
106
+ it("keeps the redirect target as the target, not the command name", () => {
107
+ expect(summary("ls > out.txt")[0]?.targets).toEqual(["write:/proj/out.txt"]);
108
+ expect(summary("ls >> out.txt")[0]?.targets).toEqual(["write:/proj/out.txt"]);
109
+ expect(summary("ls 2> err.txt")[0]?.targets).toEqual(["write:/proj/err.txt"]);
110
+ });
111
+
112
+ it("strips heredoc bodies and keeps the redirect", () => {
113
+ const unit = first("cat > f <<EOF\nhello\nEOF");
114
+ expect(unit).toMatchObject({ command: "cat", effect: "write" });
115
+ expect(unit.targets).toEqual([{ path: "/proj/f", effect: "write", source: "redirect" }]);
116
+ });
117
+
118
+ it("marks an unterminated heredoc opaque", () => {
119
+ expect(stripHeredocs("cat <<EOF\nunterminated")).toBeUndefined();
120
+ expect(first("cat <<EOF\nunterminated").opaque).toBe(true);
121
+ });
122
+ });
123
+
124
+ describe("read/write classification", () => {
125
+ it("knows read and write commands", () => {
126
+ expect(first("git log").effect).toBe("read");
127
+ expect(first("git push").effect).toBe("write");
128
+ expect(first("pnpm view react").effect).toBe("read");
129
+ expect(first("pnpm install").effect).toBe("write");
130
+ expect(first("cat notes.txt").effect).toBe("read");
131
+ });
132
+
133
+ it("withdraws a read claim for write-capable options", () => {
134
+ const sed = first("sed -i s/a/b/ f.txt");
135
+ expect(sed.effect).toBe("write");
136
+ expect(sed.targets).toEqual([
137
+ { path: "/proj/f.txt", effect: "write", source: "operand" },
138
+ ]);
139
+ expect(first("sort -o out.txt f").targets).toEqual([
140
+ { path: "/proj/out.txt", effect: "write", source: "operand" },
141
+ ]);
142
+ });
143
+
144
+ it("treats unknown commands as writes", () => {
145
+ expect(first("frobnicate x").effect).toBe("write");
146
+ });
147
+ });
148
+
149
+ describe("paths", () => {
150
+ it("treats rm/mv/cp operands as targets even when bare", () => {
151
+ expect(summary("rm notes.txt")[0]?.targets).toEqual(["write:/proj/notes.txt"]);
152
+ expect(summary("cp a b")[0]?.targets).toEqual(["write:/proj/a", "write:/proj/b"]);
153
+ });
154
+
155
+ it("resolves git -C against the -C directory, not the base", () => {
156
+ expect(summary("git -C ../other log")[0]?.targets).toEqual([
157
+ "read:/other",
158
+ ]);
159
+ expect(summary("git -C /tmp log")[0]?.targets).toEqual(["read:/tmp"]);
160
+ expect(summary("git -C \"$DIR\" log")[0]?.opaque).toBe(true);
161
+ });
162
+
163
+ it("collapses a glob to the directory it expands in", () => {
164
+ expect(summary("rm -rf *")[0]?.targets).toEqual([`write:${CWD}`]);
165
+ });
166
+
167
+ it("does not treat URLs as paths", () => {
168
+ expect(summary("curl -o /tmp/x https://example.com/a")[0]?.targets).toEqual([
169
+ "write:/tmp/x",
170
+ ]);
171
+ });
172
+
173
+ it("expands ~ while parsing", () => {
174
+ expect(summary("tee ~/x")[0]?.targets).toEqual([`write:${HOME}/x`]);
175
+ });
176
+ });
177
+
178
+ describe("fail closed", () => {
179
+ it("marks an unresolved variable in a path position opaque", () => {
180
+ expect(summary("echo $UNKNOWN/x")).toEqual([
181
+ expect.objectContaining({ opaque: true, targets: [] }),
182
+ ]);
183
+ });
184
+
185
+ it("marks a non-literal cd and its following relative command opaque", () => {
186
+ const units = summary('cd "$DIR" && rm x');
187
+ expect(units[0]?.opaque).toBe(true);
188
+ expect(units[1]?.opaque).toBe(true);
189
+ expect(units[1]?.base).toBeNull();
190
+ });
191
+
192
+ it("ignores comments, including apostrophes inside them", () => {
193
+ expect(first("echo hi # it's fine").opaque).toBe(false);
194
+ expect(first("echo hi # a 'quote").opaque).toBe(false);
195
+ expect(first("echo hi # x '").targets).toEqual([]);
196
+ });
197
+
198
+ it("marks unbalanced quoting opaque", () => {
199
+ expect(first('echo "unclosed').opaque).toBe(true);
200
+ });
201
+
202
+ it("marks a non-literal shell payload opaque", () => {
203
+ expect(first('bash -c "$CMD"').opaque).toBe(true);
204
+ });
205
+ });