@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,338 @@
1
+ /**
2
+ * permission feature — per-call decisions.
3
+ *
4
+ * The policy is evaluated per command unit: every touched path is canonicalized,
5
+ * classified as inside or outside the session roots, and answered by, in order:
6
+ *
7
+ * 1. the fixed secrets table (deny in every mode),
8
+ * 2. the fixed danger table (ask in every mode),
9
+ * 3. session grants (deny wins over allow),
10
+ * 4. the four modes.
11
+ *
12
+ * The strictest unit of a compound command wins (`deny > ask > allow`), so
13
+ * `cd safe && rm -rf /` cannot ride on the first segment's verdict.
14
+ */
15
+ import type { PermissionModes } from "./constants.js";
16
+ import { dangerReason, secretReason, type Effect } from "./constants.js";
17
+ import { canonicalize, grantRoot, insideAnyRoot } from "./paths.js";
18
+ import type { SessionRules } from "./session-rules.js";
19
+ import type { CommandUnit } from "./units.js";
20
+
21
+ export type PolicyAction = "allow" | "ask" | "deny";
22
+
23
+ /** One resolved access a decision was made about. */
24
+ export interface PolicyTarget {
25
+ /** Canonical absolute path. */
26
+ readonly path: string;
27
+ readonly effect: Effect;
28
+ readonly scope: "in" | "out";
29
+ }
30
+
31
+ export interface PolicyDecision {
32
+ readonly action: PolicyAction;
33
+ readonly unit: CommandUnit;
34
+ readonly target?: PolicyTarget;
35
+ /** Human/model-facing explanation for ask and deny. */
36
+ readonly reason?: string;
37
+ }
38
+
39
+ export interface PolicyContext {
40
+ readonly modes: PermissionModes;
41
+ /** Canonical session roots (working directory plus added directories). */
42
+ readonly roots: readonly string[];
43
+ readonly rules: SessionRules;
44
+ }
45
+
46
+ const SEVERITY: Record<PolicyAction, number> = { allow: 0, ask: 1, deny: 2 };
47
+
48
+ /** What the session grants alone say about one access. */
49
+ function decideSession(
50
+ effect: Effect,
51
+ pathValue: string,
52
+ ctx: PolicyContext,
53
+ ): "allow" | "deny" | undefined {
54
+ if (ctx.rules.denies(effect, pathValue)) return "deny";
55
+ if (ctx.rules.allows(effect, pathValue)) return "allow";
56
+ return undefined;
57
+ }
58
+
59
+ function worse(a: PolicyDecision, b: PolicyDecision): PolicyDecision {
60
+ return SEVERITY[b.action] > SEVERITY[a.action] ? b : a;
61
+ }
62
+
63
+ function modeFor(
64
+ modes: PermissionModes,
65
+ effect: Effect,
66
+ scope: "in" | "out",
67
+ ): "ask" | "allow" | "deny" {
68
+ if (effect === "read") return scope === "in" ? modes.inRead : modes.outRead;
69
+ return scope === "in" ? modes.inWrite : modes.outWrite;
70
+ }
71
+
72
+ /** Scope and session-grant answer for one already-canonical path. */
73
+ function decidePath(
74
+ unit: CommandUnit,
75
+ pathValue: string,
76
+ effect: Effect,
77
+ ctx: PolicyContext,
78
+ ): PolicyDecision {
79
+ const scope = insideAnyRoot(ctx.roots, pathValue) ? "in" : "out";
80
+ const secret = secretReason(pathValue);
81
+ if (secret !== undefined) {
82
+ return {
83
+ action: "deny",
84
+ unit,
85
+ target: { path: pathValue, effect, scope },
86
+ reason: `${secret} — denied by the fixed safety table`,
87
+ };
88
+ }
89
+ const target: PolicyTarget = { path: pathValue, effect, scope };
90
+ if (ctx.rules.denies(effect, pathValue)) {
91
+ return {
92
+ action: "deny",
93
+ unit,
94
+ target,
95
+ reason: `${effect} under ${pathValue} was denied for this session`,
96
+ };
97
+ }
98
+ if (ctx.rules.allows(effect, pathValue)) {
99
+ return { action: "allow", unit, target };
100
+ }
101
+ const mode = modeFor(ctx.modes, effect, scope);
102
+ if (mode === "allow") return { action: "allow", unit, target };
103
+ if (mode === "deny") {
104
+ return {
105
+ action: "deny",
106
+ unit,
107
+ target,
108
+ reason: `${scope === "out" ? "outside-project" : "in-project"} ${effect} is set to deny`,
109
+ };
110
+ }
111
+ return {
112
+ action: "ask",
113
+ unit,
114
+ target,
115
+ reason:
116
+ scope === "out"
117
+ ? `outside-project ${effect} requires confirmation`
118
+ : `in-project ${effect} requires confirmation`,
119
+ };
120
+ }
121
+
122
+ /** Decide one command unit. */
123
+ export function decideUnit(
124
+ unit: CommandUnit,
125
+ ctx: PolicyContext,
126
+ ): PolicyDecision {
127
+ if (unit.opaque) {
128
+ // Session grants for an opaque program are keyed on the whole command
129
+ // text and checked in {@link decideProgram}; a single unit has no path
130
+ // to grant.
131
+ return {
132
+ action: "ask",
133
+ unit,
134
+ reason: unit.note ?? "this command could not be analysed",
135
+ };
136
+ }
137
+
138
+ const resolvedTargets = unit.targets.map((target) => ({
139
+ ...target,
140
+ path: canonicalize(target.path),
141
+ }));
142
+ const canonicalBase = unit.base === null ? null : canonicalize(unit.base);
143
+
144
+ // The secrets table is absolute: it outranks both the danger table and the
145
+ // modes, so `sudo cat ~/.ssh/id_rsa` denies instead of asking.
146
+ const secret = resolvedTargets
147
+ .map((target) => ({ target, reason: secretReason(target.path) }))
148
+ .find((entry) => entry.reason !== undefined);
149
+ if (secret?.reason !== undefined) {
150
+ return {
151
+ action: "deny",
152
+ unit,
153
+ target: {
154
+ path: secret.target.path,
155
+ effect: secret.target.effect,
156
+ scope: insideAnyRoot(ctx.roots, secret.target.path) ? "in" : "out",
157
+ },
158
+ reason: `${secret.reason} — denied by the fixed safety table`,
159
+ };
160
+ }
161
+ if (resolvedTargets.length === 0 && canonicalBase !== null) {
162
+ const baseSecret = secretReason(canonicalBase);
163
+ if (baseSecret !== undefined) {
164
+ return {
165
+ action: "deny",
166
+ unit,
167
+ target: {
168
+ path: canonicalBase,
169
+ effect: unit.effect,
170
+ scope: insideAnyRoot(ctx.roots, canonicalBase) ? "in" : "out",
171
+ },
172
+ reason: `${baseSecret} — denied by the fixed safety table`,
173
+ };
174
+ }
175
+ }
176
+
177
+ // Session grants are the operator's explicit decision and outrank the danger
178
+ // table: once an operation is allowed for the session, repeating it must not
179
+ // ask again. A deny likewise turns a dangerous command into a denial instead
180
+ // of a prompt.
181
+ let sessionVerdict: "allow" | "deny" | undefined;
182
+ if (resolvedTargets.length === 0) {
183
+ sessionVerdict =
184
+ canonicalBase === null
185
+ ? undefined
186
+ : decideSession(unit.effect, canonicalBase, ctx);
187
+ } else {
188
+ const verdicts = resolvedTargets.map((target) =>
189
+ decideSession(target.effect, target.path, ctx),
190
+ );
191
+ if (verdicts.includes("deny")) {
192
+ const denied = resolvedTargets[verdicts.indexOf("deny")];
193
+ return decidePath(unit, denied.path, denied.effect, ctx);
194
+ }
195
+ sessionVerdict = verdicts.every((verdict) => verdict === "allow")
196
+ ? "allow"
197
+ : undefined;
198
+ }
199
+ if (sessionVerdict === "deny" && canonicalBase !== null) {
200
+ return decidePath(unit, canonicalBase, unit.effect, ctx);
201
+ }
202
+
203
+ // The fixed danger table asks regardless of mode: `rm -rf .` inside the
204
+ // project is still destructive, and scope alone cannot express that.
205
+ const danger = dangerReason(
206
+ unit.command,
207
+ unit.args,
208
+ resolvedTargets.map((target) => target.path),
209
+ canonicalBase,
210
+ );
211
+ const privileged = unit.headWord === "sudo" || unit.headWord === "doas";
212
+ if (sessionVerdict !== "allow" && (danger !== undefined || privileged)) {
213
+ const first = resolvedTargets[0];
214
+ let target: PolicyTarget | undefined;
215
+ if (first !== undefined) {
216
+ target = {
217
+ path: first.path,
218
+ effect: first.effect,
219
+ scope: insideAnyRoot(ctx.roots, first.path) ? "in" : "out",
220
+ };
221
+ }
222
+ return {
223
+ action: "ask",
224
+ unit,
225
+ target,
226
+ reason: danger ?? "privileged command",
227
+ };
228
+ }
229
+
230
+ if (resolvedTargets.length === 0) {
231
+ if (canonicalBase === null) {
232
+ return {
233
+ action: "ask",
234
+ unit,
235
+ reason: "the working directory could not be resolved",
236
+ };
237
+ }
238
+ return decidePath(unit, canonicalBase, unit.effect, ctx);
239
+ }
240
+
241
+ let decision = decidePath(
242
+ unit,
243
+ resolvedTargets[0].path,
244
+ resolvedTargets[0].effect,
245
+ ctx,
246
+ );
247
+ for (const target of resolvedTargets.slice(1)) {
248
+ decision = worse(decision, decidePath(unit, target.path, target.effect, ctx));
249
+ }
250
+ return decision;
251
+ }
252
+
253
+ /** Decide a whole bash program (or the single unit of a path-bearing tool). */
254
+ /** Stable key for a session grant on an opaque program. */
255
+ export function programKey(units: readonly CommandUnit[]): string {
256
+ return units.map((unit) => unit.raw).join(" ; ");
257
+ }
258
+
259
+ export function decideProgram(
260
+ units: readonly CommandUnit[],
261
+ ctx: PolicyContext,
262
+ ): PolicyDecision {
263
+ if (units.length === 0) {
264
+ return {
265
+ action: "allow",
266
+ unit: {
267
+ raw: "",
268
+ headWord: "",
269
+ command: "",
270
+ args: [],
271
+ effect: "read",
272
+ opaque: false,
273
+ base: null,
274
+ targets: [],
275
+ },
276
+ };
277
+ }
278
+ // A program the parser could not fully resolve has no path-level grant;
279
+ // the operator's "allow this session" covers the exact command text, so a
280
+ // repeat of the same opaque program does not ask again.
281
+ const opaqueUnit = units.find((unit) => unit.opaque);
282
+ if (opaqueUnit) {
283
+ const key = programKey(units);
284
+ if (ctx.rules.deniesCommand(key)) {
285
+ return {
286
+ action: "deny",
287
+ unit: opaqueUnit,
288
+ reason: "this command was denied for this session",
289
+ };
290
+ }
291
+ if (ctx.rules.allowsCommand(key)) {
292
+ return { action: "allow", unit: opaqueUnit };
293
+ }
294
+ }
295
+
296
+ let decision = decideUnit(units[0], ctx);
297
+ for (const unit of units.slice(1)) {
298
+ decision = worse(decision, decideUnit(unit, ctx));
299
+ }
300
+ return decision;
301
+ }
302
+
303
+ /** Root a session grant for this decision should cover. */
304
+ export function decisionGrantRoot(
305
+ decision: PolicyDecision,
306
+ getCwd: () => string,
307
+ ): string {
308
+ if (decision.target) return grantRoot(decision.target.path);
309
+ if (decision.unit.base !== null) return grantRoot(canonicalize(decision.unit.base));
310
+ return grantRoot(canonicalize(getCwd()));
311
+ }
312
+
313
+ /** Effect a session grant for this decision should cover. */
314
+ export function decisionEffect(decision: PolicyDecision): Effect {
315
+ return decision.target?.effect ?? decision.unit.effect;
316
+ }
317
+
318
+ /**
319
+ * Model-facing denial. The model receives this as the tool result, so it names
320
+ * the offending segment, the resolved target, and the policy that denied it —
321
+ * and tells the model not to retry an equivalent command.
322
+ */
323
+ export function formatDenial(decision: PolicyDecision): string {
324
+ const lines = [`Permission denied: ${decision.unit.raw || "(empty command)"}`];
325
+ if (decision.target) {
326
+ const scope = decision.target.scope === "out" ? "outside project" : "inside project";
327
+ lines.push(
328
+ `Target: ${decision.target.path} (${scope}, ${decision.target.effect})`,
329
+ );
330
+ }
331
+ if (decision.reason) lines.push(`Reason: ${decision.reason}`);
332
+ lines.push(
333
+ decision.reason?.includes("fixed safety table") === true
334
+ ? "This is a fixed safety rule; /ask-perm cannot override it. Do not retry an equivalent command."
335
+ : "The operator controls this with /ask-perm. Do not retry the same operation through an equivalent command.",
336
+ );
337
+ return lines.join("\n");
338
+ }
@@ -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
+ }