@gotgenes/pi-permission-system 5.1.2 → 5.2.1

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
@@ -5,6 +5,29 @@ All notable changes to this project will be documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## [5.2.1](https://github.com/gotgenes/pi-permission-system/compare/v5.2.0...v5.2.1) (2026-05-05)
9
+
10
+
11
+ ### Documentation
12
+
13
+ * document subagent extension coexistence ([#97](https://github.com/gotgenes/pi-permission-system/issues/97)) ([9bf2972](https://github.com/gotgenes/pi-permission-system/commit/9bf29726de98d44d7cfd8963f666be37fe807c9a))
14
+ * plan subagent extension coexistence documentation ([#97](https://github.com/gotgenes/pi-permission-system/issues/97)) ([4cf975f](https://github.com/gotgenes/pi-permission-system/commit/4cf975f505081c5df46b20a2dd2d65e9a9a877f9))
15
+ * **retro:** add retro notes for issue [#96](https://github.com/gotgenes/pi-permission-system/issues/96) ([8757ffc](https://github.com/gotgenes/pi-permission-system/commit/8757ffc3f186d137c65aea7abde9a383335584f8))
16
+
17
+ ## [5.2.0](https://github.com/gotgenes/pi-permission-system/compare/v5.1.2...v5.2.0) (2026-05-05)
18
+
19
+
20
+ ### Features
21
+
22
+ * add SUBAGENT_PARENT_SESSION_ENV_CANDIDATES, iterate in resolver ([#96](https://github.com/gotgenes/pi-permission-system/issues/96)) ([ac6831d](https://github.com/gotgenes/pi-permission-system/commit/ac6831d3418db0aee5d5ed4757d5833730a6e130))
23
+ * broaden SUBAGENT_ENV_HINT_KEYS for nicobailon + HazAT extensions ([#96](https://github.com/gotgenes/pi-permission-system/issues/96)) ([8adafdb](https://github.com/gotgenes/pi-permission-system/commit/8adafdb45af66cf99b000b3ad011bee5a2c90476))
24
+
25
+
26
+ ### Documentation
27
+
28
+ * plan broaden subagent env hint keys ([#96](https://github.com/gotgenes/pi-permission-system/issues/96)) ([9fa97b7](https://github.com/gotgenes/pi-permission-system/commit/9fa97b7385e5b9203a35c80d15f980ac4501f788))
29
+ * update target-architecture subagent detection for [#96](https://github.com/gotgenes/pi-permission-system/issues/96) ([64cce35](https://github.com/gotgenes/pi-permission-system/commit/64cce3569c09cede690858af41d2f35611a8705f))
30
+
8
31
  ## [5.1.2](https://github.com/gotgenes/pi-permission-system/compare/v5.1.1...v5.1.2) (2026-05-05)
9
32
 
10
33
 
package/README.md CHANGED
@@ -519,6 +519,63 @@ When a delegated or routed subagent runs without direct UI access, `ask` permiss
519
519
 
520
520
  This keeps `ask` policies usable even when the original permission check happens inside a non-UI execution context.
521
521
 
522
+ ### Coexistence with Subagent Extensions
523
+
524
+ Several pi-subagent extensions implement their own tool restriction mechanisms.
525
+ These compose correctly with the permission system because the two operate at different layers: **visibility** (subagent extension) and **policy** (permission system).
526
+
527
+ #### The two-layer model
528
+
529
+ ```text
530
+ ┌─────────────────────────────────────────────────────┐
531
+ │ Layer 1 – Visibility (subagent extension) │
532
+ │ Controls which tools are registered / active │
533
+ │ before the agent session starts. │
534
+ ├─────────────────────────────────────────────────────┤
535
+ │ Layer 2 – Policy (pi-permission-system) │
536
+ │ Controls allow / ask / deny decisions on every │
537
+ │ tool call, bash command, MCP operation, etc. │
538
+ └─────────────────────────────────────────────────────┘
539
+ ```
540
+
541
+ #### Known subagent extensions and their mechanisms
542
+
543
+ |Extension|Mechanism|Frontmatter key|
544
+ |----|----------|----------|
545
+ |[nicobailon/pi-subagents](https://github.com/nicobailon/pi-subagents)|`--tools` CLI allowlist passed to subprocess|`tools:` (CSV allowlist)|
546
+ |[tintinweb/pi-subagents](https://github.com/tintinweb/pi-subagents)|`session.setActiveToolsByName()` in-process filter|`disallowed_tools:` (CSV denylist)|
547
+ |[HazAT/pi-interactive-subagents](https://github.com/HazAT/pi-interactive-subagents)|`PI_DENY_TOOLS` env var + `--tools` CLI allowlist|`deny-tools:` (CSV denylist), `spawning:` (bool)|
548
+
549
+ #### Interaction rules
550
+
551
+ 1. **Hidden tool → permission system never sees it.**
552
+ If a subagent extension removes a tool from the active set, the permission system receives no registration or call event for that tool.
553
+ The permission policy for that tool is irrelevant — it is already gone.
554
+ 2. **Denied tool → hidden regardless of the subagent extension's allowlist.**
555
+ If the permission system denies a tool (via `deny` policy or tool filtering), it is removed from the active set before the agent starts.
556
+ A `tools:` allowlist in a subagent extension cannot restore a tool that the permission system has already hidden.
557
+ 3. **The two denylist mechanisms are additive, not conflicting.**
558
+ A tool blocked by either layer stays blocked.
559
+ Neither layer can silently re-enable what the other has blocked.
560
+
561
+ #### `permission:` frontmatter is exclusive to this extension
562
+
563
+ The `permission:` key in an agent's YAML frontmatter is read exclusively by `pi-permission-system`.
564
+ It has no interaction with the `tools:`, `disallowed_tools:`, or `deny-tools:` keys consumed by subagent extensions.
565
+ You can freely use both in the same agent file:
566
+
567
+ ```yaml
568
+ ---
569
+ # Subagent extension: allow only bash and read_file in the subprocess
570
+ tools: bash,read_file
571
+ # pi-permission-system: still enforce ask on bash within those allowed tools
572
+ permission:
573
+ bash: ask
574
+ ---
575
+ ```
576
+
577
+ In this example the subagent extension restricts visibility to `bash` and `read_file`, and the permission system then gates every `bash` call with an `ask` prompt — both rules apply independently.
578
+
522
579
  ### Logging
523
580
 
524
581
  When the extension prompts, denies, or forwards permission requests, it can append structured JSONL entries under:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gotgenes/pi-permission-system",
3
- "version": "5.1.2",
3
+ "version": "5.2.1",
4
4
  "description": "Permission enforcement extension for the Pi coding agent.",
5
5
  "type": "module",
6
6
  "files": [
@@ -18,6 +18,7 @@ import {
18
18
  PERMISSION_FORWARDING_POLL_INTERVAL_MS,
19
19
  PERMISSION_FORWARDING_TIMEOUT_MS,
20
20
  resolvePermissionForwardingTargetSessionId,
21
+ SUBAGENT_PARENT_SESSION_ENV_CANDIDATES,
21
22
  } from "../permission-forwarding";
22
23
  import { isSubagentExecutionContext } from "../subagent-context";
23
24
 
@@ -110,7 +111,10 @@ export async function waitForForwardedPermissionApproval(
110
111
  if (!targetSessionId) {
111
112
  logPermissionForwardingError(
112
113
  deps.logger,
113
- "Permission forwarding target session could not be resolved from subagent runtime metadata (expected PI_AGENT_ROUTER_PARENT_SESSION_ID)",
114
+ `Permission forwarding target session could not be resolved. ` +
115
+ `Checked env vars: ${SUBAGENT_PARENT_SESSION_ENV_CANDIDATES.join(", ")}. ` +
116
+ `If you are using nicobailon/pi-subagents or HazAT/pi-interactive-subagents, ` +
117
+ `parent-session forwarding is not yet supported for those extensions (see issue #98).`,
114
118
  );
115
119
  return { approved: false, state: "denied" };
116
120
  }
@@ -5,12 +5,30 @@ import type { PermissionDecisionState } from "./permission-dialog";
5
5
  export const PERMISSION_FORWARDING_POLL_INTERVAL_MS = 250;
6
6
  export const PERMISSION_FORWARDING_TIMEOUT_MS = 10 * 60 * 1000;
7
7
  export const SUBAGENT_ENV_HINT_KEYS = [
8
+ // pi-agent-router (original)
8
9
  "PI_IS_SUBAGENT",
9
10
  "PI_SUBAGENT_SESSION_ID",
10
11
  "PI_AGENT_ROUTER_SUBAGENT",
12
+ // nicobailon/pi-subagents
13
+ "PI_SUBAGENT_CHILD",
14
+ "PI_SUBAGENT_RUN_ID",
15
+ "PI_SUBAGENT_CHILD_AGENT",
16
+ "PI_SUBAGENT_DEPTH",
17
+ // HazAT/pi-interactive-subagents
18
+ "PI_SUBAGENT_NAME",
19
+ "PI_SUBAGENT_ID",
20
+ "PI_SUBAGENT_SESSION",
21
+ "PI_SUBAGENT_ACTIVITY_FILE",
11
22
  ] as const;
23
+ /** Ordered list of env var names to check for the parent session ID. First match wins. */
24
+ export const SUBAGENT_PARENT_SESSION_ENV_CANDIDATES: readonly string[] = [
25
+ // pi-agent-router (original)
26
+ "PI_AGENT_ROUTER_PARENT_SESSION_ID",
27
+ ] as const;
28
+
29
+ /** @deprecated Use SUBAGENT_PARENT_SESSION_ENV_CANDIDATES */
12
30
  export const SUBAGENT_PARENT_SESSION_ENV_KEY =
13
- "PI_AGENT_ROUTER_PARENT_SESSION_ID";
31
+ SUBAGENT_PARENT_SESSION_ENV_CANDIDATES[0];
14
32
 
15
33
  const SESSION_FORWARDING_ROOT_DIRECTORY_NAME = "sessions";
16
34
  const SESSION_FORWARDING_REQUESTS_DIRECTORY_NAME = "requests";
@@ -106,9 +124,12 @@ export function resolvePermissionForwardingTargetSessionId(options: {
106
124
  return null;
107
125
  }
108
126
 
109
- return normalizePermissionForwardingSessionId(
110
- options.env?.[SUBAGENT_PARENT_SESSION_ENV_KEY],
111
- );
127
+ const env = options.env ?? process.env;
128
+ for (const key of SUBAGENT_PARENT_SESSION_ENV_CANDIDATES) {
129
+ const resolved = normalizePermissionForwardingSessionId(env[key]);
130
+ if (resolved) return resolved;
131
+ }
132
+ return null;
112
133
  }
113
134
 
114
135
  export function isForwardedPermissionRequestForSession(
@@ -0,0 +1,143 @@
1
+ import { afterEach, describe, expect, test, vi } from "vitest";
2
+ import {
3
+ resolvePermissionForwardingTargetSessionId,
4
+ SUBAGENT_PARENT_SESSION_ENV_CANDIDATES,
5
+ SUBAGENT_PARENT_SESSION_ENV_KEY,
6
+ } from "../src/permission-forwarding";
7
+
8
+ afterEach(() => {
9
+ vi.unstubAllEnvs();
10
+ });
11
+
12
+ describe("SUBAGENT_PARENT_SESSION_ENV_CANDIDATES", () => {
13
+ test("is an array containing PI_AGENT_ROUTER_PARENT_SESSION_ID", () => {
14
+ expect(Array.isArray(SUBAGENT_PARENT_SESSION_ENV_CANDIDATES)).toBe(true);
15
+ expect(SUBAGENT_PARENT_SESSION_ENV_CANDIDATES).toContain(
16
+ "PI_AGENT_ROUTER_PARENT_SESSION_ID",
17
+ );
18
+ });
19
+
20
+ test("deprecated SUBAGENT_PARENT_SESSION_ENV_KEY equals the first candidate", () => {
21
+ expect(SUBAGENT_PARENT_SESSION_ENV_KEY).toBe(
22
+ SUBAGENT_PARENT_SESSION_ENV_CANDIDATES[0],
23
+ );
24
+ });
25
+ });
26
+
27
+ describe("resolvePermissionForwardingTargetSessionId", () => {
28
+ test("hasUI=true returns the current session ID (UI host owns forwarding)", () => {
29
+ expect(
30
+ resolvePermissionForwardingTargetSessionId({
31
+ hasUI: true,
32
+ isSubagent: false,
33
+ currentSessionId: "parent-session-abc",
34
+ env: {},
35
+ }),
36
+ ).toBe("parent-session-abc");
37
+ });
38
+
39
+ test("hasUI=true with isSubagent=true still returns current session ID", () => {
40
+ expect(
41
+ resolvePermissionForwardingTargetSessionId({
42
+ hasUI: true,
43
+ isSubagent: true,
44
+ currentSessionId: "session-xyz",
45
+ env: { PI_AGENT_ROUTER_PARENT_SESSION_ID: "other" },
46
+ }),
47
+ ).toBe("session-xyz");
48
+ });
49
+
50
+ test("hasUI=false, isSubagent=false returns null", () => {
51
+ expect(
52
+ resolvePermissionForwardingTargetSessionId({
53
+ hasUI: false,
54
+ isSubagent: false,
55
+ currentSessionId: "session-xyz",
56
+ env: { PI_AGENT_ROUTER_PARENT_SESSION_ID: "parent-session-abc" },
57
+ }),
58
+ ).toBeNull();
59
+ });
60
+
61
+ test("isSubagent=true, no candidates set returns null", () => {
62
+ expect(
63
+ resolvePermissionForwardingTargetSessionId({
64
+ hasUI: false,
65
+ isSubagent: true,
66
+ currentSessionId: "session-xyz",
67
+ env: {},
68
+ }),
69
+ ).toBeNull();
70
+ });
71
+
72
+ test("isSubagent=true, PI_AGENT_ROUTER_PARENT_SESSION_ID set returns its value", () => {
73
+ expect(
74
+ resolvePermissionForwardingTargetSessionId({
75
+ hasUI: false,
76
+ isSubagent: true,
77
+ currentSessionId: "session-xyz",
78
+ env: { PI_AGENT_ROUTER_PARENT_SESSION_ID: "parent-session-abc" },
79
+ }),
80
+ ).toBe("parent-session-abc");
81
+ });
82
+
83
+ test("isSubagent=true, first candidate absent but second set returns second value", () => {
84
+ // Inject a second candidate at test-time to validate the iteration logic
85
+ // without waiting for a real extension to adopt the convention.
86
+ const originalCandidates = [...SUBAGENT_PARENT_SESSION_ENV_CANDIDATES];
87
+ // Mutate the array via index-assignment through a cast so we can test
88
+ // multi-candidate iteration without changing the exported constant type.
89
+ // This is test-only; production code never mutates the array.
90
+ (SUBAGENT_PARENT_SESSION_ENV_CANDIDATES as unknown as string[]).push(
91
+ "PI_SUBAGENT_PARENT_SESSION_ID_TEST_ONLY",
92
+ );
93
+
94
+ try {
95
+ expect(
96
+ resolvePermissionForwardingTargetSessionId({
97
+ hasUI: false,
98
+ isSubagent: true,
99
+ currentSessionId: "session-xyz",
100
+ env: {
101
+ PI_SUBAGENT_PARENT_SESSION_ID_TEST_ONLY: "parent-from-second",
102
+ },
103
+ }),
104
+ ).toBe("parent-from-second");
105
+ } finally {
106
+ // Restore original array contents.
107
+ (SUBAGENT_PARENT_SESSION_ENV_CANDIDATES as unknown as string[]).length =
108
+ originalCandidates.length;
109
+ }
110
+ });
111
+
112
+ test("isSubagent=true, candidate value is empty string returns null", () => {
113
+ expect(
114
+ resolvePermissionForwardingTargetSessionId({
115
+ hasUI: false,
116
+ isSubagent: true,
117
+ currentSessionId: "session-xyz",
118
+ env: { PI_AGENT_ROUTER_PARENT_SESSION_ID: "" },
119
+ }),
120
+ ).toBeNull();
121
+ });
122
+
123
+ test("isSubagent=true, candidate value is 'unknown' returns null", () => {
124
+ expect(
125
+ resolvePermissionForwardingTargetSessionId({
126
+ hasUI: false,
127
+ isSubagent: true,
128
+ currentSessionId: "session-xyz",
129
+ env: { PI_AGENT_ROUTER_PARENT_SESSION_ID: "unknown" },
130
+ }),
131
+ ).toBeNull();
132
+ });
133
+
134
+ test("env defaults to process.env when omitted", () => {
135
+ vi.stubEnv("PI_AGENT_ROUTER_PARENT_SESSION_ID", "env-session-abc");
136
+ expect(
137
+ resolvePermissionForwardingTargetSessionId({
138
+ hasUI: false,
139
+ isSubagent: true,
140
+ }),
141
+ ).toBe("env-session-abc");
142
+ });
143
+ });
@@ -61,11 +61,86 @@ describe("isSubagentExecutionContext — env hint detection", () => {
61
61
  ).toBe(true);
62
62
  });
63
63
 
64
- test("covers all three declared SUBAGENT_ENV_HINT_KEYS", () => {
64
+ // nicobailon/pi-subagents keys
65
+ test("returns true when PI_SUBAGENT_CHILD is set", () => {
66
+ vi.stubEnv("PI_SUBAGENT_CHILD", "1");
67
+ expect(
68
+ isSubagentExecutionContext(makeCtx(null), "/sessions/subagents"),
69
+ ).toBe(true);
70
+ });
71
+
72
+ test("returns true when PI_SUBAGENT_RUN_ID is set", () => {
73
+ vi.stubEnv("PI_SUBAGENT_RUN_ID", "run-abc");
74
+ expect(
75
+ isSubagentExecutionContext(makeCtx(null), "/sessions/subagents"),
76
+ ).toBe(true);
77
+ });
78
+
79
+ test("returns true when PI_SUBAGENT_CHILD_AGENT is set", () => {
80
+ vi.stubEnv("PI_SUBAGENT_CHILD_AGENT", "worker");
81
+ expect(
82
+ isSubagentExecutionContext(makeCtx(null), "/sessions/subagents"),
83
+ ).toBe(true);
84
+ });
85
+
86
+ test("returns true when PI_SUBAGENT_DEPTH is set", () => {
87
+ vi.stubEnv("PI_SUBAGENT_DEPTH", "1");
88
+ expect(
89
+ isSubagentExecutionContext(makeCtx(null), "/sessions/subagents"),
90
+ ).toBe(true);
91
+ });
92
+
93
+ test("returns true when PI_SUBAGENT_DEPTH is zero (depth-0 is still a subagent context)", () => {
94
+ vi.stubEnv("PI_SUBAGENT_DEPTH", "0");
95
+ expect(
96
+ isSubagentExecutionContext(makeCtx(null), "/sessions/subagents"),
97
+ ).toBe(true);
98
+ });
99
+
100
+ // HazAT/pi-interactive-subagents keys
101
+ test("returns true when PI_SUBAGENT_NAME is set", () => {
102
+ vi.stubEnv("PI_SUBAGENT_NAME", "my-agent");
103
+ expect(
104
+ isSubagentExecutionContext(makeCtx(null), "/sessions/subagents"),
105
+ ).toBe(true);
106
+ });
107
+
108
+ test("returns true when PI_SUBAGENT_ID is set", () => {
109
+ vi.stubEnv("PI_SUBAGENT_ID", "id-xyz");
110
+ expect(
111
+ isSubagentExecutionContext(makeCtx(null), "/sessions/subagents"),
112
+ ).toBe(true);
113
+ });
114
+
115
+ test("returns true when PI_SUBAGENT_SESSION is set", () => {
116
+ vi.stubEnv("PI_SUBAGENT_SESSION", "session-xyz");
117
+ expect(
118
+ isSubagentExecutionContext(makeCtx(null), "/sessions/subagents"),
119
+ ).toBe(true);
120
+ });
121
+
122
+ test("returns true when PI_SUBAGENT_ACTIVITY_FILE is set", () => {
123
+ vi.stubEnv("PI_SUBAGENT_ACTIVITY_FILE", "/tmp/activity.json");
124
+ expect(
125
+ isSubagentExecutionContext(makeCtx(null), "/sessions/subagents"),
126
+ ).toBe(true);
127
+ });
128
+
129
+ test("covers all declared SUBAGENT_ENV_HINT_KEYS", () => {
65
130
  // Verify the keys we test match what the module declares.
66
131
  expect(SUBAGENT_ENV_HINT_KEYS).toContain("PI_IS_SUBAGENT");
67
132
  expect(SUBAGENT_ENV_HINT_KEYS).toContain("PI_SUBAGENT_SESSION_ID");
68
133
  expect(SUBAGENT_ENV_HINT_KEYS).toContain("PI_AGENT_ROUTER_SUBAGENT");
134
+ // nicobailon/pi-subagents
135
+ expect(SUBAGENT_ENV_HINT_KEYS).toContain("PI_SUBAGENT_CHILD");
136
+ expect(SUBAGENT_ENV_HINT_KEYS).toContain("PI_SUBAGENT_RUN_ID");
137
+ expect(SUBAGENT_ENV_HINT_KEYS).toContain("PI_SUBAGENT_CHILD_AGENT");
138
+ expect(SUBAGENT_ENV_HINT_KEYS).toContain("PI_SUBAGENT_DEPTH");
139
+ // HazAT/pi-interactive-subagents
140
+ expect(SUBAGENT_ENV_HINT_KEYS).toContain("PI_SUBAGENT_NAME");
141
+ expect(SUBAGENT_ENV_HINT_KEYS).toContain("PI_SUBAGENT_ID");
142
+ expect(SUBAGENT_ENV_HINT_KEYS).toContain("PI_SUBAGENT_SESSION");
143
+ expect(SUBAGENT_ENV_HINT_KEYS).toContain("PI_SUBAGENT_ACTIVITY_FILE");
69
144
  });
70
145
 
71
146
  test("returns false when env hint value is empty string", () => {