@itc-steve/pi-ask-complete 0.1.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,551 @@
1
+ /**
2
+ * Permission store: bash + tools + path wildcards.
3
+ * All rules come from permission.json — no hardcoded blocklists in code.
4
+ *
5
+ * File: ~/.pi/agent/permission.json
6
+ * Seeded from permission.json.example on first run.
7
+ */
8
+
9
+ import {
10
+ existsSync,
11
+ mkdirSync,
12
+ readFileSync,
13
+ renameSync,
14
+ writeFileSync,
15
+ } from "node:fs";
16
+ import { basename, dirname, join, resolve } from "node:path";
17
+ import { fileURLToPath } from "node:url";
18
+ import { homedir } from "node:os";
19
+ import {
20
+ baseCommand,
21
+ isPersistableBashKey,
22
+ isSudoPrefixed,
23
+ normalizeCommandForMatch,
24
+ stripSudoPrefix,
25
+ stripWrappers,
26
+ wrapperHasArgs,
27
+ } from "./base-command.ts";
28
+ import {
29
+ hasUnresolvedExpansion,
30
+ isUnresolvedPath,
31
+ pathArgs,
32
+ splitUnits,
33
+ } from "./bash-scan.ts";
34
+ import { matchGlob, normalizePath, resolveRules } from "./wildcard.ts";
35
+
36
+ export type PermissionKind = "bash" | "tools" | "paths";
37
+ export type RuleState = "allow" | "deny" | "ask";
38
+
39
+ export type PermissionFile = {
40
+ bash?: Record<string, RuleState>;
41
+ tools?: Record<string, RuleState>;
42
+ paths?: Record<string, "allow" | "deny">;
43
+ };
44
+
45
+ type PathRule = { pattern: string; state: "allow" | "deny" };
46
+
47
+ /** Expand ~/ and resolve relative/`..` against cwd (absolute args keep their root). */
48
+ function resolvePathArg(arg: string, cwd: string): string {
49
+ if (arg === "~") return homedir();
50
+ if (arg.startsWith("~/")) return resolve(homedir(), arg.slice(2));
51
+ return resolve(cwd, arg);
52
+ }
53
+
54
+ function agentDir(): string {
55
+ const fromEnv = process.env.PI_CODING_AGENT_DIR?.trim();
56
+ if (fromEnv) return fromEnv;
57
+ return join(homedir(), ".pi", "agent");
58
+ }
59
+
60
+ export function permissionFilePath(): string {
61
+ return join(agentDir(), "permission.json");
62
+ }
63
+
64
+ /** Bundled example shipped next to this package. */
65
+ export function examplePermissionPath(): string {
66
+ // src/ → package root
67
+ const here = dirname(fileURLToPath(import.meta.url));
68
+ return join(here, "..", "permission.json.example");
69
+ }
70
+
71
+ function readJsonFile(path: string): PermissionFile | null {
72
+ try {
73
+ const v = JSON.parse(readFileSync(path, "utf-8")) as unknown;
74
+ // Arrays / primitives parse but are not a rules object — treat as corrupt.
75
+ if (!v || typeof v !== "object" || Array.isArray(v)) return null;
76
+ return v as PermissionFile;
77
+ } catch {
78
+ return null;
79
+ }
80
+ }
81
+
82
+ /** Read example body if it is a usable non-array object JSON; else null. */
83
+ function readExampleBody(): string | null {
84
+ const examplePath = examplePermissionPath();
85
+ try {
86
+ if (!existsSync(examplePath)) return null;
87
+ const body = readFileSync(examplePath, "utf-8");
88
+ const v = JSON.parse(body) as unknown;
89
+ if (!v || typeof v !== "object" || Array.isArray(v)) return null;
90
+ return body;
91
+ } catch {
92
+ return null;
93
+ }
94
+ }
95
+
96
+ export class PermissionStore {
97
+ private bash = new Map<string, RuleState>();
98
+ private tools = new Map<string, RuleState>();
99
+ private pathRules: PathRule[] = [];
100
+ private pathIndex = new Map<string, "allow" | "deny">();
101
+ /** In-memory allows for this agent session only (not written to disk). */
102
+ private sessionBash = new Set<string>();
103
+ private sessionTools = new Set<string>();
104
+ private sessionPaths = new Set<string>();
105
+ /** Session-only: auto-approve asks. Never upgrades deny; never written to disk. */
106
+ private yoloOn = false;
107
+ /**
108
+ * No loadable policy (missing user file AND missing/unreadable example).
109
+ * Empty maps must not mean "everything readable" — checkPath denies all.
110
+ */
111
+ private noPolicy = false;
112
+ private path: string;
113
+
114
+ constructor(path = permissionFilePath()) {
115
+ this.path = path;
116
+ this.reload();
117
+ }
118
+
119
+ get filePath(): string {
120
+ return this.path;
121
+ }
122
+
123
+ get yolo(): boolean {
124
+ return this.yoloOn;
125
+ }
126
+
127
+ /** Toggle session YOLO — memory only; never touches permission.json. */
128
+ setYolo(on: boolean): void {
129
+ this.yoloOn = on;
130
+ }
131
+
132
+ /** Drop session-only allows + yolo (e.g. on new agent session). */
133
+ clearSession(): void {
134
+ this.sessionBash.clear();
135
+ this.sessionTools.clear();
136
+ this.sessionPaths.clear();
137
+ this.yoloOn = false;
138
+ }
139
+
140
+ /** Load rules from the user JSON only. Session allows are kept. */
141
+ reload(): void {
142
+ if (!existsSync(this.path)) {
143
+ // Missing live config (…/permission.json) → re-seed from example.
144
+ // Ephemeral/test paths with other names stay empty (no auto-seed).
145
+ // seedUserFile writes only; does not call reload (no recursion).
146
+ const isLiveConfig = basename(this.path) === "permission.json";
147
+ if (isLiveConfig && this.seedUserFile()) {
148
+ // File now exists; fall through to normal load.
149
+ } else if (isLiveConfig) {
150
+ // Broken install: keep last-good maps. Empty + no example → noPolicy.
151
+ if (this.bash.size === 0 && this.tools.size === 0 && this.pathIndex.size === 0) {
152
+ this.noPolicy = true;
153
+ }
154
+ return;
155
+ } else {
156
+ // Missing non-live path: do not clear (fail closed on last-good).
157
+ return;
158
+ }
159
+ }
160
+
161
+ const parsed = readJsonFile(this.path);
162
+ // Corrupt / unreadable file: keep last good in-memory rules (fail closed on denies).
163
+ if (!parsed) return;
164
+
165
+ this.noPolicy = false;
166
+ this.bash.clear();
167
+ this.tools.clear();
168
+ this.pathRules = [];
169
+ this.pathIndex.clear();
170
+
171
+ for (const [k, v] of Object.entries(parsed.bash ?? {})) {
172
+ if (k && (v === "allow" || v === "deny" || v === "ask")) this.bash.set(k, v);
173
+ }
174
+ for (const [k, v] of Object.entries(parsed.tools ?? {})) {
175
+ if (k && (v === "allow" || v === "deny" || v === "ask")) this.tools.set(k, v);
176
+ }
177
+ for (const [k, v] of Object.entries(parsed.paths ?? {})) {
178
+ if (k && (v === "allow" || v === "deny")) this.pathIndex.set(k, v);
179
+ }
180
+
181
+ const allows: PathRule[] = [];
182
+ const denies: PathRule[] = [];
183
+ for (const [pattern, state] of this.pathIndex) {
184
+ (state === "allow" ? allows : denies).push({ pattern, state });
185
+ }
186
+ this.pathRules = [...allows, ...denies];
187
+ }
188
+
189
+ /**
190
+ * Write permission.json.example bytes to this.path when missing.
191
+ * Does not call reload (avoids recursion). False when example is unusable.
192
+ */
193
+ private seedUserFile(): boolean {
194
+ if (existsSync(this.path)) return true;
195
+ const body = readExampleBody();
196
+ if (body === null) return false;
197
+ const dir = dirname(this.path);
198
+ if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
199
+ writeFileSync(this.path, body);
200
+ return true;
201
+ }
202
+
203
+ checkPath(filePath: string): "allow" | "deny" | undefined {
204
+ if (!filePath) return undefined;
205
+ // Broken install (no user file, no example): deny every path — empty ≠ readable.
206
+ if (this.noPolicy) return "deny";
207
+ return resolveRules(this.pathRules, normalizePath(filePath));
208
+ }
209
+
210
+ /**
211
+ * Whole-command decision: every command unit (segments + substitutions) must
212
+ * be allowed, and no path-like argument may hit a path deny rule.
213
+ * deny wins → deny; any non-allowed unit → ask; allow only when all allow.
214
+ */
215
+ checkBash(command: string, opts?: { cwd?: string }): RuleState {
216
+ const plan = this.planBash(command, opts);
217
+ if (plan.action === "deny") return "deny";
218
+ if (plan.action === "ask") return "ask";
219
+ return "allow";
220
+ }
221
+
222
+ /**
223
+ * Per-unit plan for a bash line. Deny still wins per unit/path; ask lists units
224
+ * that need approval. The gate prompts ONCE for the whole line (not per unit).
225
+ * `cwd` is used to resolve relative path args (optional; defaults to process.cwd()).
226
+ */
227
+ planBash(
228
+ command: string,
229
+ opts?: { cwd?: string },
230
+ ):
231
+ | { action: "allow" }
232
+ | {
233
+ action: "deny";
234
+ label: string;
235
+ kind: "path" | "bash" | "sudo_redirect";
236
+ }
237
+ | { action: "ask"; units: string[] } {
238
+ const units = splitUnits(command);
239
+ const cwd = opts?.cwd ?? process.cwd();
240
+
241
+ // sudo/doas after wrappers (`env sudo …`) → same redirect as a leading sudo.
242
+ for (const unit of units) {
243
+ const effective = stripWrappers(unit) || unit;
244
+ if (isSudoPrefixed(effective)) {
245
+ const inner = stripSudoPrefix(effective) || effective;
246
+ return {
247
+ action: "deny",
248
+ label: JSON.stringify(inner),
249
+ kind: "sudo_redirect",
250
+ };
251
+ }
252
+ }
253
+
254
+ // Path deny on the full line AND every unit (covers substitution bodies).
255
+ const pathCandidates = new Set<string>([
256
+ ...pathArgs(command),
257
+ ...units.flatMap((u) => pathArgs(u)),
258
+ ]);
259
+ for (const arg of pathCandidates) {
260
+ // Literal token first (`$HOME/.env` matches **/.env as typed).
261
+ if (this.checkPath(normalizePath(arg)) === "deny") {
262
+ return { action: "deny", label: arg, kind: "path" };
263
+ }
264
+ // Resolve ~/ and relative/`..` against cwd so `/etc/shadow` denies still hit.
265
+ const abs = resolvePathArg(arg, cwd);
266
+ if (normalizePath(abs) !== normalizePath(arg) && this.checkPath(normalizePath(abs)) === "deny") {
267
+ return { action: "deny", label: arg, kind: "path" };
268
+ }
269
+ }
270
+
271
+ const ask: string[] = [];
272
+ for (const unit of units) {
273
+ const state = this.checkUnit(unit);
274
+ if (state === "deny") {
275
+ return {
276
+ action: "deny",
277
+ label: baseCommand(unit) || unit,
278
+ kind: "bash",
279
+ };
280
+ }
281
+ if (state === "ask") ask.push(unit);
282
+ }
283
+
284
+ // Unresolved globs/braces/$'' — fail-closed ask when yolo is off (path may be
285
+ // a secret: `cat .*` → .env). Under yolo, treat like any other ask → allow;
286
+ // resolved path/bash denies above still block.
287
+ if (hasUnresolvedExpansion(command) || [...pathCandidates].some(isUnresolvedPath)) {
288
+ if (this.yoloOn) return { action: "allow" };
289
+ return {
290
+ action: "ask",
291
+ units: ask.length ? ask : units.length ? units : [command],
292
+ };
293
+ }
294
+
295
+ if (ask.length) {
296
+ if (this.yoloOn) return { action: "allow" };
297
+ return { action: "ask", units: ask };
298
+ }
299
+ return { action: "allow" };
300
+ }
301
+
302
+ /** Unique persistable base names for units that still need approval. */
303
+ askBases(units: string[]): string[] {
304
+ const out: string[] = [];
305
+ const seen = new Set<string>();
306
+ for (const unit of units) {
307
+ const b = baseCommand(unit);
308
+ // Drop junk (`1` from a bad split, keywords, etc.) — only real binaries.
309
+ if (!b || seen.has(b) || !isPersistableBashKey(b)) continue;
310
+ seen.add(b);
311
+ out.push(b);
312
+ }
313
+ return out;
314
+ }
315
+
316
+ /** Rule lookup for a SINGLE command unit (no operators/substitutions). */
317
+ checkUnit(command: string): RuleState {
318
+ const trimmed = command.trim();
319
+ // Peel env/strace/timeout/… so an allow-listed wrapper cannot launder the inner binary.
320
+ // Bare `env` peels to "" → keep original so the wrapper itself can still allow.
321
+ const peeled = stripWrappers(trimmed);
322
+ // Wrapper with args but no resolvable inner (`strace -f` alone) → ask, never allow.
323
+ if (!peeled && wrapperHasArgs(trimmed)) {
324
+ return this.yoloOn ? "allow" : "ask";
325
+ }
326
+ const effective = peeled || trimmed;
327
+
328
+ // First token of the *resolved* command (sudo after wrappers, real binary).
329
+ const rawFirst = (effective.split(/\s+/)[0] ?? "").replace(/^['"]|['"]$/g, "");
330
+ const rawBase = rawFirst.split(/[/\\]/).pop() ?? rawFirst;
331
+
332
+ // Never classify sudo/doas via the stripped inner binary (true; sudo id → id allow).
333
+ if (/^(sudo|doas)$/i.test(rawBase)) return "deny";
334
+
335
+ if (rawBase && this.bash.has(rawBase)) {
336
+ const s = this.bash.get(rawBase)!;
337
+ if (s !== "ask") return s;
338
+ }
339
+
340
+ // Full-command subjects: resolved + normalized (e.g. strip `git -C path`)
341
+ const normalized = normalizeCommandForMatch(effective);
342
+ const subjects =
343
+ normalized !== effective ? [effective, normalized] : [effective];
344
+
345
+ for (const sub of subjects) {
346
+ if (this.bash.has(sub)) {
347
+ const s = this.bash.get(sub)!;
348
+ if (s !== "ask") return s;
349
+ }
350
+ }
351
+
352
+ const base = baseCommand(effective);
353
+ if (base && base !== rawBase && this.bash.has(base)) {
354
+ const s = this.bash.get(base)!;
355
+ if (s !== "ask") return s;
356
+ }
357
+
358
+ // Wildcard patterns against full/normalized command, raw first token, base
359
+ for (const [pattern, state] of this.bash) {
360
+ if (!pattern.includes("*") && !pattern.includes("?")) continue;
361
+ const hit =
362
+ subjects.some((sub) => matchGlob(pattern, sub)) ||
363
+ (rawBase ? matchGlob(pattern, rawBase) : false) ||
364
+ (base ? matchGlob(pattern, base) : false);
365
+ if (hit) {
366
+ if (state === "allow") return "allow";
367
+ if (state === "deny") return "deny";
368
+ }
369
+ }
370
+
371
+ // Session allow only upgrades ask → allow (never overrides deny).
372
+ if ((base && this.sessionBash.has(base)) || (rawBase && this.sessionBash.has(rawBase))) {
373
+ return "allow";
374
+ }
375
+ // yolo last: only after every deny/allow lookup above.
376
+ return this.yoloOn ? "allow" : "ask";
377
+ }
378
+
379
+ checkTool(toolName: string): RuleState {
380
+ const s = this.tools.get(toolName);
381
+ if (s === "allow" || s === "deny") return s;
382
+ if (this.sessionTools.has(toolName)) return "allow";
383
+ // yolo upgrades ask / default-ask only — deny already returned.
384
+ if (this.yoloOn) return "allow";
385
+ return s ?? "ask";
386
+ }
387
+
388
+ decide(
389
+ toolName: string,
390
+ subject: string,
391
+ filePath?: string,
392
+ ): { state: RuleState; matched?: string } {
393
+ if (filePath) {
394
+ const pathState = this.checkPath(filePath);
395
+ if (pathState === "allow") return { state: "allow", matched: filePath };
396
+ if (pathState === "deny") return { state: "deny", matched: filePath };
397
+ if (this.sessionPaths.has(normalizePath(filePath))) {
398
+ return { state: "allow", matched: filePath };
399
+ }
400
+ }
401
+ const state =
402
+ toolName === "bash" ? this.checkBash(subject) : this.checkTool(toolName);
403
+ // yolo already applied inside checkBash/checkTool (including glob asks).
404
+ return { state };
405
+ }
406
+
407
+ isAllowed(toolName: string, subject: string, filePath?: string): boolean {
408
+ return this.decide(toolName, subject, filePath).state === "allow";
409
+ }
410
+
411
+ isDenied(toolName: string, subject: string, filePath?: string): boolean {
412
+ return this.decide(toolName, subject, filePath).state === "deny";
413
+ }
414
+
415
+ /** Persist allow to permission.json (and live maps). */
416
+ allowPermanently(toolName: string, subject: string, filePath?: string): string {
417
+ if (toolName === "bash") {
418
+ const entry = baseCommand(subject) || subject.trim();
419
+ // Refuse prose/keyword/junk keys — still allow once for this call via session.
420
+ if (!entry || !isPersistableBashKey(entry)) {
421
+ if (entry) this.sessionBash.add(entry); // session-only so this chain can finish
422
+ return "";
423
+ }
424
+ this.bash.set(entry, "allow");
425
+ this.sessionBash.add(entry); // live immediately even if disk write fails later
426
+ this.persistUserKey("bash", entry, "allow");
427
+ return entry;
428
+ }
429
+
430
+ if (filePath) {
431
+ const entry = normalizePath(filePath);
432
+ this.pathIndex.set(entry, "allow");
433
+ this.pathRules = [
434
+ { pattern: entry, state: "allow" },
435
+ ...this.pathRules.filter((r) => r.pattern !== entry),
436
+ ];
437
+ this.sessionPaths.add(entry);
438
+ this.persistUserKey("paths", entry, "allow");
439
+ return entry;
440
+ }
441
+
442
+ const entry = toolName.trim();
443
+ this.tools.set(entry, "allow");
444
+ this.sessionTools.add(entry);
445
+ this.persistUserKey("tools", entry, "allow");
446
+ return entry;
447
+ }
448
+
449
+ /** Allow for this agent session only — not written to disk. */
450
+ allowSession(toolName: string, subject: string, filePath?: string): string {
451
+ if (toolName === "bash") {
452
+ const entry = baseCommand(subject) || subject.trim();
453
+ if (entry) this.sessionBash.add(entry);
454
+ return entry;
455
+ }
456
+ if (filePath) {
457
+ const entry = normalizePath(filePath);
458
+ this.sessionPaths.add(entry);
459
+ return entry;
460
+ }
461
+ const entry = toolName.trim();
462
+ this.sessionTools.add(entry);
463
+ return entry;
464
+ }
465
+
466
+ /** Allow several bash bases for this session (one chain prompt). */
467
+ allowSessionBases(bases: string[]): string[] {
468
+ const out: string[] = [];
469
+ for (const b of bases) {
470
+ const entry = b.trim();
471
+ if (!entry) continue;
472
+ // Session can be looser than disk, but still skip empty/absurd keys.
473
+ if (entry.length > 64 || /[\s{}"'`]/.test(entry)) continue;
474
+ this.sessionBash.add(entry);
475
+ out.push(entry);
476
+ }
477
+ return out;
478
+ }
479
+
480
+ /** Persist several bash bases (one chain prompt → multi allow). */
481
+ allowPermanentlyBases(bases: string[]): string[] {
482
+ const out: string[] = [];
483
+ for (const b of bases) {
484
+ const entry = this.allowPermanently("bash", b);
485
+ if (entry) out.push(entry);
486
+ }
487
+ return out;
488
+ }
489
+
490
+ listAllowed(): {
491
+ bash: string[];
492
+ tools: string[];
493
+ paths: string[];
494
+ sessionBash: string[];
495
+ sessionTools: string[];
496
+ sessionPaths: string[];
497
+ } {
498
+ return {
499
+ bash: [...this.bash.entries()].filter(([, s]) => s === "allow").map(([k]) => k).sort(),
500
+ tools: [...this.tools.entries()].filter(([, s]) => s === "allow").map(([k]) => k).sort(),
501
+ paths: [...this.pathIndex.entries()].filter(([, s]) => s === "allow").map(([k]) => k).sort(),
502
+ sessionBash: [...this.sessionBash].sort(),
503
+ sessionTools: [...this.sessionTools].sort(),
504
+ sessionPaths: [...this.sessionPaths].sort(),
505
+ };
506
+ }
507
+
508
+ listDeniedPaths(): string[] {
509
+ return [...this.pathIndex.entries()].filter(([, s]) => s === "deny").map(([k]) => k).sort();
510
+ }
511
+
512
+ private persistUserKey(
513
+ section: "bash" | "tools" | "paths",
514
+ key: string,
515
+ state: "allow" | "deny" | "ask",
516
+ ): void {
517
+ const dir = dirname(this.path);
518
+ if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
519
+
520
+ let existing: PermissionFile = {};
521
+ if (existsSync(this.path)) {
522
+ existing = readJsonFile(this.path) ?? {};
523
+ }
524
+
525
+ const bucket = { ...(existing[section] ?? {}) } as Record<string, string>;
526
+ bucket[key] = state;
527
+ const next: PermissionFile = { ...existing, [section]: bucket };
528
+ const body = `${JSON.stringify(next, null, 2)}\n`;
529
+ // Atomic replace so a crash mid-write cannot leave corrupt JSON (fail-open).
530
+ const tmp = `${this.path}.${process.pid}.tmp`;
531
+ writeFileSync(tmp, body, "utf-8");
532
+ renameSync(tmp, this.path);
533
+ }
534
+
535
+ /**
536
+ * If the user config is missing, seed it from permission.json.example.
537
+ * Does not overwrite an existing file. Does not write an empty stub when the
538
+ * example is missing (that would fail open) — sets noPolicy instead.
539
+ */
540
+ ensureUserFile(): void {
541
+ if (existsSync(this.path)) return;
542
+ if (!this.seedUserFile()) {
543
+ // Broken install: do not write {} (empty file = no rules = secrets readable).
544
+ if (this.bash.size === 0 && this.tools.size === 0 && this.pathIndex.size === 0) {
545
+ this.noPolicy = true;
546
+ }
547
+ return;
548
+ }
549
+ this.reload();
550
+ }
551
+ }