@itc-steve/pi-ask-complete 0.1.0 → 0.2.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.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,10 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.2.0
4
+
5
+ - Path allows now act like directory-scoped YOLO for bash, write, and edit asks
6
+ - Any matching bash or path deny now takes priority over every allow
7
+
3
8
  ## 0.1.0
4
9
 
5
10
  First public release.
package/README.md CHANGED
@@ -16,8 +16,8 @@ Seeded `permission.json` allows non-destructive **explore / troubleshoot** comma
16
16
  out of the box (listing, read/print text, search, process/system info, network
17
17
  diagnostics, checksums, git read-only subcommands). `find`/`awk`/`curl`/`fd` etc are
18
18
  **ask** (not allow) — they are general exec engines.
19
- Path **deny** rules still block secret files even when the binary is allowed
20
- (`cat .env` → deny).
19
+ Path **deny** rules still block secret files even when the binary or project
20
+ directory is allowed (`cat .env` → deny).
21
21
 
22
22
  Still **ask** (not pre-allowed): writers and mutators — `rm`, `mv`, `cp`, `mkdir`,
23
23
  `touch`, `sed`, `tee`, `chmod`, package managers, `git push` / `commit` / `reset`,
@@ -40,9 +40,12 @@ Panel options: **Allow this** · **Allow for this session** · **Allow permanent
40
40
  ? one char except /
41
41
  ```
42
42
 
43
- **Most-specific pattern wins.** The pattern that pins more of the filename tail
44
- takes precedence regardless of allow/deny, so `**/.env` **deny** beats a broad
45
- `Projects/**` **allow** for `Projects/app/.env`. Exact-specificity ties deny wins.
43
+ **Deny always wins.** If any matching path rule is `deny`, no `allow` can
44
+ override it. Otherwise, a matching directory `allow` acts like path-scoped YOLO:
45
+ bash asks running in that directory, plus writes and edits there, are approved
46
+ automatically. A command that starts with `cd` into an allowed directory is also
47
+ approved when all detected path arguments are allowed. Bash deny rules and path
48
+ deny rules still block it.
46
49
 
47
50
  Bash commands are decomposed for **policy**: chains (`;` `|` `&&`) and substitutions
48
51
  each become a unit that must independently pass. Path-like args are checked against
@@ -103,15 +106,13 @@ Full starter list: `permission.json.example`.
103
106
  pi install npm:@itc-steve/pi-ask-complete
104
107
  ```
105
108
 
106
- Or from a local checkout:
109
+ From a local checkout:
107
110
 
108
111
  ```bash
109
112
  pi install /path/to/pi-ask-complete
110
- # packages entry: "npm:@itc-steve/pi-ask-complete"
111
- # or: "../../Projects/pi-ask-complete"
112
113
  ```
113
114
 
114
- `/reload` after changes. `/permissions` shows current allows.
115
+ Then `/reload`. `/permissions` shows current allows.
115
116
 
116
117
  ## `/yolo`
117
118
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@itc-steve/pi-ask-complete",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "description": "Pi extension: bottom-panel ask_user + bash permission gate (Allow / Allow permanently / Deny with reason) writing ~/.pi/agent/permission.json",
5
5
  "keywords": [
6
6
  "pi-package",
package/src/bash-scan.ts CHANGED
@@ -280,6 +280,8 @@ export function cleanPathToken(token: string): string {
280
280
  */
281
281
  export function isUnresolvedPath(token: string): boolean {
282
282
  if (!token) return false;
283
+ // Bash expands ~user, but this gate only resolves the current user's ~/.
284
+ if (/^~[^/\s]+(?:\/|$)/.test(token)) return true;
283
285
  let i = 0;
284
286
  while (i < token.length) {
285
287
  const c = token[i]!;
@@ -361,6 +363,12 @@ export function pathArgs(command: string): string[] {
361
363
  // bare tokens (and if=/.git pathspec forms via cleanPathToken)
362
364
  for (const rawTok of src.split(/\s+/)) {
363
365
  if (!rawTok) continue;
366
+ // Attached option values commonly carry paths (`--file=.env`, `--chdir=/tmp`).
367
+ const optionValue = rawTok.match(/^--?[A-Za-z][A-Za-z0-9-]*=(.+)$/)?.[1];
368
+ if (optionValue) {
369
+ push(optionValue);
370
+ continue;
371
+ }
364
372
  // Keep if=.env visible to cleanPathToken (looksLikePath alone would skip `=`).
365
373
  if (/^(?:if|of|in|out|file|path|filename|dest|source)=/i.test(rawTok)) {
366
374
  push(rawTok);
@@ -26,6 +26,7 @@ import {
26
26
  wrapperHasArgs,
27
27
  } from "./base-command.ts";
28
28
  import {
29
+ cleanPathToken,
29
30
  hasUnresolvedExpansion,
30
31
  isUnresolvedPath,
31
32
  pathArgs,
@@ -51,6 +52,24 @@ function resolvePathArg(arg: string, cwd: string): string {
51
52
  return resolve(cwd, arg);
52
53
  }
53
54
 
55
+ type ExecutionScope = { cwd: string; cdTarget?: string };
56
+
57
+ /** Resolve one simple leading `cd path &&`; multiple/mid-chain cds are ambiguous. */
58
+ function executionScope(command: string, cwd: string, units: string[]): ExecutionScope | undefined {
59
+ const cdUnits = units.filter((unit) => {
60
+ const effective = stripWrappers(unit) || unit;
61
+ return /^cd(?:\s|$)/.test(effective);
62
+ });
63
+ if (cdUnits.length === 0) return { cwd: resolve(cwd) };
64
+ if (cdUnits.length !== 1 || cdUnits[0] !== units[0]) return undefined;
65
+
66
+ const match = command.match(/^\s*cd\s+("[^"]*"|'[^']*'|[^\s;&|]+)\s*&&/);
67
+ if (!match?.[1]) return undefined;
68
+ const target = cleanPathToken(match[1]);
69
+ if (!target || target.startsWith("-") || isUnresolvedPath(target)) return undefined;
70
+ return { cwd: resolvePathArg(target, cwd), cdTarget: target };
71
+ }
72
+
54
73
  function agentDir(): string {
55
74
  const fromEnv = process.env.PI_CODING_AGENT_DIR?.trim();
56
75
  if (fromEnv) return fromEnv;
@@ -251,19 +270,26 @@ export class PermissionStore {
251
270
  }
252
271
  }
253
272
 
254
- // Path deny on the full line AND every unit (covers substitution bodies).
273
+ // Path deny on the full line and every unit (covers substitution bodies).
274
+ const scope = executionScope(command, cwd, units);
275
+ const effectiveCwd = normalizePath(scope?.cwd ?? resolve(cwd));
276
+ const cwdState = scope ? this.checkPath(effectiveCwd) : undefined;
255
277
  const pathCandidates = new Set<string>([
256
278
  ...pathArgs(command),
257
279
  ...units.flatMap((u) => pathArgs(u)),
258
280
  ]);
281
+ const actualPath = (arg: string): string => {
282
+ const base = scope?.cdTarget === cleanPathToken(arg) ? cwd : effectiveCwd;
283
+ return normalizePath(resolvePathArg(arg, base));
284
+ };
285
+ const pathIsAllowed = (arg: string): boolean =>
286
+ this.checkPath(actualPath(arg)) === "allow";
259
287
  for (const arg of pathCandidates) {
260
288
  // Literal token first (`$HOME/.env` matches **/.env as typed).
261
289
  if (this.checkPath(normalizePath(arg)) === "deny") {
262
290
  return { action: "deny", label: arg, kind: "path" };
263
291
  }
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") {
292
+ if (this.checkPath(actualPath(arg)) === "deny") {
267
293
  return { action: "deny", label: arg, kind: "path" };
268
294
  }
269
295
  }
@@ -292,6 +318,12 @@ export class PermissionStore {
292
318
  };
293
319
  }
294
320
 
321
+ // An allowed execution cwd acts like directory-scoped yolo only when every
322
+ // detected path stays allowed. Explicit path/bash denies above still win.
323
+ if (cwdState === "allow" && [...pathCandidates].every(pathIsAllowed)) {
324
+ return { action: "allow" };
325
+ }
326
+
295
327
  if (ask.length) {
296
328
  if (this.yoloOn) return { action: "allow" };
297
329
  return { action: "ask", units: ask };
@@ -315,7 +347,15 @@ export class PermissionStore {
315
347
 
316
348
  /** Rule lookup for a SINGLE command unit (no operators/substitutions). */
317
349
  checkUnit(command: string): RuleState {
318
- const trimmed = command.trim();
350
+ let trimmed = command.trim();
351
+ for (let i = 0; i < 8; i++) {
352
+ const next = trimmed
353
+ .replace(/^(?:[!({]\s*|(?:then|do|else|if|while|until)\s+)/, "")
354
+ .replace(/\s*[)}]\s*$/, "")
355
+ .trim();
356
+ if (next === trimmed) break;
357
+ trimmed = next;
358
+ }
319
359
  // Peel env/strace/timeout/… so an allow-listed wrapper cannot launder the inner binary.
320
360
  // Bare `env` peels to "" → keep original so the wrapper itself can still allow.
321
361
  const peeled = stripWrappers(trimmed);
@@ -332,41 +372,27 @@ export class PermissionStore {
332
372
  // Never classify sudo/doas via the stripped inner binary (true; sudo id → id allow).
333
373
  if (/^(sudo|doas)$/i.test(rawBase)) return "deny";
334
374
 
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`)
375
+ // Full-command subjects: resolved + normalized (e.g. strip `git -C path`).
341
376
  const normalized = normalizeCommandForMatch(effective);
342
377
  const subjects =
343
378
  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
379
  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
380
 
358
- // Wildcard patterns against full/normalized command, raw first token, base
381
+ // Check every matching rule so deny always beats allow, regardless of order
382
+ // or whether the allow matched a base and the deny matched a full command.
383
+ let configuredAllow = false;
359
384
  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
- }
385
+ const wildcard = pattern.includes("*") || pattern.includes("?");
386
+ const hit = wildcard
387
+ ? subjects.some((sub) => matchGlob(pattern, sub)) ||
388
+ (rawBase ? matchGlob(pattern, rawBase) : false) ||
389
+ (base ? matchGlob(pattern, base) : false)
390
+ : subjects.includes(pattern) || pattern === rawBase || pattern === base;
391
+ if (!hit) continue;
392
+ if (state === "deny") return "deny";
393
+ if (state === "allow") configuredAllow = true;
369
394
  }
395
+ if (configuredAllow) return "allow";
370
396
 
371
397
  // Session allow only upgrades ask → allow (never overrides deny).
372
398
  if ((base && this.sessionBash.has(base)) || (rawBase && this.sessionBash.has(rawBase))) {
@@ -390,18 +416,21 @@ export class PermissionStore {
390
416
  subject: string,
391
417
  filePath?: string,
392
418
  ): { 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 =
419
+ const pathState = filePath ? this.checkPath(filePath) : undefined;
420
+ if (pathState === "deny") return { state: "deny", matched: filePath };
421
+
422
+ const toolState =
402
423
  toolName === "bash" ? this.checkBash(subject) : this.checkTool(toolName);
424
+ if (toolState === "deny") return { state: "deny" };
425
+
426
+ if (
427
+ filePath &&
428
+ (pathState === "allow" || this.sessionPaths.has(normalizePath(filePath)))
429
+ ) {
430
+ return { state: "allow", matched: filePath };
431
+ }
403
432
  // yolo already applied inside checkBash/checkTool (including glob asks).
404
- return { state };
433
+ return { state: toolState };
405
434
  }
406
435
 
407
436
  isAllowed(toolName: string, subject: string, filePath?: string): boolean {
package/src/permission.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  // ask_permission + automatic gate for bash / write / edit / sudo_run / sensitive reads.
2
- // Path wildcards: most-specific wins; equal score deny.
2
+ // Path wildcards: any matching deny wins; directory allows act like scoped yolo.
3
3
  // Prompt: Allow this | Allow for this session | Allow permanently | Deny with reason
4
4
  // Bash: every unit is checked; one prompt per tool call (not per unit).
5
5
  // Session remembers all ask-bases; permanent writes the primary base only.
@@ -54,12 +54,12 @@ function blockReason(
54
54
  if (kind === "path") {
55
55
  return (
56
56
  `Blocked by path rule for \`${label}\`. ` +
57
- `Add a paths allow entry in permission.json to override.`
57
+ `Remove or narrow the matching deny in permission.json.`
58
58
  );
59
59
  }
60
60
  return (
61
61
  `Blocked by permission rule for \`${label}\`. ` +
62
- `Add an allow entry in permission.json to override.` +
62
+ `Remove or narrow the matching deny in permission.json.` +
63
63
  (extra ? ` ${extra}` : "")
64
64
  );
65
65
  }
package/src/wildcard.ts CHANGED
@@ -76,27 +76,16 @@ export function globSpecificity(pattern: string): number {
76
76
  return suffix.replace(/[*?]/g, "").length;
77
77
  }
78
78
 
79
- /**
80
- * Most-specific matching rule wins, regardless of allow/deny. On an exact
81
- * specificity tie, deny wins (fail closed). Returns undefined when nothing
82
- * matches.
83
- */
79
+ /** Any matching deny wins. Otherwise, any matching allow wins. */
84
80
  export function resolveRules(
85
81
  rules: ReadonlyArray<{ pattern: string; state: "allow" | "deny" }>,
86
82
  value: string,
87
83
  ): "allow" | "deny" | undefined {
88
- let best: { state: "allow" | "deny"; score: number } | undefined;
84
+ let allowed = false;
89
85
  for (const rule of rules) {
90
86
  if (!matchGlob(rule.pattern, value)) continue;
91
- const score = globSpecificity(rule.pattern);
92
- if (
93
- !best ||
94
- score > best.score ||
95
- // equal score → deny wins
96
- (score === best.score && rule.state === "deny")
97
- ) {
98
- best = { state: rule.state, score };
99
- }
87
+ if (rule.state === "deny") return "deny";
88
+ allowed = true;
100
89
  }
101
- return best?.state;
90
+ return allowed ? "allow" : undefined;
102
91
  }