@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 +5 -0
- package/README.md +10 -9
- package/package.json +1 -1
- package/src/bash-scan.ts +8 -0
- package/src/permission-store.ts +72 -43
- package/src/permission.ts +3 -3
- package/src/wildcard.ts +5 -16
package/CHANGELOG.md
CHANGED
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
|
|
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
|
-
**
|
|
44
|
-
|
|
45
|
-
|
|
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
|
-
|
|
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
|
|
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.
|
|
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);
|
package/src/permission-store.ts
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
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
|
-
|
|
361
|
-
const hit =
|
|
362
|
-
subjects.some((sub) => matchGlob(pattern, sub)) ||
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
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
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
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:
|
|
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
|
-
`
|
|
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
|
-
`
|
|
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
|
|
84
|
+
let allowed = false;
|
|
89
85
|
for (const rule of rules) {
|
|
90
86
|
if (!matchGlob(rule.pattern, value)) continue;
|
|
91
|
-
|
|
92
|
-
|
|
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
|
|
90
|
+
return allowed ? "allow" : undefined;
|
|
102
91
|
}
|