pi-permission-system 0.5.0 → 0.6.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
@@ -7,6 +7,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.6.0] - 2026-05-26
11
+
12
+ ### Added
13
+ - Added `hasAllowedSkills()` method to `PermissionManager` that checks whether the resolved permission config has any explicitly allowed skills, returning true when the default skills policy is not "deny" or at least one individual skill entry has state "allow".
14
+ - Added skill-scoped `read` tool exception so the `read` tool remains exposed to agents with explicitly allowed skills even when the `read` tool-level permission is "deny", enabling skill file access without granting unrestricted read access.
15
+
16
+ ### Changed
17
+ - Widened Pi peer dependency ranges to `^0.74.0 || ^0.75.0` and bumped dev dependencies to `^0.75.5`.
18
+
10
19
  ## [0.5.0] - 2026-05-22
11
20
 
12
21
  ### Added
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-permission-system",
3
- "version": "0.5.0",
3
+ "version": "0.6.0",
4
4
  "description": "Permission enforcement extension for the Pi coding agent.",
5
5
  "type": "module",
6
6
  "main": "./index.ts",
@@ -61,9 +61,9 @@
61
61
  },
62
62
  "peerDependencies": {
63
63
  "@sinclair/typebox": "^0.34.49",
64
- "@earendil-works/pi-ai": "^0.75.4",
65
- "@earendil-works/pi-coding-agent": "^0.75.4",
66
- "@earendil-works/pi-tui": "^0.75.4"
64
+ "@earendil-works/pi-ai": "^0.74.0 || ^0.75.0",
65
+ "@earendil-works/pi-coding-agent": "^0.74.0 || ^0.75.0",
66
+ "@earendil-works/pi-tui": "^0.74.0 || ^0.75.0"
67
67
  },
68
68
  "dependencies": {
69
69
  "jsonc-parser": "^3.3.1"
package/src/index.ts CHANGED
@@ -562,7 +562,7 @@ function formatSkillPathAskPrompt(skill: SkillPromptEntry, readPath: string, age
562
562
 
563
563
  function formatSkillPathDenyReason(skill: SkillPromptEntry, readPath: string, agentName?: string): string {
564
564
  const subject = agentName ? `Agent '${agentName}'` : "Current agent";
565
- return `${subject} is not permitted to access skill '${skill.name}' via '${readPath}'.`;
565
+ return `${subject} is not permitted to access this skill.`;
566
566
  }
567
567
 
568
568
  function extractSkillNameUnderRoot(normalizedReadPath: string, normalizedSkillsRoot: string): string | null {
@@ -1734,7 +1734,18 @@ export default function piPermissionSystemExtension(pi: ExtensionAPI): void {
1734
1734
  // This ensures that agent-specific tool deny rules (e.g., bash: deny) are respected
1735
1735
  // before any command-level permissions are considered
1736
1736
  const toolPermission = permissionManager.getToolPermission(toolName, agentName ?? undefined);
1737
- return toolPermission !== "deny";
1737
+ if (toolPermission !== "deny") {
1738
+ return true;
1739
+ }
1740
+
1741
+ // If the read tool is denied but the agent has explicitly allowed skills,
1742
+ // expose read anyway so the agent can read skill files. The tool_call handler
1743
+ // will restrict reads to skill paths only.
1744
+ if (toolName === "read" && permissionManager.hasAllowedSkills(agentName ?? undefined)) {
1745
+ return true;
1746
+ }
1747
+
1748
+ return false;
1738
1749
  };
1739
1750
 
1740
1751
  const refreshSessionRuntimeState = (ctx: ExtensionContext): void => {
@@ -1915,7 +1926,7 @@ export default function piPermissionSystemExtension(pi: ExtensionAPI): void {
1915
1926
  });
1916
1927
  return {
1917
1928
  block: true,
1918
- reason: `Accessing skill '${readSkill.name}' requires approval, but no interactive UI is available.`,
1929
+ reason: `Accessing this skill requires approval, but no interactive UI is available.`,
1919
1930
  };
1920
1931
  }
1921
1932
 
@@ -1931,10 +1942,14 @@ export default function piPermissionSystemExtension(pi: ExtensionAPI): void {
1931
1942
  });
1932
1943
  if (!decision.approved) {
1933
1944
  const denialReason = decision.denialReason ? ` Reason: ${decision.denialReason}.` : "";
1934
- return { block: true, reason: `User denied access to skill '${readSkill.name}'.${denialReason}` };
1945
+ return { block: true, reason: `User denied access to this skill.${denialReason}` };
1935
1946
  }
1936
1947
  }
1937
1948
  }
1949
+
1950
+ if (readSkill) {
1951
+ return {};
1952
+ }
1938
1953
  }
1939
1954
 
1940
1955
  const input = getEventInput(event);
@@ -732,6 +732,25 @@ export class PermissionManager {
732
732
  return merged.bash || {};
733
733
  }
734
734
 
735
+ /**
736
+ * Check whether the resolved permission config has any explicitly allowed skills.
737
+ * Used to decide if path-bearing tools like `read` should remain exposed to an agent
738
+ * even when the tool-level permission is `deny`, so the agent can read skill files.
739
+ *
740
+ * Returns true when any of these conditions holds:
741
+ * - The default skills policy is not "deny" (allows all skills by default)
742
+ * - At least one individual skill entry has state "allow"
743
+ */
744
+ hasAllowedSkills(agentName?: string): boolean {
745
+ const { merged } = this.resolvePermissions(agentName);
746
+ const defaultPolicy = merged.defaultPolicy.skills;
747
+ if (defaultPolicy !== "deny") {
748
+ return true;
749
+ }
750
+ const skillsRecord = merged.skills || {};
751
+ return Object.values(skillsRecord).some((state) => state === "allow");
752
+ }
753
+
735
754
  private getConfiguredMcpServerNames(): readonly string[] {
736
755
  if (this.configuredMcpServerNamesOverride) {
737
756
  return this.configuredMcpServerNamesOverride;
@@ -1465,6 +1465,85 @@ permission:
1465
1465
  }
1466
1466
  });
1467
1467
 
1468
+ runTest("hasAllowedSkills detects explicitly allowed skills", () => {
1469
+ // Agent with specific allowed skills
1470
+ const { manager, cleanup } = createManager(
1471
+ {
1472
+ defaultPolicy: { tools: "ask", bash: "ask", mcp: "ask", skills: "deny", special: "ask" },
1473
+ skills: { "allowed-skill": "allow" },
1474
+ },
1475
+ {},
1476
+ );
1477
+
1478
+ try {
1479
+ assert.equal(manager.hasAllowedSkills(), true);
1480
+ } finally {
1481
+ cleanup();
1482
+ }
1483
+ });
1484
+
1485
+ runTest("hasAllowedSkills returns false when no skills are allowed", () => {
1486
+ const { manager, cleanup } = createManager(
1487
+ {
1488
+ defaultPolicy: { tools: "ask", bash: "ask", mcp: "ask", skills: "deny", special: "ask" },
1489
+ },
1490
+ {},
1491
+ );
1492
+
1493
+ try {
1494
+ assert.equal(manager.hasAllowedSkills(), false);
1495
+ } finally {
1496
+ cleanup();
1497
+ }
1498
+ });
1499
+
1500
+ runTest("hasAllowedSkills returns true when default skills policy is not deny", () => {
1501
+ const { manager, cleanup } = createManager(
1502
+ {
1503
+ defaultPolicy: { tools: "ask", bash: "ask", mcp: "ask", skills: "allow", special: "ask" },
1504
+ },
1505
+ {},
1506
+ );
1507
+
1508
+ try {
1509
+ assert.equal(manager.hasAllowedSkills(), true);
1510
+ } finally {
1511
+ cleanup();
1512
+ }
1513
+ });
1514
+
1515
+ runTest("hasAllowedSkills respects per-agent skill allow overrides", () => {
1516
+ const { manager, cleanup } = createManager(
1517
+ {
1518
+ defaultPolicy: { tools: "ask", bash: "ask", mcp: "ask", skills: "deny", special: "ask" },
1519
+ skills: { "*": "deny", "reviewer-skill": "allow" },
1520
+ },
1521
+ {},
1522
+ );
1523
+
1524
+ try {
1525
+ assert.equal(manager.hasAllowedSkills(), true);
1526
+ } finally {
1527
+ cleanup();
1528
+ }
1529
+ });
1530
+
1531
+ runTest("hasAllowedSkills returns false for agent with all denied skills", () => {
1532
+ const { manager, cleanup } = createManager(
1533
+ {
1534
+ defaultPolicy: { tools: "ask", bash: "ask", mcp: "ask", skills: "deny", special: "ask" },
1535
+ skills: { "*": "deny" },
1536
+ },
1537
+ {},
1538
+ );
1539
+
1540
+ try {
1541
+ assert.equal(manager.hasAllowedSkills(), false);
1542
+ } finally {
1543
+ cleanup();
1544
+ }
1545
+ });
1546
+
1468
1547
  runTest("getToolPermission supports arbitrary extension tool names", () => {
1469
1548
  const { manager, cleanup } = createManager({
1470
1549
  defaultPolicy: {
@@ -2257,7 +2336,7 @@ await runAsyncTest("tool_call blocks direct reads of denied skill files even whe
2257
2336
  });
2258
2337
 
2259
2338
  assert.equal(result.block, true);
2260
- assert.match(String(result.reason), /not permitted to access skill 'blocked-skill'/);
2339
+ assert.match(String(result.reason), /not permitted to access this skill/);
2261
2340
  } finally {
2262
2341
  await harness.cleanup();
2263
2342
  }
@@ -2296,7 +2375,7 @@ await runAsyncTest("tool_call blocks reads below denied skill directories", asyn
2296
2375
  });
2297
2376
 
2298
2377
  assert.equal(result.block, true);
2299
- assert.match(String(result.reason), /not permitted to access skill 'blocked-skill'/);
2378
+ assert.match(String(result.reason), /not permitted to access this skill/);
2300
2379
  } finally {
2301
2380
  await harness.cleanup();
2302
2381
  }
@@ -2323,7 +2402,7 @@ await runAsyncTest("tool_call blocks project skill reads even when the skill was
2323
2402
  });
2324
2403
 
2325
2404
  assert.equal(result.block, true);
2326
- assert.match(String(result.reason), /not permitted to access skill 'hidden-skill'/);
2405
+ assert.match(String(result.reason), /not permitted to access this skill/);
2327
2406
  } finally {
2328
2407
  await harness.cleanup();
2329
2408
  }
@@ -2366,6 +2445,108 @@ await runAsyncTest("tool_call still allows reads for explicitly allowed skill fi
2366
2445
  }
2367
2446
  });
2368
2447
 
2448
+ await runAsyncTest("tool_call allows read of allowed skill even when read tool is deny", async () => {
2449
+ const harness = createToolCallHarness(
2450
+ {
2451
+ defaultPolicy: { tools: "allow", bash: "ask", mcp: "ask", skills: "deny", special: "allow" },
2452
+ tools: { read: "deny" },
2453
+ skills: { "allowed-skill": "allow" },
2454
+ },
2455
+ ["read"],
2456
+ );
2457
+ const allowedSkillPath = join(harness.cwd, "skills", "allowed", "SKILL.md");
2458
+ const prompt = [
2459
+ '<active_agent name="orchestrator" mode="direct">',
2460
+ "<available_skills>",
2461
+ " <skill>",
2462
+ " <name>allowed-skill</name>",
2463
+ " <description>Allowed skill</description>",
2464
+ ` <location>${allowedSkillPath}</location>`,
2465
+ " </skill>",
2466
+ "</available_skills>",
2467
+ ].join("\n");
2468
+
2469
+ try {
2470
+ const ctx = createMockContext(harness.cwd, harness.prompts);
2471
+ const startResult = await Promise.resolve(harness.handlers.before_agent_start?.({ systemPrompt: prompt }, ctx)) as Record<string, unknown> | undefined;
2472
+ assert.equal(String(startResult?.systemPrompt ?? prompt).includes("allowed-skill"), true);
2473
+
2474
+ const result = await runToolCall(harness, {
2475
+ toolName: "read",
2476
+ toolCallId: "skill-read-overrides-read-deny",
2477
+ input: { path: allowedSkillPath },
2478
+ });
2479
+
2480
+ assert.deepEqual(result, {});
2481
+ } finally {
2482
+ await harness.cleanup();
2483
+ }
2484
+ });
2485
+
2486
+ await runAsyncTest("tool_call allows read of allowed skill even when tools default policy is deny", async () => {
2487
+ const harness = createToolCallHarness(
2488
+ {
2489
+ defaultPolicy: { tools: "deny", bash: "ask", mcp: "ask", skills: "deny", special: "allow" },
2490
+ skills: { "allowed-skill": "allow" },
2491
+ },
2492
+ ["read"],
2493
+ );
2494
+ const allowedSkillPath = join(harness.cwd, "skills", "allowed", "SKILL.md");
2495
+ const prompt = [
2496
+ '<active_agent name="orchestrator" mode="direct">',
2497
+ "<available_skills>",
2498
+ " <skill>",
2499
+ " <name>allowed-skill</name>",
2500
+ " <description>Allowed skill</description>",
2501
+ ` <location>${allowedSkillPath}</location>`,
2502
+ " </skill>",
2503
+ "</available_skills>",
2504
+ ].join("\n");
2505
+
2506
+ try {
2507
+ const ctx = createMockContext(harness.cwd, harness.prompts);
2508
+ const startResult = await Promise.resolve(harness.handlers.before_agent_start?.({ systemPrompt: prompt }, ctx)) as Record<string, unknown> | undefined;
2509
+ assert.equal(String(startResult?.systemPrompt ?? prompt).includes("allowed-skill"), true);
2510
+
2511
+ const result = await runToolCall(harness, {
2512
+ toolName: "read",
2513
+ toolCallId: "skill-read-overrides-tools-deny",
2514
+ input: { path: allowedSkillPath },
2515
+ });
2516
+
2517
+ assert.deepEqual(result, {});
2518
+ } finally {
2519
+ await harness.cleanup();
2520
+ }
2521
+ });
2522
+
2523
+ await runAsyncTest("tool_call still blocks read of non-skill file when read tool is deny", async () => {
2524
+ const harness = createToolCallHarness(
2525
+ {
2526
+ defaultPolicy: { tools: "allow", bash: "ask", mcp: "ask", skills: "allow", special: "allow" },
2527
+ tools: { read: "deny" },
2528
+ },
2529
+ ["read"],
2530
+ );
2531
+ const nonSkillPath = join(harness.cwd, "src", "main.ts");
2532
+
2533
+ try {
2534
+ const ctx = createMockContext(harness.cwd, harness.prompts);
2535
+ await Promise.resolve(harness.handlers.before_agent_start?.({ systemPrompt: "No skills in this prompt" }, ctx));
2536
+
2537
+ const result = await runToolCall(harness, {
2538
+ toolName: "read",
2539
+ toolCallId: "non-skill-read-denied",
2540
+ input: { path: nonSkillPath },
2541
+ });
2542
+
2543
+ assert.equal(result.block, true);
2544
+ assert.match(String(result.reason), /not permitted to run 'read'/);
2545
+ } finally {
2546
+ await harness.cleanup();
2547
+ }
2548
+ });
2549
+
2369
2550
  // ---------------------------------------------------------------------------
2370
2551
  // external_directory special permission
2371
2552
  // ---------------------------------------------------------------------------
@@ -2838,4 +3019,79 @@ await runAsyncTest("permission review logs redact raw prompts and tool input pre
2838
3019
  }
2839
3020
  });
2840
3021
 
3022
+ // ---------------------------------------------------------------------------
3023
+ // Targeted smoke test: skill read denial via agent-level '*': deny
3024
+ // ---------------------------------------------------------------------------
3025
+
3026
+ await runAsyncTest("TARGETED SMOKE: agent 'code' with '*': deny blocked from reading 'test-driven-development/SKILL.md'", async () => {
3027
+ const harness = createToolCallHarness(
3028
+ {
3029
+ defaultPolicy: { tools: "allow", bash: "ask", mcp: "ask", skills: "allow", special: "allow" },
3030
+ },
3031
+ ["read"],
3032
+ );
3033
+ const tddSkillPath = join(harness.cwd, ".pi", "agent", "skills", "test-driven-development", "SKILL.md");
3034
+
3035
+ try {
3036
+ writeFileSync(join(harness.baseDir, "agents", "code.md"), [
3037
+ "---",
3038
+ "name: code",
3039
+ "permission:",
3040
+ " skills:",
3041
+ " '*': deny",
3042
+ "---",
3043
+ "",
3044
+ ].join("\n"), "utf8");
3045
+
3046
+ const ctx = createMockContext(harness.cwd, harness.prompts);
3047
+ await Promise.resolve(harness.handlers.before_agent_start?.(
3048
+ { systemPrompt: '<active_agent name="code" mode="delegated">\nNo skills listed.' },
3049
+ ctx,
3050
+ ));
3051
+
3052
+ // ---- Check 1: The read IS blocked ----
3053
+ const result = await runToolCall(harness, {
3054
+ toolName: "read",
3055
+ toolCallId: "tdd-skill-read-denied",
3056
+ input: { path: tddSkillPath },
3057
+ });
3058
+ assert.equal(result.block, true, "CHECK 1 FAILED: Read of skill denied via '*': deny must be blocked");
3059
+ console.log(" [PASS] CHECK 1: block = true (read correctly denied)");
3060
+
3061
+ // ---- Check 2: Reason does NOT contain the skill name ----
3062
+ const reason = String(result.reason ?? "");
3063
+ const nameLeaked = reason.includes("test-driven-development");
3064
+ console.log(" [CHECK 2] Skill name leaked: " + nameLeaked);
3065
+ console.log(" [CHECK 2] Reason text: " + reason);
3066
+ assert.equal(nameLeaked, false, "CHECK 2 FAILED: Deny reason leaks the skill name—security concern");
3067
+
3068
+ // ---- Check 3: yoloMode does NOT auto-approve a deny-state read ----
3069
+ const { savePermissionSystemConfig, DEFAULT_EXTENSION_CONFIG } = await import("../src/extension-config.js");
3070
+ savePermissionSystemConfig({ ...DEFAULT_EXTENSION_CONFIG, yoloMode: true });
3071
+ const yoloResult = await runToolCall(harness, {
3072
+ toolName: "read",
3073
+ toolCallId: "tdd-skill-read-yolo",
3074
+ input: { path: tddSkillPath },
3075
+ });
3076
+ assert.equal(yoloResult.block, true, "CHECK 3 FAILED: yoloMode must NOT auto-approve denied skill read");
3077
+ console.log(" [PASS] CHECK 3: yoloMode does NOT auto-approve deny-state read");
3078
+ savePermissionSystemConfig({ ...DEFAULT_EXTENSION_CONFIG, yoloMode: false });
3079
+
3080
+ // ---- Check 4: inferSkillEntryFromReadPath correctly identifies the skill ----
3081
+ // Non-skill path should NOT be blocked by skill policy
3082
+ const nonSkillResult = await runToolCall(harness, {
3083
+ toolName: "read",
3084
+ toolCallId: "non-skill-check",
3085
+ input: { path: join(harness.cwd, "src", "main.ts") },
3086
+ });
3087
+ assert.deepEqual(nonSkillResult, {}, "CHECK 4 FAILED: Non-skill reads should pass through unblocked");
3088
+ // The skill path IS blocked with skill-specific reason (not "not permitted to run 'read'")
3089
+ // This proves inference found "test-driven-development"
3090
+ assert.equal(reason.includes("not permitted to access this skill"), true, "CHECK 4 FAILED: Block was not skill-specific");
3091
+ console.log(" [PASS] CHECK 4: inferSkillEntryFromReadPath correctly identifies 'test-driven-development'");
3092
+ } finally {
3093
+ await harness.cleanup();
3094
+ }
3095
+ });
3096
+
2841
3097
  console.log("All permission system tests passed.");