@gotgenes/pi-permission-system 7.1.4 → 7.3.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.
Files changed (50) hide show
  1. package/CHANGELOG.md +26 -0
  2. package/package.json +2 -2
  3. package/src/active-agent.ts +1 -1
  4. package/src/bash-arity.ts +1 -0
  5. package/src/config-modal.ts +2 -0
  6. package/src/forwarded-permissions/io.ts +4 -2
  7. package/src/forwarded-permissions/polling.ts +22 -9
  8. package/src/forwarding-manager.ts +3 -1
  9. package/src/handlers/before-agent-start.ts +7 -6
  10. package/src/handlers/gates/bash-path-extractor.ts +3 -5
  11. package/src/handlers/gates/bash-path.ts +1 -1
  12. package/src/handlers/gates/runner.ts +3 -0
  13. package/src/handlers/lifecycle.ts +9 -8
  14. package/src/handlers/permission-gate-handler.ts +12 -7
  15. package/src/index.ts +19 -1
  16. package/src/logging.ts +3 -0
  17. package/src/node-modules-discovery.ts +1 -1
  18. package/src/normalize.ts +1 -0
  19. package/src/permission-event-rpc.ts +2 -0
  20. package/src/permission-forwarding.ts +15 -0
  21. package/src/permission-manager.ts +7 -6
  22. package/src/permission-merge.ts +4 -2
  23. package/src/permission-prompter.ts +7 -0
  24. package/src/permission-prompts.ts +1 -1
  25. package/src/policy-loader.ts +5 -5
  26. package/src/service.ts +37 -1
  27. package/src/skill-prompt-sanitizer.ts +3 -3
  28. package/src/subagent-context.ts +14 -1
  29. package/src/subagent-registry.ts +60 -0
  30. package/src/tool-registry.ts +1 -1
  31. package/src/yolo-mode.ts +2 -1
  32. package/test/config-modal.test.ts +6 -8
  33. package/test/forwarding-manager.test.ts +1 -0
  34. package/test/handlers/before-agent-start.test.ts +1 -1
  35. package/test/handlers/external-directory-integration.test.ts +1 -1
  36. package/test/handlers/gates/skill-read.test.ts +8 -10
  37. package/test/handlers/gates/tool.test.ts +1 -1
  38. package/test/handlers/input-events.test.ts +1 -1
  39. package/test/handlers/input.test.ts +1 -1
  40. package/test/handlers/tool-call-events.test.ts +1 -1
  41. package/test/handlers/tool-call.test.ts +1 -1
  42. package/test/permission-event-rpc.test.ts +1 -0
  43. package/test/permission-events.test.ts +2 -0
  44. package/test/permission-forwarding.test.ts +98 -0
  45. package/test/permission-manager-unified.test.ts +4 -2
  46. package/test/permission-session.test.ts +2 -2
  47. package/test/permission-system.test.ts +8 -8
  48. package/test/service.test.ts +100 -6
  49. package/test/subagent-context.test.ts +65 -0
  50. package/test/subagent-registry.test.ts +94 -0
package/CHANGELOG.md CHANGED
@@ -5,6 +5,32 @@ 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
+ ## [7.3.0](https://github.com/gotgenes/pi-packages/compare/pi-permission-system-v7.2.0...pi-permission-system-v7.3.0) (2026-05-25)
9
+
10
+
11
+ ### Features
12
+
13
+ * **pi-permission-system:** add SubagentSessionRegistry class ([a0ef16b](https://github.com/gotgenes/pi-packages/commit/a0ef16b8302f95b30cc11cb121441dbd164c276c))
14
+ * **pi-permission-system:** detect in-process subagents via session registry ([c90b824](https://github.com/gotgenes/pi-packages/commit/c90b824b4515a1d5ca259348ae0b60c7d70f29d4))
15
+ * **pi-permission-system:** expose registry and getToolPermission on PermissionsService ([984d2bb](https://github.com/gotgenes/pi-packages/commit/984d2bbb76f08cea91b5c0117eb356ae576ad6be))
16
+ * **pi-permission-system:** resolve forwarding target from subagent registry ([5eb15af](https://github.com/gotgenes/pi-packages/commit/5eb15afe680bfd36627c2c21165b59a0ea5e227c))
17
+
18
+
19
+ ### Documentation
20
+
21
+ * **pi-permission-system:** document subagent session registry API ([93c5c3e](https://github.com/gotgenes/pi-packages/commit/93c5c3e72b2b757a99eba17d1c6885ea49271403))
22
+ * **pi-permission-system:** update architecture for subagent registry ([7b32e6a](https://github.com/gotgenes/pi-packages/commit/7b32e6a247e789b927e5cb3f19a367db0c110353))
23
+ * plan subagent session registry and tool-level permission query ([#221](https://github.com/gotgenes/pi-packages/issues/221)) ([a11d91a](https://github.com/gotgenes/pi-packages/commit/a11d91aa1e13e846030deb0af37444c44eeda7c8))
24
+ * **retro:** add planning stage notes for issue [#221](https://github.com/gotgenes/pi-packages/issues/221) ([cf434c2](https://github.com/gotgenes/pi-packages/commit/cf434c2f9711f26290a4635aea519f1f56e98cc7))
25
+ * **retro:** add TDD stage notes for issue [#221](https://github.com/gotgenes/pi-packages/issues/221) ([e050898](https://github.com/gotgenes/pi-packages/commit/e05089840ee6bbb07cbeab5c55367e2dcd304866))
26
+
27
+ ## [7.2.0](https://github.com/gotgenes/pi-packages/compare/pi-permission-system-v7.1.4...pi-permission-system-v7.2.0) (2026-05-24)
28
+
29
+
30
+ ### Features
31
+
32
+ * add eslint config with type-aware rules and import enforcement ([4fb3cc6](https://github.com/gotgenes/pi-packages/commit/4fb3cc678da10d350b85c464318476ba9ae99dca))
33
+
8
34
  ## [7.1.4](https://github.com/gotgenes/pi-packages/compare/pi-permission-system-v7.1.3...pi-permission-system-v7.1.4) (2026-05-23)
9
35
 
10
36
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gotgenes/pi-permission-system",
3
- "version": "7.1.4",
3
+ "version": "7.3.0",
4
4
  "description": "Permission enforcement extension for the Pi coding agent.",
5
5
  "type": "module",
6
6
  "exports": {
@@ -77,6 +77,6 @@
77
77
  "test": "vitest run",
78
78
  "test:watch": "vitest",
79
79
  "lint:md": "rumdl check *.md docs/**/*.md",
80
- "lint": "biome check . && pnpm run lint:md"
80
+ "lint": "biome check . && eslint . && pnpm run lint:md"
81
81
  }
82
82
  }
@@ -49,7 +49,7 @@ export function getActiveAgentNameFromSystemPrompt(
49
49
  return null;
50
50
  }
51
51
 
52
- const match = systemPrompt.match(ACTIVE_AGENT_TAG_REGEX);
52
+ const match = ACTIVE_AGENT_TAG_REGEX.exec(systemPrompt);
53
53
  if (!match?.[1]) {
54
54
  return null;
55
55
  }
package/src/bash-arity.ts CHANGED
@@ -175,6 +175,7 @@ export function prefix(tokens: string[]): string[] {
175
175
  .map((t) => t.toLowerCase())
176
176
  .join(" ");
177
177
  const arity = ARITY[key];
178
+ // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- ARITY record type hides that a key may be absent at runtime
178
179
  if (arity !== undefined) {
179
180
  return tokens.slice(0, Math.min(arity, tokens.length));
180
181
  }
@@ -61,6 +61,7 @@ function toOnOff(value: boolean): string {
61
61
  }
62
62
 
63
63
  function formatRulesSummary(rules: Ruleset): string {
64
+ // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- origin may be absent despite its type
64
65
  const configRules = rules.filter((r) => r.layer === "config" && r.origin);
65
66
  if (configRules.length === 0) return "";
66
67
  const formatted = configRules
@@ -171,6 +172,7 @@ async function openSettingsModal(
171
172
  margin: 1,
172
173
  };
173
174
 
175
+ // eslint-disable-next-line @typescript-eslint/no-invalid-void-type -- ctx.ui.custom<void> is valid; rule does not allow void in generic fn call type args
174
176
  await ctx.ui.custom<void>(
175
177
  (_tui, _theme, _keybindings, done) => {
176
178
  let current = controller.getConfig();
@@ -9,13 +9,13 @@ import {
9
9
  writeFileSync,
10
10
  } from "node:fs";
11
11
 
12
- import { isPermissionDecisionState } from "../permission-dialog";
12
+ import { isPermissionDecisionState } from "#src/permission-dialog";
13
13
  import {
14
14
  createPermissionForwardingLocation,
15
15
  type ForwardedPermissionRequest,
16
16
  type ForwardedPermissionResponse,
17
17
  type PermissionForwardingLocation,
18
- } from "../permission-forwarding";
18
+ } from "#src/permission-forwarding";
19
19
 
20
20
  type LogFn = (event: string, details: Record<string, unknown>) => void;
21
21
 
@@ -262,6 +262,7 @@ export function readForwardedPermissionRequest(
262
262
  const raw = readFileSync(filePath, "utf-8");
263
263
  const parsed = JSON.parse(raw) as Partial<ForwardedPermissionRequest>;
264
264
  if (
265
+ // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- JSON.parse can return null for the string "null"
265
266
  !parsed ||
266
267
  typeof parsed.id !== "string" ||
267
268
  typeof parsed.createdAt !== "number" ||
@@ -303,6 +304,7 @@ export function readForwardedPermissionResponse(
303
304
  const raw = readFileSync(filePath, "utf-8");
304
305
  const parsed = JSON.parse(raw) as Partial<ForwardedPermissionResponse>;
305
306
  if (
307
+ // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- JSON.parse can return null for the string "null"
306
308
  !parsed ||
307
309
  typeof parsed.approved !== "boolean" ||
308
310
  !isPermissionDecisionState(parsed.state) ||
@@ -5,12 +5,12 @@ import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
5
5
  import {
6
6
  getActiveAgentName,
7
7
  getActiveAgentNameFromSystemPrompt,
8
- } from "../active-agent";
9
- import { toRecord } from "../common";
8
+ } from "#src/active-agent";
9
+ import { toRecord } from "#src/common";
10
10
  import type {
11
11
  PermissionPromptDecision,
12
12
  RequestPermissionOptions,
13
- } from "../permission-dialog";
13
+ } from "#src/permission-dialog";
14
14
  import {
15
15
  type ForwardedPermissionRequest,
16
16
  type ForwardedPermissionResponse,
@@ -19,8 +19,9 @@ import {
19
19
  PERMISSION_FORWARDING_TIMEOUT_MS,
20
20
  resolvePermissionForwardingTargetSessionId,
21
21
  SUBAGENT_PARENT_SESSION_ENV_CANDIDATES,
22
- } from "../permission-forwarding";
23
- import { isSubagentExecutionContext } from "../subagent-context";
22
+ } from "#src/permission-forwarding";
23
+ import { isSubagentExecutionContext } from "#src/subagent-context";
24
+ import type { SubagentSessionRegistry } from "#src/subagent-registry";
24
25
 
25
26
  import {
26
27
  cleanupPermissionForwardingLocationIfEmpty,
@@ -40,6 +41,8 @@ import {
40
41
  export interface PermissionForwardingDeps {
41
42
  forwardingDir: string;
42
43
  subagentSessionsDir: string;
44
+ /** In-process subagent session registry for detection and forwarding target resolution. */
45
+ registry?: SubagentSessionRegistry;
43
46
  logger: ForwardedPermissionLogger;
44
47
  writeReviewLog: (event: string, details: Record<string, unknown>) => void;
45
48
  requestPermissionDecisionFromUi: (
@@ -69,6 +72,7 @@ function getContextSystemPrompt(ctx: ExtensionContext): string | undefined {
69
72
  }
70
73
 
71
74
  try {
75
+ // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment -- getSystemPrompt is a Pi SDK accessor returning any
72
76
  const systemPrompt = getSystemPrompt.call(ctx);
73
77
  return typeof systemPrompt === "string" ? systemPrompt : undefined;
74
78
  } catch (error) {
@@ -101,11 +105,18 @@ export async function waitForForwardedPermissionApproval(
101
105
  deps: PermissionForwardingDeps,
102
106
  ): Promise<PermissionPromptDecision> {
103
107
  const requesterSessionId = getSessionId(ctx);
108
+ const sessionDir = ctx.sessionManager.getSessionDir();
104
109
  const targetSessionId = resolvePermissionForwardingTargetSessionId({
105
110
  hasUI: ctx.hasUI,
106
- isSubagent: isSubagentExecutionContext(ctx, deps.subagentSessionsDir),
111
+ isSubagent: isSubagentExecutionContext(
112
+ ctx,
113
+ deps.subagentSessionsDir,
114
+ deps.registry,
115
+ ),
107
116
  currentSessionId: requesterSessionId,
108
117
  env: process.env,
118
+ sessionDir,
119
+ registry: deps.registry,
109
120
  });
110
121
 
111
122
  if (!targetSessionId) {
@@ -135,8 +146,8 @@ export async function waitForForwardedPermissionApproval(
135
146
 
136
147
  const requestId = `${Date.now()}-${Math.random().toString(36).slice(2, 10)}-${process.pid}`;
137
148
  const requesterAgentName =
138
- getActiveAgentName(ctx) ||
139
- getActiveAgentNameFromSystemPrompt(getContextSystemPrompt(ctx)) ||
149
+ getActiveAgentName(ctx) ??
150
+ getActiveAgentNameFromSystemPrompt(getContextSystemPrompt(ctx)) ??
140
151
  "unknown";
141
152
  const request: ForwardedPermissionRequest = {
142
153
  id: requestId,
@@ -359,7 +370,9 @@ export async function confirmPermission(
359
370
  );
360
371
  }
361
372
 
362
- if (!isSubagentExecutionContext(ctx, deps.subagentSessionsDir)) {
373
+ if (
374
+ !isSubagentExecutionContext(ctx, deps.subagentSessionsDir, deps.registry)
375
+ ) {
363
376
  return { approved: false, state: "denied" };
364
377
  }
365
378
 
@@ -4,6 +4,7 @@ import type { PermissionForwardingDeps } from "./forwarded-permissions/polling";
4
4
  import { processForwardedPermissionRequests } from "./forwarded-permissions/polling";
5
5
  import { PERMISSION_FORWARDING_POLL_INTERVAL_MS } from "./permission-forwarding";
6
6
  import { isSubagentExecutionContext } from "./subagent-context";
7
+ import type { SubagentSessionRegistry } from "./subagent-registry";
7
8
 
8
9
  /**
9
10
  * Narrow interface for the forwarding lifecycle used by `PermissionSession`.
@@ -30,6 +31,7 @@ export class ForwardingManager {
30
31
  constructor(
31
32
  private readonly subagentSessionsDir: string,
32
33
  private readonly forwardingDeps: PermissionForwardingDeps,
34
+ private readonly registry?: SubagentSessionRegistry,
33
35
  ) {}
34
36
 
35
37
  /**
@@ -41,7 +43,7 @@ export class ForwardingManager {
41
43
  start(ctx: ExtensionContext): void {
42
44
  if (
43
45
  !ctx.hasUI ||
44
- isSubagentExecutionContext(ctx, this.subagentSessionsDir)
46
+ isSubagentExecutionContext(ctx, this.subagentSessionsDir, this.registry)
45
47
  ) {
46
48
  this.stop();
47
49
  return;
@@ -6,12 +6,12 @@ import type {
6
6
  import {
7
7
  createActiveToolsCacheKey,
8
8
  createBeforeAgentStartPromptStateKey,
9
- } from "../before-agent-start-cache";
10
- import type { PermissionSession } from "../permission-session";
11
- import { resolveSkillPromptEntries } from "../skill-prompt-sanitizer";
12
- import { sanitizeAvailableToolsSection } from "../system-prompt-sanitizer";
13
- import { getToolNameFromValue, type ToolRegistry } from "../tool-registry";
14
- import type { PermissionState } from "../types";
9
+ } from "#src/before-agent-start-cache";
10
+ import type { PermissionSession } from "#src/permission-session";
11
+ import { resolveSkillPromptEntries } from "#src/skill-prompt-sanitizer";
12
+ import { sanitizeAvailableToolsSection } from "#src/system-prompt-sanitizer";
13
+ import { getToolNameFromValue, type ToolRegistry } from "#src/tool-registry";
14
+ import type { PermissionState } from "#src/types";
15
15
 
16
16
  /** Minimal subset of BeforeAgentStartEvent used by this handler. */
17
17
  interface BeforeAgentStartPayload {
@@ -45,6 +45,7 @@ export class AgentPrepHandler {
45
45
  private readonly toolRegistry: ToolRegistry,
46
46
  ) {}
47
47
 
48
+ // eslint-disable-next-line @typescript-eslint/require-await
48
49
  async handle(
49
50
  event: BeforeAgentStartPayload,
50
51
  ctx: ExtensionContext,
@@ -40,13 +40,11 @@ async function initParser(): Promise<TSParser> {
40
40
  const bashWasm = req.resolve("tree-sitter-bash/tree-sitter-bash.wasm");
41
41
  const bash = await Language.load(bashWasm);
42
42
  parser.setLanguage(bash);
43
- return parser as TSParser;
43
+ return parser;
44
44
  }
45
45
 
46
46
  function getParser(): Promise<TSParser> {
47
- if (!parserPromise) {
48
- parserPromise = initParser();
49
- }
47
+ parserPromise ??= initParser();
50
48
  return parserPromise;
51
49
  }
52
50
 
@@ -83,7 +81,7 @@ function resolveNodeText(node: TSNode): string {
83
81
  case "raw_string": {
84
82
  // Strip surrounding single quotes: 'content' → content
85
83
  const t = node.text;
86
- if (t.length >= 2 && t[0] === "'" && t[t.length - 1] === "'") {
84
+ if (t.length >= 2 && t.startsWith("'") && t.endsWith("'")) {
87
85
  return t.slice(1, -1);
88
86
  }
89
87
  return t;
@@ -73,7 +73,7 @@ export async function describeBashPathGate(
73
73
  worstToken = token;
74
74
  break; // Short-circuit on deny.
75
75
  }
76
- if (check.state === "ask" && (!worstCheck || worstCheck.state !== "ask")) {
76
+ if (check.state === "ask" && worstCheck?.state !== "ask") {
77
77
  worstCheck = check;
78
78
  worstToken = token;
79
79
  }
@@ -61,6 +61,7 @@ export async function runGateCheck(
61
61
  value: descriptor.decision.value,
62
62
  result: "allow",
63
63
  resolution: "session_approved",
64
+ // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- ?? null normalises undefined to null for the log record
64
65
  origin: check.origin ?? null,
65
66
  agentName: agentName ?? null,
66
67
  matchedPattern: check.matchedPattern ?? null,
@@ -107,6 +108,7 @@ export async function runGateCheck(
107
108
  autoApproved = decision.autoApproved === true;
108
109
  return decision;
109
110
  },
111
+ // eslint-disable-next-line @typescript-eslint/unbound-method -- logger methods are plain functions; no this-binding issue
110
112
  writeLog: deps.writeReviewLog,
111
113
  logContext: { ...descriptor.logContext, agentName },
112
114
  messages,
@@ -128,6 +130,7 @@ export async function runGateCheck(
128
130
  canConfirm,
129
131
  autoApproved,
130
132
  ),
133
+ // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- ?? null normalises undefined to null for the log record
131
134
  origin: check.origin ?? null,
132
135
  agentName: agentName ?? null,
133
136
  matchedPattern: check.matchedPattern ?? null,
@@ -1,7 +1,7 @@
1
1
  import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
2
2
 
3
- import type { PermissionSession } from "../permission-session";
4
- import { PERMISSION_SYSTEM_STATUS_KEY } from "../status";
3
+ import type { PermissionSession } from "#src/permission-session";
4
+ import { PERMISSION_SYSTEM_STATUS_KEY } from "#src/status";
5
5
 
6
6
  /** Minimal subset of SessionStartEvent used by this handler. */
7
7
  interface SessionStartPayload {
@@ -26,7 +26,7 @@ export class SessionLifecycleHandler {
26
26
  private readonly cleanupRpc: () => void,
27
27
  ) {}
28
28
 
29
- async handleSessionStart(
29
+ handleSessionStart(
30
30
  event: SessionStartPayload,
31
31
  ctx: ExtensionContext,
32
32
  ): Promise<void> {
@@ -48,13 +48,12 @@ export class SessionLifecycleHandler {
48
48
  cwd: ctx.cwd,
49
49
  });
50
50
  }
51
+ return Promise.resolve();
51
52
  }
52
53
 
53
- async handleResourcesDiscover(
54
- event: ResourcesDiscoverPayload,
55
- ): Promise<void> {
54
+ handleResourcesDiscover(event: ResourcesDiscoverPayload): Promise<void> {
56
55
  if (event.reason !== "reload") {
57
- return;
56
+ return Promise.resolve();
58
57
  }
59
58
 
60
59
  const { session } = this;
@@ -64,9 +63,10 @@ export class SessionLifecycleHandler {
64
63
  reason: event.reason,
65
64
  cwd: session.getRuntimeContext()?.cwd ?? null,
66
65
  });
66
+ return Promise.resolve();
67
67
  }
68
68
 
69
- async handleSessionShutdown(): Promise<void> {
69
+ handleSessionShutdown(): Promise<void> {
70
70
  const { session } = this;
71
71
  const ctx = session.getRuntimeContext();
72
72
  if (ctx) {
@@ -74,5 +74,6 @@ export class SessionLifecycleHandler {
74
74
  }
75
75
  session.shutdown();
76
76
  this.cleanupRpc();
77
+ return Promise.resolve();
77
78
  }
78
79
  }
@@ -3,24 +3,24 @@ import type {
3
3
  InputEventResult,
4
4
  } from "@earendil-works/pi-coding-agent";
5
5
 
6
- import { toRecord } from "../common";
6
+ import { toRecord } from "#src/common";
7
7
  import {
8
8
  emitDecisionEvent,
9
9
  type PermissionEventBus,
10
- } from "../permission-events";
11
- import { applyPermissionGate } from "../permission-gate";
12
- import type { PromptPermissionDetails } from "../permission-prompter";
10
+ } from "#src/permission-events";
11
+ import { applyPermissionGate } from "#src/permission-gate";
12
+ import type { PromptPermissionDetails } from "#src/permission-prompter";
13
13
  import {
14
14
  formatMissingToolNameReason,
15
15
  formatSkillAskPrompt,
16
16
  formatUnknownToolReason,
17
- } from "../permission-prompts";
18
- import type { PermissionSession } from "../permission-session";
17
+ } from "#src/permission-prompts";
18
+ import type { PermissionSession } from "#src/permission-session";
19
19
  import {
20
20
  checkRequestedToolRegistration,
21
21
  getToolNameFromValue,
22
22
  type ToolRegistry,
23
- } from "../tool-registry";
23
+ } from "#src/tool-registry";
24
24
  import { describeBashExternalDirectoryGate } from "./gates/bash-external-directory";
25
25
  import { describeBashPathGate } from "./gates/bash-path";
26
26
  import type { GateRunnerDeps } from "./gates/descriptor";
@@ -104,6 +104,7 @@ export class PermissionGateHandler {
104
104
  session.prompt(ctx, details);
105
105
  const emitDecision: GateRunnerDeps["emitDecision"] = (e) =>
106
106
  emitDecisionEvent(this.events, e);
107
+ // eslint-disable-next-line @typescript-eslint/unbound-method -- logger.review is a plain function closure; no this-binding issue
107
108
  const writeReviewLog = session.logger.review;
108
109
  const checkPermission: GateRunnerDeps["checkPermission"] = (
109
110
  surface,
@@ -305,6 +306,7 @@ export class PermissionGateHandler {
305
306
  skillInputAutoApproved = decision.autoApproved === true;
306
307
  return decision;
307
308
  },
309
+ // eslint-disable-next-line @typescript-eslint/unbound-method -- logger.review is a plain function closure; no this-binding issue
308
310
  writeLog: session.logger.review,
309
311
  logContext: {
310
312
  source: "skill_input",
@@ -324,6 +326,7 @@ export class PermissionGateHandler {
324
326
  surface: "skill",
325
327
  value: skillName,
326
328
  result: skillInputGate.action === "allow" ? "allow" : "deny",
329
+ /* eslint-disable @typescript-eslint/no-unnecessary-condition -- defensive fallback; TypeScript narrows check.state before the ternary's else branch */
327
330
  resolution:
328
331
  check.state === "allow"
329
332
  ? "policy_allow"
@@ -336,6 +339,8 @@ export class PermissionGateHandler {
336
339
  : skillInputCanConfirm
337
340
  ? "user_denied"
338
341
  : "confirmation_unavailable",
342
+ /* eslint-enable @typescript-eslint/no-unnecessary-condition */
343
+ // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- ?? null normalises undefined to null for the log record
339
344
  origin: check.origin ?? null,
340
345
  agentName: agentName ?? null,
341
346
  matchedPattern: check.matchedPattern ?? null,
package/src/index.ts CHANGED
@@ -27,6 +27,7 @@ import {
27
27
  } from "./service";
28
28
  import { createSessionLogger } from "./session-logger";
29
29
  import { isSubagentExecutionContext } from "./subagent-context";
30
+ import { SubagentSessionRegistry } from "./subagent-registry";
30
31
  import {
31
32
  canResolveAskPermissionRequest,
32
33
  shouldAutoApprovePermissionState,
@@ -34,18 +35,21 @@ import {
34
35
 
35
36
  export default function piPermissionSystemExtension(pi: ExtensionAPI): void {
36
37
  const runtime = createExtensionRuntime();
38
+ const subagentRegistry = new SubagentSessionRegistry();
37
39
 
38
40
  const prompter = new PermissionPrompter({
39
41
  getConfig: () => runtime.config,
40
42
  writeReviewLog: runtime.writeReviewLog.bind(runtime),
41
43
  subagentSessionsDir: runtime.subagentSessionsDir,
42
44
  forwardingDir: runtime.forwardingDir,
45
+ registry: subagentRegistry,
43
46
  requestPermissionDecisionFromUi,
44
47
  });
45
48
 
46
49
  const forwardingDeps: PermissionForwardingDeps = {
47
50
  forwardingDir: runtime.forwardingDir,
48
51
  subagentSessionsDir: runtime.subagentSessionsDir,
52
+ registry: subagentRegistry,
49
53
  logger: {
50
54
  writeReviewLog: runtime.writeReviewLog.bind(runtime),
51
55
  writeDebugLog: runtime.writeDebugLog.bind(runtime),
@@ -61,7 +65,11 @@ export default function piPermissionSystemExtension(pi: ExtensionAPI): void {
61
65
  const session = new PermissionSession(
62
66
  runtime,
63
67
  createSessionLogger(runtime),
64
- new ForwardingManager(runtime.subagentSessionsDir, forwardingDeps),
68
+ new ForwardingManager(
69
+ runtime.subagentSessionsDir,
70
+ forwardingDeps,
71
+ subagentRegistry,
72
+ ),
65
73
  {
66
74
  refreshExtensionConfig: (ctx) => refreshExtensionConfig(runtime, ctx),
67
75
  logResolvedConfigPaths: () => logResolvedConfigPaths(runtime),
@@ -73,6 +81,7 @@ export default function piPermissionSystemExtension(pi: ExtensionAPI): void {
73
81
  isSubagent: isSubagentExecutionContext(
74
82
  ctx,
75
83
  runtime.subagentSessionsDir,
84
+ subagentRegistry,
76
85
  ),
77
86
  }),
78
87
  promptPermission: (ctx, details) => prompter.prompt(ctx, details),
@@ -108,6 +117,15 @@ export default function piPermissionSystemExtension(pi: ExtensionAPI): void {
108
117
  sessionRules,
109
118
  );
110
119
  },
120
+ registerSubagentSession(sessionKey, info) {
121
+ subagentRegistry.register(sessionKey, info);
122
+ },
123
+ unregisterSubagentSession(sessionKey) {
124
+ subagentRegistry.unregister(sessionKey);
125
+ },
126
+ getToolPermission(toolName, agentName) {
127
+ return runtime.permissionManager.getToolPermission(toolName, agentName);
128
+ },
111
129
  };
112
130
  publishPermissionsService(permissionsService);
113
131
 
package/src/logging.ts CHANGED
@@ -21,12 +21,15 @@ export function safeJsonStringify(value: unknown): string | undefined {
21
21
  }
22
22
 
23
23
  if (typeof currentValue === "object" && currentValue !== null) {
24
+ // eslint-disable-next-line @typescript-eslint/no-unsafe-argument -- JSON.stringify replacer receives any; currentValue is narrowed to object here
24
25
  if (seen.has(currentValue)) {
25
26
  return "[Circular]";
26
27
  }
28
+ // eslint-disable-next-line @typescript-eslint/no-unsafe-argument -- same as above
27
29
  seen.add(currentValue);
28
30
  }
29
31
 
32
+ // eslint-disable-next-line @typescript-eslint/no-unsafe-return -- JSON.stringify replacer must return any
30
33
  return currentValue;
31
34
  });
32
35
  }
@@ -40,7 +40,7 @@ function discoverGlobalNodeModulesViaSubprocess(): string | null {
40
40
  timeout: 5000,
41
41
  stdio: ["ignore", "pipe", "ignore"],
42
42
  });
43
- const root = result.stdout?.trim();
43
+ const root = result.stdout.trim();
44
44
  if (result.status === 0 && root && existsSync(root)) {
45
45
  return root;
46
46
  }
package/src/normalize.ts CHANGED
@@ -20,6 +20,7 @@ export function normalizeFlatConfig(permission: FlatPermissionConfig): Ruleset {
20
20
  if (isPermissionState(value)) {
21
21
  rules.push({ surface, pattern: "*", action: value, origin: "builtin" });
22
22
  }
23
+ // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- defensive null check; value type does not include null but runtime JSON may
23
24
  } else if (typeof value === "object" && value !== null) {
24
25
  for (const [pattern, action] of Object.entries(value)) {
25
26
  if (isPermissionState(action)) {
@@ -1,3 +1,4 @@
1
+ /* eslint-disable @typescript-eslint/no-deprecated -- this module implements the deprecated event-bus RPC channel; references to its own deprecated symbols are intentional */
1
2
  /**
2
3
  * Permission event bus RPC handlers.
3
4
  *
@@ -112,6 +113,7 @@ function handleCheckRpc(
112
113
  const data: PermissionsCheckReplyData = {
113
114
  result: result.state,
114
115
  matchedPattern: result.matchedPattern ?? null,
116
+ // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- ?? null normalises undefined to null for the reply record
115
117
  origin: result.origin ?? null,
116
118
  };
117
119
  events.emit(replyChannel, successReply(data));
@@ -1,6 +1,7 @@
1
1
  import { join } from "node:path";
2
2
 
3
3
  import type { PermissionDecisionState } from "./permission-dialog";
4
+ import type { SubagentSessionRegistry } from "./subagent-registry";
4
5
 
5
6
  export const PERMISSION_FORWARDING_POLL_INTERVAL_MS = 250;
6
7
  export const PERMISSION_FORWARDING_TIMEOUT_MS = 10 * 60 * 1000;
@@ -118,6 +119,10 @@ export function resolvePermissionForwardingTargetSessionId(options: {
118
119
  isSubagent: boolean;
119
120
  currentSessionId?: string | null;
120
121
  env?: NodeJS.ProcessEnv;
122
+ /** Session directory key for registry lookup. */
123
+ sessionDir?: string;
124
+ /** In-process subagent session registry (checked before env vars). */
125
+ registry?: SubagentSessionRegistry;
121
126
  }): string | null {
122
127
  if (options.hasUI) {
123
128
  return normalizePermissionForwardingSessionId(options.currentSessionId);
@@ -127,6 +132,16 @@ export function resolvePermissionForwardingTargetSessionId(options: {
127
132
  return null;
128
133
  }
129
134
 
135
+ // 1. Registry — in-process subagents register parentSessionId explicitly.
136
+ if (options.registry && options.sessionDir) {
137
+ const entry = options.registry.get(options.sessionDir);
138
+ const resolved = normalizePermissionForwardingSessionId(
139
+ entry?.parentSessionId,
140
+ );
141
+ if (resolved) return resolved;
142
+ }
143
+
144
+ // 2. Env vars — process-based subagent extensions.
130
145
  const env = options.env ?? process.env;
131
146
  for (const key of SUBAGENT_PARENT_SESSION_ENV_CANDIDATES) {
132
147
  const resolved = normalizePermissionForwardingSessionId(env[key]);
@@ -78,7 +78,7 @@ export class PermissionManager {
78
78
  }
79
79
 
80
80
  private resolvePermissions(agentName?: string): ResolvedPermissions {
81
- const cacheKey = agentName || "__global__";
81
+ const cacheKey = agentName ?? "__global__";
82
82
  const stamp = this.loader.getCacheStamp(agentName);
83
83
  const cached = this.resolvedPermissionsCache.get(cacheKey);
84
84
  if (cached?.stamp === stamp) {
@@ -107,17 +107,19 @@ export class PermissionManager {
107
107
 
108
108
  for (const [surface, value] of Object.entries(scope.permission)) {
109
109
  const baseVal = mergedPermission[surface];
110
+ /* eslint-disable @typescript-eslint/no-unnecessary-condition -- defensive null/type checks; config values may differ at runtime */
110
111
  const bothObjects =
111
112
  typeof baseVal === "object" &&
112
113
  baseVal !== null &&
113
114
  typeof value === "object" &&
114
115
  value !== null;
116
+ /* eslint-enable @typescript-eslint/no-unnecessary-condition */
115
117
 
116
118
  if (bothObjects) {
117
119
  // Shallow-merge: each incoming pattern is attributed to this scope;
118
120
  // existing patterns from lower scopes keep their earlier origin.
119
121
  if (!origins.has(surface)) origins.set(surface, new Map());
120
- for (const pattern of Object.keys(value as Record<string, unknown>)) {
122
+ for (const pattern of Object.keys(value)) {
121
123
  origins.get(surface)!.set(pattern, scopeName);
122
124
  }
123
125
  } else {
@@ -125,10 +127,9 @@ export class PermissionManager {
125
127
  const surfaceOrigins = new Map<string, RuleOrigin>();
126
128
  if (typeof value === "string") {
127
129
  surfaceOrigins.set("*", scopeName);
130
+ // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- defensive null check
128
131
  } else if (typeof value === "object" && value !== null) {
129
- for (const pattern of Object.keys(
130
- value as Record<string, unknown>,
131
- )) {
132
+ for (const pattern of Object.keys(value)) {
132
133
  surfaceOrigins.set(pattern, scopeName);
133
134
  }
134
135
  }
@@ -146,7 +147,7 @@ export class PermissionManager {
146
147
  // The "*" key feeds synthesizeDefaults() only — it is NOT included as a
147
148
  // config rule so that extension tools fall through to source:"default".
148
149
  const universalFallback = isPermissionState(mergedPermission["*"])
149
- ? (mergedPermission["*"] as PermissionState)
150
+ ? mergedPermission["*"]
150
151
  : DEFAULT_UNIVERSAL_FALLBACK;
151
152
  // Track which scope contributed the universal fallback.
152
153
  const universalFallbackOrigin: RuleOrigin =
@@ -12,15 +12,17 @@ export function mergeFlatPermissions(
12
12
  const merged: FlatPermissionConfig = { ...base };
13
13
  for (const [key, value] of Object.entries(override)) {
14
14
  const baseVal = merged[key];
15
+ /* eslint-disable @typescript-eslint/no-unnecessary-condition -- defensive null/type checks; config values may differ at runtime */
15
16
  if (
16
17
  typeof baseVal === "object" &&
17
18
  baseVal !== null &&
18
19
  typeof value === "object" &&
19
20
  value !== null
20
21
  ) {
22
+ /* eslint-enable @typescript-eslint/no-unnecessary-condition */
21
23
  merged[key] = {
22
- ...(baseVal as Record<string, PermissionState>),
23
- ...(value as Record<string, PermissionState>),
24
+ ...baseVal,
25
+ ...value,
24
26
  };
25
27
  } else {
26
28
  merged[key] = value;