@gotgenes/pi-permission-system 4.8.0 → 5.0.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 +43 -0
- package/README.md +18 -5
- package/config/config.example.json +2 -0
- package/package.json +1 -1
- package/schemas/permissions.schema.json +10 -0
- package/src/config-modal.ts +25 -3
- package/src/extension-config.ts +13 -1
- package/src/external-directory.ts +96 -1
- package/src/handlers/tool-call.ts +87 -61
- package/src/index.ts +4 -0
- package/src/normalize.ts +2 -2
- package/src/permission-manager.ts +72 -17
- package/src/rule.ts +26 -2
- package/src/runtime.ts +17 -0
- package/src/session-rules.ts +7 -1
- package/src/synthesize.ts +7 -2
- package/src/tool-input-preview.ts +7 -1
- package/src/types.ts +6 -0
- package/tests/bash-external-directory.test.ts +50 -0
- package/tests/config-modal.test.ts +83 -0
- package/tests/handlers/tool-call.test.ts +149 -1
- package/tests/normalize.test.ts +64 -22
- package/tests/permission-manager-unified.test.ts +215 -0
- package/tests/permission-prompts.test.ts +8 -1
- package/tests/permission-system.test.ts +12 -0
- package/tests/pi-infrastructure-read.test.ts +245 -0
- package/tests/rule.test.ts +76 -8
- package/tests/runtime.test.ts +45 -0
- package/tests/session-rules.test.ts +7 -1
- package/tests/skill-prompt-sanitizer.test.ts +1 -1
- package/tests/synthesize.test.ts +64 -4
- package/tests/tool-input-preview.test.ts +29 -0
|
@@ -16,7 +16,7 @@ import {
|
|
|
16
16
|
import { getGlobalConfigPath } from "./config-paths";
|
|
17
17
|
import { normalizeInput } from "./input-normalizer";
|
|
18
18
|
import { normalizeFlatConfig } from "./normalize";
|
|
19
|
-
import type { Rule, Ruleset } from "./rule";
|
|
19
|
+
import type { Rule, RuleOrigin, Ruleset } from "./rule";
|
|
20
20
|
import { evaluate, evaluateFirst } from "./rule";
|
|
21
21
|
import {
|
|
22
22
|
composeRuleset,
|
|
@@ -354,19 +354,55 @@ export class PermissionManager {
|
|
|
354
354
|
const projectAgentConfig = this.loadProjectScopeConfig(agentName);
|
|
355
355
|
|
|
356
356
|
// Merge permission objects across scopes (lowest → highest precedence).
|
|
357
|
+
// Build a parallel origin map that tracks which scope contributed each
|
|
358
|
+
// (surface, pattern) entry, mirroring mergeFlatPermissions() semantics.
|
|
359
|
+
type OriginMap = Map<string, Map<string, RuleOrigin>>;
|
|
360
|
+
const origins: OriginMap = new Map();
|
|
357
361
|
let mergedPermission: FlatPermissionConfig = {};
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
362
|
+
|
|
363
|
+
for (const [scopeName, scope] of [
|
|
364
|
+
["global", globalConfig],
|
|
365
|
+
["project", projectConfig],
|
|
366
|
+
["agent", agentConfig],
|
|
367
|
+
["project-agent", projectAgentConfig],
|
|
368
|
+
] as const) {
|
|
369
|
+
if (!scope.permission) continue;
|
|
370
|
+
|
|
371
|
+
for (const [surface, value] of Object.entries(scope.permission)) {
|
|
372
|
+
const baseVal = mergedPermission[surface];
|
|
373
|
+
const bothObjects =
|
|
374
|
+
typeof baseVal === "object" &&
|
|
375
|
+
baseVal !== null &&
|
|
376
|
+
typeof value === "object" &&
|
|
377
|
+
value !== null;
|
|
378
|
+
|
|
379
|
+
if (bothObjects) {
|
|
380
|
+
// Shallow-merge: each incoming pattern is attributed to this scope;
|
|
381
|
+
// existing patterns from lower scopes keep their earlier origin.
|
|
382
|
+
if (!origins.has(surface)) origins.set(surface, new Map());
|
|
383
|
+
for (const pattern of Object.keys(value as Record<string, unknown>)) {
|
|
384
|
+
origins.get(surface)!.set(pattern, scopeName);
|
|
385
|
+
}
|
|
386
|
+
} else {
|
|
387
|
+
// Full replacement: this scope takes over the entire surface entry.
|
|
388
|
+
const surfaceOrigins = new Map<string, RuleOrigin>();
|
|
389
|
+
if (typeof value === "string") {
|
|
390
|
+
surfaceOrigins.set("*", scopeName);
|
|
391
|
+
} else if (typeof value === "object" && value !== null) {
|
|
392
|
+
for (const pattern of Object.keys(
|
|
393
|
+
value as Record<string, unknown>,
|
|
394
|
+
)) {
|
|
395
|
+
surfaceOrigins.set(pattern, scopeName);
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
origins.set(surface, surfaceOrigins);
|
|
399
|
+
}
|
|
369
400
|
}
|
|
401
|
+
|
|
402
|
+
mergedPermission = mergeFlatPermissions(
|
|
403
|
+
mergedPermission,
|
|
404
|
+
scope.permission,
|
|
405
|
+
);
|
|
370
406
|
}
|
|
371
407
|
|
|
372
408
|
// Extract the universal fallback from permission["*"].
|
|
@@ -375,19 +411,28 @@ export class PermissionManager {
|
|
|
375
411
|
const universalFallback = isPermissionState(mergedPermission["*"])
|
|
376
412
|
? (mergedPermission["*"] as PermissionState)
|
|
377
413
|
: DEFAULT_UNIVERSAL_FALLBACK;
|
|
414
|
+
// Track which scope contributed the universal fallback.
|
|
415
|
+
const universalFallbackOrigin: RuleOrigin =
|
|
416
|
+
origins.get("*")?.get("*") ?? "builtin";
|
|
378
417
|
|
|
379
418
|
// Build config rules from everything except the universal "*" key.
|
|
380
419
|
const permissionWithoutUniversal: FlatPermissionConfig = Object.fromEntries(
|
|
381
420
|
Object.entries(mergedPermission).filter(([k]) => k !== "*"),
|
|
382
421
|
);
|
|
383
422
|
|
|
384
|
-
// Normalize to config rules, tagged with "config" layer.
|
|
423
|
+
// Normalize to config rules, tagged with "config" layer and their origin.
|
|
385
424
|
const configRules: Ruleset = normalizeFlatConfig(
|
|
386
425
|
permissionWithoutUniversal,
|
|
387
|
-
).map(
|
|
426
|
+
).map(
|
|
427
|
+
(r): Rule => ({
|
|
428
|
+
...r,
|
|
429
|
+
layer: "config",
|
|
430
|
+
origin: origins.get(r.surface)?.get(r.pattern) ?? "builtin",
|
|
431
|
+
}),
|
|
432
|
+
);
|
|
388
433
|
|
|
389
434
|
const composedRules = composeRuleset(
|
|
390
|
-
synthesizeDefaults(universalFallback),
|
|
435
|
+
synthesizeDefaults(universalFallback, universalFallbackOrigin),
|
|
391
436
|
synthesizeBaseline(configRules),
|
|
392
437
|
configRules,
|
|
393
438
|
);
|
|
@@ -415,6 +460,17 @@ export class PermissionManager {
|
|
|
415
460
|
return value;
|
|
416
461
|
}
|
|
417
462
|
|
|
463
|
+
/**
|
|
464
|
+
* Return the composed config-layer rules for the given agent scope.
|
|
465
|
+
* Used by the `/permission-system show` command to display effective rules
|
|
466
|
+
* with their origin annotations.
|
|
467
|
+
* Session rules are not included — they are runtime-only.
|
|
468
|
+
*/
|
|
469
|
+
getComposedConfigRules(agentName?: string): Ruleset {
|
|
470
|
+
const { composedRules } = this.resolvePermissions(agentName);
|
|
471
|
+
return composedRules.filter((r) => r.layer === "config");
|
|
472
|
+
}
|
|
473
|
+
|
|
418
474
|
/**
|
|
419
475
|
* Get the tool-level permission state for a tool, without considering
|
|
420
476
|
* command-level rules. Used for tool injection decisions.
|
|
@@ -480,6 +536,7 @@ export class PermissionManager {
|
|
|
480
536
|
? rule.pattern
|
|
481
537
|
: undefined,
|
|
482
538
|
source: deriveSource(rule, normalizedToolName),
|
|
539
|
+
origin: rule.origin,
|
|
483
540
|
...extras,
|
|
484
541
|
};
|
|
485
542
|
}
|
|
@@ -493,7 +550,6 @@ export class PermissionManager {
|
|
|
493
550
|
*
|
|
494
551
|
* - session → "session" (always, all surfaces)
|
|
495
552
|
* - mcp + default → "default"
|
|
496
|
-
* - mcp + override → "tool"
|
|
497
553
|
* - mcp + other → "mcp"
|
|
498
554
|
* - special → "special" (always)
|
|
499
555
|
* - skill → "skill" (always)
|
|
@@ -509,7 +565,6 @@ function deriveSource(
|
|
|
509
565
|
|
|
510
566
|
if (toolName === "mcp") {
|
|
511
567
|
if (rule.layer === "default") return "default";
|
|
512
|
-
if (rule.layer === "override") return "tool";
|
|
513
568
|
return "mcp";
|
|
514
569
|
}
|
|
515
570
|
|
package/src/rule.ts
CHANGED
|
@@ -1,6 +1,23 @@
|
|
|
1
1
|
import type { PermissionState } from "./types";
|
|
2
2
|
import { wildcardMatch } from "./wildcard-matcher";
|
|
3
3
|
|
|
4
|
+
/**
|
|
5
|
+
* Provenance of a rule — which source contributed it.
|
|
6
|
+
*
|
|
7
|
+
* Config scopes: "global", "project", "agent", "project-agent".
|
|
8
|
+
* Synthesized: "builtin" (universal default / evaluate() fallback),
|
|
9
|
+
* "baseline" (conditional MCP metadata auto-allow).
|
|
10
|
+
* Runtime: "session" (session approvals).
|
|
11
|
+
*/
|
|
12
|
+
export type RuleOrigin =
|
|
13
|
+
| "global"
|
|
14
|
+
| "project"
|
|
15
|
+
| "agent"
|
|
16
|
+
| "project-agent"
|
|
17
|
+
| "builtin"
|
|
18
|
+
| "baseline"
|
|
19
|
+
| "session";
|
|
20
|
+
|
|
4
21
|
/** A single permission rule — the atomic unit of policy. */
|
|
5
22
|
export interface Rule {
|
|
6
23
|
/** The permission surface: "bash", "read", "mcp", "skill", "external_directory", etc. */
|
|
@@ -13,7 +30,9 @@ export interface Rule {
|
|
|
13
30
|
* Origin layer — used to derive PermissionCheckResult.source after evaluation.
|
|
14
31
|
* Not used by evaluate(); purely informational metadata.
|
|
15
32
|
*/
|
|
16
|
-
layer?: "default" | "
|
|
33
|
+
layer?: "default" | "baseline" | "config" | "session";
|
|
34
|
+
/** Which source contributed this rule. */
|
|
35
|
+
origin: RuleOrigin;
|
|
17
36
|
}
|
|
18
37
|
|
|
19
38
|
/** An ordered list of rules. Later rules take priority (last-match-wins). */
|
|
@@ -39,7 +58,12 @@ export function evaluate(
|
|
|
39
58
|
wildcardMatch(r.surface, surface) && wildcardMatch(r.pattern, pattern),
|
|
40
59
|
);
|
|
41
60
|
if (rule !== undefined) return rule;
|
|
42
|
-
return {
|
|
61
|
+
return {
|
|
62
|
+
surface,
|
|
63
|
+
pattern,
|
|
64
|
+
action: defaultAction ?? "ask",
|
|
65
|
+
origin: "builtin",
|
|
66
|
+
};
|
|
43
67
|
}
|
|
44
68
|
|
|
45
69
|
/**
|
package/src/runtime.ts
CHANGED
|
@@ -34,6 +34,7 @@ import {
|
|
|
34
34
|
normalizePermissionSystemConfig,
|
|
35
35
|
type PermissionSystemExtensionConfig,
|
|
36
36
|
} from "./extension-config";
|
|
37
|
+
import { discoverGlobalNodeModulesRoot } from "./external-directory";
|
|
37
38
|
import {
|
|
38
39
|
type PermissionForwardingDeps,
|
|
39
40
|
processForwardedPermissionRequests,
|
|
@@ -64,6 +65,14 @@ export interface ExtensionRuntime {
|
|
|
64
65
|
readonly subagentSessionsDir: string;
|
|
65
66
|
readonly forwardingDir: string;
|
|
66
67
|
readonly globalLogsDir: string;
|
|
68
|
+
/**
|
|
69
|
+
* Static Pi infrastructure directories used for external-directory
|
|
70
|
+
* read auto-allow. Computed once at construction from `agentDir` and
|
|
71
|
+
* `discoverGlobalNodeModulesRoot()`. Config-based extras
|
|
72
|
+
* (`piInfrastructureReadPaths`) are read from `runtime.config` at
|
|
73
|
+
* call time in the handler so they pick up config reloads.
|
|
74
|
+
*/
|
|
75
|
+
readonly piInfrastructureDirs: string[];
|
|
67
76
|
|
|
68
77
|
// ── Mutable state ──────────────────────────────────────────────────────
|
|
69
78
|
config: PermissionSystemExtensionConfig;
|
|
@@ -341,6 +350,13 @@ export function createExtensionRuntime(options?: {
|
|
|
341
350
|
const forwardingDir = join(sessionsDir, "permission-forwarding");
|
|
342
351
|
const globalLogsDir = getGlobalLogsDir(agentDir);
|
|
343
352
|
|
|
353
|
+
const globalNodeModulesRoot = discoverGlobalNodeModulesRoot();
|
|
354
|
+
const piInfrastructureDirs: string[] = [
|
|
355
|
+
agentDir,
|
|
356
|
+
join(agentDir, "git"),
|
|
357
|
+
...(globalNodeModulesRoot ? [globalNodeModulesRoot] : []),
|
|
358
|
+
];
|
|
359
|
+
|
|
344
360
|
// Build a plain-object runtime first so the logger's `getConfig` closure
|
|
345
361
|
// can reference `runtime.config` directly (always reads current value).
|
|
346
362
|
const runtime: ExtensionRuntime = {
|
|
@@ -349,6 +365,7 @@ export function createExtensionRuntime(options?: {
|
|
|
349
365
|
subagentSessionsDir,
|
|
350
366
|
forwardingDir,
|
|
351
367
|
globalLogsDir,
|
|
368
|
+
piInfrastructureDirs,
|
|
352
369
|
config: { ...DEFAULT_EXTENSION_CONFIG },
|
|
353
370
|
runtimeContext: null,
|
|
354
371
|
permissionManager: createPermissionManagerForCwd(agentDir, undefined),
|
package/src/session-rules.ts
CHANGED
|
@@ -15,7 +15,13 @@ export class SessionRules {
|
|
|
15
15
|
|
|
16
16
|
/** Record a wildcard pattern as approved for the given surface. */
|
|
17
17
|
approve(surface: string, pattern: string): void {
|
|
18
|
-
this.rules.push({
|
|
18
|
+
this.rules.push({
|
|
19
|
+
surface,
|
|
20
|
+
pattern,
|
|
21
|
+
action: "allow",
|
|
22
|
+
layer: "session",
|
|
23
|
+
origin: "session",
|
|
24
|
+
});
|
|
19
25
|
}
|
|
20
26
|
|
|
21
27
|
/** Return a defensive copy of the current session ruleset. */
|
package/src/synthesize.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { Rule, Ruleset } from "./rule";
|
|
1
|
+
import type { Rule, RuleOrigin, Ruleset } from "./rule";
|
|
2
2
|
import type { PermissionState } from "./types";
|
|
3
3
|
|
|
4
4
|
/**
|
|
@@ -11,13 +11,17 @@ import type { PermissionState } from "./types";
|
|
|
11
11
|
* regular config rules from `normalizeFlatConfig()` and sit at higher indices
|
|
12
12
|
* in the composed array, so they override this default via last-match-wins.
|
|
13
13
|
*/
|
|
14
|
-
export function synthesizeDefaults(
|
|
14
|
+
export function synthesizeDefaults(
|
|
15
|
+
universalDefault: PermissionState,
|
|
16
|
+
origin: RuleOrigin = "builtin",
|
|
17
|
+
): Ruleset {
|
|
15
18
|
return [
|
|
16
19
|
{
|
|
17
20
|
surface: "*",
|
|
18
21
|
pattern: "*",
|
|
19
22
|
action: universalDefault,
|
|
20
23
|
layer: "default",
|
|
24
|
+
origin,
|
|
21
25
|
},
|
|
22
26
|
];
|
|
23
27
|
}
|
|
@@ -63,6 +67,7 @@ export function synthesizeBaseline(configRules: Ruleset): Ruleset {
|
|
|
63
67
|
pattern: target,
|
|
64
68
|
action: "allow",
|
|
65
69
|
layer: "baseline",
|
|
70
|
+
origin: "baseline",
|
|
66
71
|
}),
|
|
67
72
|
);
|
|
68
73
|
}
|
|
@@ -193,7 +193,12 @@ export function getPermissionLogContext(
|
|
|
193
193
|
result: PermissionCheckResult,
|
|
194
194
|
input: unknown,
|
|
195
195
|
pathBearingTools: ReadonlySet<string>,
|
|
196
|
-
): {
|
|
196
|
+
): {
|
|
197
|
+
command?: string;
|
|
198
|
+
target?: string;
|
|
199
|
+
toolInputPreview?: string;
|
|
200
|
+
origin?: string;
|
|
201
|
+
} {
|
|
197
202
|
return {
|
|
198
203
|
command: result.command,
|
|
199
204
|
target: result.target,
|
|
@@ -202,5 +207,6 @@ export function getPermissionLogContext(
|
|
|
202
207
|
input,
|
|
203
208
|
pathBearingTools,
|
|
204
209
|
),
|
|
210
|
+
origin: result.origin,
|
|
205
211
|
};
|
|
206
212
|
}
|
package/src/types.ts
CHANGED
|
@@ -1,5 +1,9 @@
|
|
|
1
1
|
export type PermissionState = "allow" | "deny" | "ask";
|
|
2
2
|
|
|
3
|
+
import type { RuleOrigin } from "./rule";
|
|
4
|
+
|
|
5
|
+
export type { RuleOrigin };
|
|
6
|
+
|
|
3
7
|
/**
|
|
4
8
|
* The on-disk permission shape inside the `"permission"` key.
|
|
5
9
|
* Each key is a surface name; values are either a PermissionState string
|
|
@@ -36,4 +40,6 @@ export interface PermissionCheckResult {
|
|
|
36
40
|
command?: string;
|
|
37
41
|
target?: string;
|
|
38
42
|
source: "tool" | "bash" | "mcp" | "skill" | "special" | "default" | "session";
|
|
43
|
+
/** Which source contributed the winning rule. */
|
|
44
|
+
origin: RuleOrigin;
|
|
39
45
|
}
|
|
@@ -544,6 +544,56 @@ describe("extractExternalPathsFromBashCommand", () => {
|
|
|
544
544
|
expect(etcHostsCount).toBe(1);
|
|
545
545
|
});
|
|
546
546
|
});
|
|
547
|
+
|
|
548
|
+
describe("regex patterns are not mistaken for paths", () => {
|
|
549
|
+
test("grep -v with //.*pattern is not flagged", async () => {
|
|
550
|
+
const result = await extractExternalPathsFromBashCommand(
|
|
551
|
+
'grep -n "glob" src/foo.ts 2>/dev/null | grep -v "//.*glob\\|globalConfig" | head -30',
|
|
552
|
+
cwd,
|
|
553
|
+
);
|
|
554
|
+
expect(result).toHaveLength(0);
|
|
555
|
+
});
|
|
556
|
+
|
|
557
|
+
test("grep -v with //.*pattern without backslash-pipe is not flagged", async () => {
|
|
558
|
+
const result = await extractExternalPathsFromBashCommand(
|
|
559
|
+
'grep -v "//.*foo" file.txt',
|
|
560
|
+
cwd,
|
|
561
|
+
);
|
|
562
|
+
expect(result).toHaveLength(0);
|
|
563
|
+
});
|
|
564
|
+
|
|
565
|
+
test("grep with backslash-pipe alternation is not flagged", async () => {
|
|
566
|
+
const result = await extractExternalPathsFromBashCommand(
|
|
567
|
+
'grep "foo\\|bar\\|baz" src/file.ts',
|
|
568
|
+
cwd,
|
|
569
|
+
);
|
|
570
|
+
expect(result).toHaveLength(0);
|
|
571
|
+
});
|
|
572
|
+
|
|
573
|
+
test("grep -E with ^/ anchored regex is not flagged", async () => {
|
|
574
|
+
const result = await extractExternalPathsFromBashCommand(
|
|
575
|
+
'grep -E "^/usr/bin" file.txt',
|
|
576
|
+
cwd,
|
|
577
|
+
);
|
|
578
|
+
expect(result).toHaveLength(0);
|
|
579
|
+
});
|
|
580
|
+
|
|
581
|
+
test("sed with regex containing slashes is not flagged", async () => {
|
|
582
|
+
const result = await extractExternalPathsFromBashCommand(
|
|
583
|
+
'sed "s/foo.*/bar/g" file.txt',
|
|
584
|
+
cwd,
|
|
585
|
+
);
|
|
586
|
+
expect(result).toHaveLength(0);
|
|
587
|
+
});
|
|
588
|
+
|
|
589
|
+
test("real external paths are still detected alongside regex args", async () => {
|
|
590
|
+
const result = await extractExternalPathsFromBashCommand(
|
|
591
|
+
'grep -v "//.*pattern" /etc/hosts',
|
|
592
|
+
cwd,
|
|
593
|
+
);
|
|
594
|
+
expect(result).toContain("/etc/hosts");
|
|
595
|
+
});
|
|
596
|
+
});
|
|
547
597
|
});
|
|
548
598
|
|
|
549
599
|
describe("formatBashExternalDirectoryAskPrompt", () => {
|
|
@@ -10,6 +10,7 @@ import {
|
|
|
10
10
|
type PermissionSystemExtensionConfig,
|
|
11
11
|
savePermissionSystemConfig,
|
|
12
12
|
} from "../src/extension-config";
|
|
13
|
+
import type { Rule } from "../src/rule";
|
|
13
14
|
|
|
14
15
|
vi.mock("@mariozechner/pi-coding-agent", () => ({
|
|
15
16
|
getSettingsListTheme: () => ({}),
|
|
@@ -234,3 +235,85 @@ test("permission-system command handlers manage config summary, persistence, and
|
|
|
234
235
|
rmSync(baseDir, { recursive: true, force: true });
|
|
235
236
|
}
|
|
236
237
|
});
|
|
238
|
+
|
|
239
|
+
test("show output includes rule origins when getComposedRules is provided", async () => {
|
|
240
|
+
const config = { ...DEFAULT_EXTENSION_CONFIG };
|
|
241
|
+
const composedRules: Rule[] = [
|
|
242
|
+
{
|
|
243
|
+
surface: "read",
|
|
244
|
+
pattern: "*",
|
|
245
|
+
action: "allow",
|
|
246
|
+
layer: "config",
|
|
247
|
+
origin: "global",
|
|
248
|
+
},
|
|
249
|
+
{
|
|
250
|
+
surface: "bash",
|
|
251
|
+
pattern: "rm *",
|
|
252
|
+
action: "deny",
|
|
253
|
+
layer: "config",
|
|
254
|
+
origin: "project",
|
|
255
|
+
},
|
|
256
|
+
];
|
|
257
|
+
|
|
258
|
+
const controller = {
|
|
259
|
+
getConfig: () => config,
|
|
260
|
+
setConfig: () => {},
|
|
261
|
+
getConfigPath: () => "/fake/config.json",
|
|
262
|
+
getComposedRules: () => composedRules,
|
|
263
|
+
};
|
|
264
|
+
|
|
265
|
+
let definition: {
|
|
266
|
+
handler: (args: string, ctx: CommandContextStub) => Promise<void>;
|
|
267
|
+
} | null = null;
|
|
268
|
+
|
|
269
|
+
registerPermissionSystemCommand(
|
|
270
|
+
{
|
|
271
|
+
registerCommand(_name: string, nextDef: typeof definition) {
|
|
272
|
+
definition = nextDef;
|
|
273
|
+
},
|
|
274
|
+
} as never,
|
|
275
|
+
controller as never,
|
|
276
|
+
);
|
|
277
|
+
|
|
278
|
+
const ctx = createCommandContext(true);
|
|
279
|
+
await definition!.handler("show", ctx.ctx);
|
|
280
|
+
const msg = lastNotification(ctx.notifications).message;
|
|
281
|
+
|
|
282
|
+
assert.ok(msg.includes("global"), `expected 'global' in: ${msg}`);
|
|
283
|
+
assert.ok(msg.includes("project"), `expected 'project' in: ${msg}`);
|
|
284
|
+
assert.ok(msg.includes("read"), `expected 'read' in: ${msg}`);
|
|
285
|
+
assert.ok(msg.includes("bash"), `expected 'bash' in: ${msg}`);
|
|
286
|
+
});
|
|
287
|
+
|
|
288
|
+
test("show output omits rule summary when getComposedRules is not provided", async () => {
|
|
289
|
+
const config = { ...DEFAULT_EXTENSION_CONFIG, yoloMode: true };
|
|
290
|
+
|
|
291
|
+
const controller = {
|
|
292
|
+
getConfig: () => config,
|
|
293
|
+
setConfig: () => {},
|
|
294
|
+
getConfigPath: () => "/fake/config.json",
|
|
295
|
+
// no getComposedRules
|
|
296
|
+
};
|
|
297
|
+
|
|
298
|
+
let definition: {
|
|
299
|
+
handler: (args: string, ctx: CommandContextStub) => Promise<void>;
|
|
300
|
+
} | null = null;
|
|
301
|
+
|
|
302
|
+
registerPermissionSystemCommand(
|
|
303
|
+
{
|
|
304
|
+
registerCommand(_name: string, nextDef: typeof definition) {
|
|
305
|
+
definition = nextDef;
|
|
306
|
+
},
|
|
307
|
+
} as never,
|
|
308
|
+
controller as never,
|
|
309
|
+
);
|
|
310
|
+
|
|
311
|
+
const ctx = createCommandContext(true);
|
|
312
|
+
await definition!.handler("show", ctx.ctx);
|
|
313
|
+
const msg = lastNotification(ctx.notifications).message;
|
|
314
|
+
|
|
315
|
+
// Config knobs still present.
|
|
316
|
+
assert.ok(msg.includes("yoloMode=on"), `expected yoloMode=on in: ${msg}`);
|
|
317
|
+
// No rule annotation lines.
|
|
318
|
+
assert.ok(!msg.includes("(global)"), `unexpected '(global)' in: ${msg}`);
|
|
319
|
+
});
|
|
@@ -52,7 +52,7 @@ function makeToolCallEvent(
|
|
|
52
52
|
function makePermissionResult(
|
|
53
53
|
state: "allow" | "deny" | "ask",
|
|
54
54
|
): PermissionCheckResult {
|
|
55
|
-
return { state, toolName: "read", source: "tool" };
|
|
55
|
+
return { state, toolName: "read", source: "tool", origin: "builtin" };
|
|
56
56
|
}
|
|
57
57
|
|
|
58
58
|
function makeRuntime(
|
|
@@ -64,6 +64,7 @@ function makeRuntime(
|
|
|
64
64
|
subagentSessionsDir: "/test/agent/subagent-sessions",
|
|
65
65
|
forwardingDir: "/test/agent/sessions/permission-forwarding",
|
|
66
66
|
globalLogsDir: "/test/agent/extensions/pi-permission-system/logs",
|
|
67
|
+
piInfrastructureDirs: ["/test/agent", "/test/agent/git"],
|
|
67
68
|
config: { debugLog: false, permissionReviewLog: true, yoloMode: false },
|
|
68
69
|
runtimeContext: null,
|
|
69
70
|
permissionManager: {
|
|
@@ -395,6 +396,152 @@ describe("handleToolCall — external-directory gate", () => {
|
|
|
395
396
|
});
|
|
396
397
|
});
|
|
397
398
|
|
|
399
|
+
// ── Pi infrastructure read bypass ───────────────────────────────────────────
|
|
400
|
+
|
|
401
|
+
describe("handleToolCall — Pi infrastructure read bypass", () => {
|
|
402
|
+
const infraPath = "/test/agent/git/some-package/SKILL.md";
|
|
403
|
+
|
|
404
|
+
it("skips external-directory gate for read tool targeting an infra dir", async () => {
|
|
405
|
+
const deps = makeDeps({
|
|
406
|
+
runtime: makeRuntime({
|
|
407
|
+
permissionManager: {
|
|
408
|
+
checkPermission: vi
|
|
409
|
+
.fn()
|
|
410
|
+
.mockReturnValue(makePermissionResult("allow")),
|
|
411
|
+
} as unknown as ExtensionRuntime["permissionManager"],
|
|
412
|
+
}),
|
|
413
|
+
getAllTools: vi.fn().mockReturnValue([{ name: "read" }]),
|
|
414
|
+
});
|
|
415
|
+
const event = {
|
|
416
|
+
type: "tool_call",
|
|
417
|
+
toolCallId: "tc-infra-read",
|
|
418
|
+
name: "read",
|
|
419
|
+
input: { path: infraPath },
|
|
420
|
+
};
|
|
421
|
+
const result = await handleToolCall(deps, event, makeCtx());
|
|
422
|
+
expect(result).toEqual({});
|
|
423
|
+
// external_directory permission check must NOT have been called.
|
|
424
|
+
const checkPermission = deps.runtime.permissionManager
|
|
425
|
+
.checkPermission as ReturnType<typeof vi.fn>;
|
|
426
|
+
const calls = checkPermission.mock.calls as Array<[string, ...unknown[]]>;
|
|
427
|
+
const extDirCalls = calls.filter(
|
|
428
|
+
([surface]) => surface === "external_directory",
|
|
429
|
+
);
|
|
430
|
+
expect(extDirCalls).toHaveLength(0);
|
|
431
|
+
});
|
|
432
|
+
|
|
433
|
+
it("does NOT skip gate for write tool targeting an infra dir", async () => {
|
|
434
|
+
const deps = makeDeps({
|
|
435
|
+
runtime: makeRuntime({
|
|
436
|
+
permissionManager: {
|
|
437
|
+
checkPermission: vi
|
|
438
|
+
.fn()
|
|
439
|
+
.mockReturnValue(makePermissionResult("deny")),
|
|
440
|
+
} as unknown as ExtensionRuntime["permissionManager"],
|
|
441
|
+
}),
|
|
442
|
+
getAllTools: vi.fn().mockReturnValue([{ name: "write" }]),
|
|
443
|
+
});
|
|
444
|
+
const event = {
|
|
445
|
+
type: "tool_call",
|
|
446
|
+
toolCallId: "tc-infra-write",
|
|
447
|
+
name: "write",
|
|
448
|
+
input: { path: infraPath },
|
|
449
|
+
};
|
|
450
|
+
const result = await handleToolCall(deps, event, makeCtx());
|
|
451
|
+
expect(result).toMatchObject({ block: true });
|
|
452
|
+
const checkPermission = deps.runtime.permissionManager
|
|
453
|
+
.checkPermission as ReturnType<typeof vi.fn>;
|
|
454
|
+
const calls = checkPermission.mock.calls as Array<[string, ...unknown[]]>;
|
|
455
|
+
const extDirCalls = calls.filter(
|
|
456
|
+
([surface]) => surface === "external_directory",
|
|
457
|
+
);
|
|
458
|
+
expect(extDirCalls.length).toBeGreaterThan(0);
|
|
459
|
+
});
|
|
460
|
+
|
|
461
|
+
it("does NOT skip gate for read tool targeting a non-infra external path", async () => {
|
|
462
|
+
const deps = makeDeps({
|
|
463
|
+
runtime: makeRuntime({
|
|
464
|
+
permissionManager: {
|
|
465
|
+
checkPermission: vi
|
|
466
|
+
.fn()
|
|
467
|
+
.mockReturnValue(makePermissionResult("deny")),
|
|
468
|
+
} as unknown as ExtensionRuntime["permissionManager"],
|
|
469
|
+
}),
|
|
470
|
+
getAllTools: vi.fn().mockReturnValue([{ name: "read" }]),
|
|
471
|
+
});
|
|
472
|
+
const event = {
|
|
473
|
+
type: "tool_call",
|
|
474
|
+
toolCallId: "tc-non-infra",
|
|
475
|
+
name: "read",
|
|
476
|
+
input: { path: "/etc/passwd" },
|
|
477
|
+
};
|
|
478
|
+
const result = await handleToolCall(deps, event, makeCtx());
|
|
479
|
+
expect(result).toMatchObject({ block: true });
|
|
480
|
+
const checkPermission = deps.runtime.permissionManager
|
|
481
|
+
.checkPermission as ReturnType<typeof vi.fn>;
|
|
482
|
+
const calls = checkPermission.mock.calls as Array<[string, ...unknown[]]>;
|
|
483
|
+
const extDirCalls = calls.filter(
|
|
484
|
+
([surface]) => surface === "external_directory",
|
|
485
|
+
);
|
|
486
|
+
expect(extDirCalls.length).toBeGreaterThan(0);
|
|
487
|
+
});
|
|
488
|
+
|
|
489
|
+
it("writes a review log entry when bypassing the gate", async () => {
|
|
490
|
+
const writeReviewLog = vi.fn();
|
|
491
|
+
const deps = makeDeps({
|
|
492
|
+
runtime: makeRuntime({ writeReviewLog }),
|
|
493
|
+
getAllTools: vi.fn().mockReturnValue([{ name: "read" }]),
|
|
494
|
+
});
|
|
495
|
+
const event = {
|
|
496
|
+
type: "tool_call",
|
|
497
|
+
toolCallId: "tc-infra-log",
|
|
498
|
+
name: "read",
|
|
499
|
+
input: { path: infraPath },
|
|
500
|
+
};
|
|
501
|
+
await handleToolCall(deps, event, makeCtx());
|
|
502
|
+
expect(writeReviewLog).toHaveBeenCalledWith(
|
|
503
|
+
"permission_request.infrastructure_auto_allowed",
|
|
504
|
+
expect.objectContaining({ toolName: "read", path: infraPath }),
|
|
505
|
+
);
|
|
506
|
+
});
|
|
507
|
+
|
|
508
|
+
it("respects config piInfrastructureReadPaths for bypass", async () => {
|
|
509
|
+
const customInfraPath = "/custom/infra/packages/SKILL.md";
|
|
510
|
+
const deps = makeDeps({
|
|
511
|
+
runtime: makeRuntime({
|
|
512
|
+
piInfrastructureDirs: [],
|
|
513
|
+
config: {
|
|
514
|
+
debugLog: false,
|
|
515
|
+
permissionReviewLog: true,
|
|
516
|
+
yoloMode: false,
|
|
517
|
+
piInfrastructureReadPaths: ["/custom/infra/packages"],
|
|
518
|
+
},
|
|
519
|
+
permissionManager: {
|
|
520
|
+
checkPermission: vi
|
|
521
|
+
.fn()
|
|
522
|
+
.mockReturnValue(makePermissionResult("allow")),
|
|
523
|
+
} as unknown as ExtensionRuntime["permissionManager"],
|
|
524
|
+
}),
|
|
525
|
+
getAllTools: vi.fn().mockReturnValue([{ name: "read" }]),
|
|
526
|
+
});
|
|
527
|
+
const event = {
|
|
528
|
+
type: "tool_call",
|
|
529
|
+
toolCallId: "tc-config-infra",
|
|
530
|
+
name: "read",
|
|
531
|
+
input: { path: customInfraPath },
|
|
532
|
+
};
|
|
533
|
+
const result = await handleToolCall(deps, event, makeCtx());
|
|
534
|
+
expect(result).toEqual({});
|
|
535
|
+
const checkPermission = deps.runtime.permissionManager
|
|
536
|
+
.checkPermission as ReturnType<typeof vi.fn>;
|
|
537
|
+
const calls = checkPermission.mock.calls as Array<[string, ...unknown[]]>;
|
|
538
|
+
const extDirCalls = calls.filter(
|
|
539
|
+
([surface]) => surface === "external_directory",
|
|
540
|
+
);
|
|
541
|
+
expect(extDirCalls).toHaveLength(0);
|
|
542
|
+
});
|
|
543
|
+
});
|
|
544
|
+
|
|
398
545
|
// ── bash external-directory gate ──────────────────────────────────────────
|
|
399
546
|
|
|
400
547
|
describe("handleToolCall — bash external-directory gate", () => {
|
|
@@ -614,6 +761,7 @@ describe("handleToolCall — session recording on approved_for_session", () => {
|
|
|
614
761
|
state: "ask",
|
|
615
762
|
toolName: "read",
|
|
616
763
|
source: "tool",
|
|
764
|
+
origin: "builtin",
|
|
617
765
|
}),
|
|
618
766
|
} as unknown as ExtensionRuntime["permissionManager"],
|
|
619
767
|
sessionRules,
|