@gotgenes/pi-permission-system 20.7.3 → 20.9.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 (36) hide show
  1. package/CHANGELOG.md +35 -0
  2. package/README.md +3 -0
  3. package/config/config.example.json +2 -0
  4. package/dist/public.d.ts +212 -75
  5. package/docs/configuration.md +45 -9
  6. package/package.json +1 -1
  7. package/schemas/permissions.schema.json +10 -0
  8. package/src/access-intent/input-normalizer.ts +34 -1
  9. package/src/authority/approval-escalator.ts +53 -23
  10. package/src/authority/authorizer-chain.ts +60 -0
  11. package/src/authority/authorizer-registry.ts +69 -0
  12. package/src/authority/authorizer-selection.ts +54 -8
  13. package/src/authority/authorizer.ts +31 -4
  14. package/src/authority/delegation-envelope.ts +52 -0
  15. package/src/authority/denying-authorizer.ts +2 -2
  16. package/src/authority/forwarded-request-server.ts +22 -32
  17. package/src/authority/forwarder-context.ts +7 -0
  18. package/src/authority/forwarding-io.ts +61 -0
  19. package/src/authority/local-user-authorizer.ts +2 -2
  20. package/src/authority/permission-forwarding.ts +46 -0
  21. package/src/authority/permission-prompter.ts +16 -5
  22. package/src/config-loader.ts +5 -4
  23. package/src/config-schema.ts +7 -0
  24. package/src/extension-config.ts +5 -0
  25. package/src/handlers/gates/bash-external-directory.ts +6 -0
  26. package/src/handlers/gates/bash-path.ts +2 -0
  27. package/src/handlers/gates/external-directory.ts +2 -0
  28. package/src/handlers/gates/helpers.ts +33 -0
  29. package/src/handlers/gates/path.ts +2 -0
  30. package/src/handlers/gates/skill-input.ts +2 -0
  31. package/src/handlers/gates/skill-read.ts +2 -0
  32. package/src/handlers/gates/tool.ts +19 -6
  33. package/src/index.ts +26 -13
  34. package/src/permission-resolver.ts +17 -2
  35. package/src/permissions-service.ts +10 -0
  36. package/src/service.ts +52 -14
@@ -12,6 +12,7 @@ import {
12
12
  import { isPermissionDecisionState } from "#src/authority/permission-dialog";
13
13
  import {
14
14
  createPermissionForwardingLocation,
15
+ type ForwardedAccessIntent,
15
16
  type ForwardedPermissionRequest,
16
17
  type ForwardedPermissionResponse,
17
18
  type ForwardedSessionApproval,
@@ -67,6 +68,65 @@ function asForwardedSessionApproval(
67
68
  return { surface: candidate.surface, patterns: [...candidate.patterns] };
68
69
  }
69
70
 
71
+ /**
72
+ * Narrow an unknown value to a `ForwardedAccessIntent`, or `undefined`.
73
+ *
74
+ * Tolerant read: the child-fixed access intent is optional (absent on an older
75
+ * child) and only accepted when fully well-formed — a string `surface`, an
76
+ * all-string `matchValues` array, a `string | null` `boundaryValue`, a string
77
+ * `requesterCwd`, and a `principal` with string `sessionId`/`agentName`. Any
78
+ * malformed shape → `undefined`, so the serving node floors to `ask` (Step 3)
79
+ * rather than resolving against corrupt facts.
80
+ */
81
+ function asForwardedAccessIntent(
82
+ value: unknown,
83
+ ): ForwardedAccessIntent | undefined {
84
+ if (typeof value !== "object" || value === null) {
85
+ return undefined;
86
+ }
87
+ const candidate = value as {
88
+ surface?: unknown;
89
+ matchValues?: unknown;
90
+ boundaryValue?: unknown;
91
+ requesterCwd?: unknown;
92
+ principal?: unknown;
93
+ };
94
+ if (
95
+ typeof candidate.surface !== "string" ||
96
+ !Array.isArray(candidate.matchValues) ||
97
+ !candidate.matchValues.every((entry) => typeof entry === "string") ||
98
+ !(
99
+ candidate.boundaryValue === null ||
100
+ typeof candidate.boundaryValue === "string"
101
+ ) ||
102
+ typeof candidate.requesterCwd !== "string" ||
103
+ typeof candidate.principal !== "object" ||
104
+ candidate.principal === null
105
+ ) {
106
+ return undefined;
107
+ }
108
+ const principal = candidate.principal as {
109
+ sessionId?: unknown;
110
+ agentName?: unknown;
111
+ };
112
+ if (
113
+ typeof principal.sessionId !== "string" ||
114
+ typeof principal.agentName !== "string"
115
+ ) {
116
+ return undefined;
117
+ }
118
+ return {
119
+ surface: candidate.surface,
120
+ matchValues: [...candidate.matchValues],
121
+ boundaryValue: candidate.boundaryValue,
122
+ requesterCwd: candidate.requesterCwd,
123
+ principal: {
124
+ sessionId: principal.sessionId,
125
+ agentName: principal.agentName,
126
+ },
127
+ };
128
+ }
129
+
70
130
  export function formatUnknownErrorMessage(error: unknown): string {
71
131
  if (error instanceof Error && error.message) {
72
132
  return error.message;
@@ -350,6 +410,7 @@ export function readForwardedPermissionRequest(
350
410
  surface: asNullableDisplayString(parsed.surface),
351
411
  value: asNullableDisplayString(parsed.value),
352
412
  sessionApproval: asForwardedSessionApproval(parsed.sessionApproval),
413
+ accessIntent: asForwardedAccessIntent(parsed.accessIntent),
353
414
  };
354
415
  } catch (error) {
355
416
  logPermissionForwardingWarning(
@@ -14,7 +14,7 @@ import {
14
14
  type PermissionEventBus,
15
15
  } from "#src/permission-events";
16
16
  import { buildUiPrompt } from "#src/permission-ui-prompt";
17
- import type { Authorizer } from "./authorizer";
17
+ import type { TerminalAuthorizer } from "./authorizer";
18
18
  import type { PromptPermissionDetails } from "./permission-prompter";
19
19
 
20
20
  /** Dependencies required by {@link LocalUserAuthorizer}. */
@@ -41,7 +41,7 @@ export interface LocalUserAuthorizerDeps {
41
41
  * class renders (populated `forwarding` context + "(Subagent)" title) so the
42
42
  * broadcast stays non-degraded (#292) without a second emission path.
43
43
  */
44
- export class LocalUserAuthorizer implements Authorizer {
44
+ export class LocalUserAuthorizer implements TerminalAuthorizer {
45
45
  constructor(private readonly deps: LocalUserAuthorizerDeps) {}
46
46
 
47
47
  authorize(
@@ -66,6 +66,46 @@ export interface ForwardedSessionApproval {
66
66
  patterns: readonly string[];
67
67
  }
68
68
 
69
+ /**
70
+ * The child-fixed facts a gate emits: the surface it evaluated and the match
71
+ * set it computed. `requesterCwd` and `principal` are stamped at the escalation
72
+ * edge (`ParentAuthorizer`), so a gate carries only what it alone can produce.
73
+ *
74
+ * Strings only — an `AccessPath` never crosses onto the wire
75
+ * (`docs/decisions/0002-path-values-string-boundary.md`).
76
+ */
77
+ export interface ForwardedAccessFacts {
78
+ /** Gate surface: `"path"`, `"external_directory"`, `"bash"`, a tool name, or a skill name. */
79
+ surface: string;
80
+ /**
81
+ * The child-fixed match set. Path surface: `AccessPath.matchValues()`
82
+ * (absolute ∪ cwd-relative ∪ canonical), computed at the child. Non-path
83
+ * surface: the already-portable single value as a one-element array.
84
+ */
85
+ matchValues: string[];
86
+ /** `AccessPath.boundaryValue()` (canonical) for a path surface; `null` for a non-path surface. */
87
+ boundaryValue: string | null;
88
+ }
89
+
90
+ /**
91
+ * The forwarded-wire access intent (ADR 0008 §2): the child-fixed access facts
92
+ * plus the requester identity the escalation edge stamps.
93
+ *
94
+ * The serving node resolves against this intent directly (Step 3, [#597]),
95
+ * using `matchValues` as-is — it never re-derives a path through its own
96
+ * `PathNormalizer`/cwd. See
97
+ * `docs/decisions/0008-cross-session-access-intent.md`.
98
+ */
99
+ export interface ForwardedAccessIntent extends ForwardedAccessFacts {
100
+ /** The requester's cwd, for provenance/disclosure — never for parent re-derivation. */
101
+ requesterCwd: string;
102
+ /** Who is requesting. */
103
+ principal: {
104
+ sessionId: string;
105
+ agentName: string;
106
+ };
107
+ }
108
+
69
109
  export type ForwardedPermissionRequest = {
70
110
  id: string;
71
111
  createdAt: number;
@@ -89,6 +129,12 @@ export type ForwardedPermissionRequest = {
89
129
  * omits it, and the serving dialog then offers no scope choice).
90
130
  */
91
131
  sessionApproval?: ForwardedSessionApproval;
132
+ /**
133
+ * The child-fixed access intent (ADR 0008 §2). Optional for version-skew
134
+ * tolerance: an older child omits it, and the serving node floors to `ask`
135
+ * (Step 3). Present on a current child's request for every gate surface.
136
+ */
137
+ accessIntent?: ForwardedAccessIntent;
92
138
  };
93
139
 
94
140
  export type ForwardedPermissionResponse = {
@@ -1,7 +1,10 @@
1
1
  import type { PermissionPromptDecision } from "#src/authority/permission-dialog";
2
- import type { ForwardedSessionApproval } from "#src/authority/permission-forwarding";
2
+ import type {
3
+ ForwardedAccessFacts,
4
+ ForwardedSessionApproval,
5
+ } from "#src/authority/permission-forwarding";
3
6
  import type { ReviewLogger } from "#src/session-logger";
4
- import type { Authorizer } from "./authorizer";
7
+ import type { TerminalAuthorizer } from "./authorizer";
5
8
 
6
9
  export type PermissionReviewSource = "tool_call" | "skill_input" | "skill_read";
7
10
 
@@ -46,6 +49,14 @@ export interface PromptPermissionDetails {
46
49
  * suggestion.
47
50
  */
48
51
  sessionApproval?: ForwardedSessionApproval;
52
+ /**
53
+ * The child-fixed access facts the raising gate computed (surface + match
54
+ * set). Rides through the runner to the escalation edge, which completes
55
+ * them into a `ForwardedAccessIntent` by stamping `requesterCwd` and
56
+ * `principal`. Absent for a serving-node local prompt reconstructed from a
57
+ * forwarded request.
58
+ */
59
+ accessIntent?: ForwardedAccessFacts;
49
60
  }
50
61
 
51
62
  /**
@@ -58,7 +69,7 @@ export interface PromptPermissionDetails {
58
69
  */
59
70
  export interface PermissionPrompterApi {
60
71
  prompt(
61
- authorizer: Authorizer,
72
+ authorizer: TerminalAuthorizer,
62
73
  details: PromptPermissionDetails,
63
74
  ): Promise<PermissionPromptDecision>;
64
75
  }
@@ -71,7 +82,7 @@ export interface PermissionPrompterDeps {
71
82
 
72
83
  /**
73
84
  * Brackets the ask-path flow with review-log entries and delegates the
74
- * live decision to the selected {@link Authorizer}:
85
+ * live decision to the selected {@link TerminalAuthorizer}:
75
86
  * 1. Review-log "waiting" entry.
76
87
  * 2. `authorizer.authorize(details)`.
77
88
  * 3. Review-log "approved" / "denied" entry.
@@ -89,7 +100,7 @@ export class PermissionPrompter implements PermissionPrompterApi {
89
100
  constructor(private readonly deps: PermissionPrompterDeps) {}
90
101
 
91
102
  async prompt(
92
- authorizer: Authorizer,
103
+ authorizer: TerminalAuthorizer,
93
104
  details: PromptPermissionDetails,
94
105
  ): Promise<PermissionPromptDecision> {
95
106
  this.writeReviewEntry("permission_request.waiting", details);
@@ -231,10 +231,11 @@ export function mergeUnifiedConfigs(
231
231
  }
232
232
 
233
233
  // Array fields: override replaces base when defined
234
- const piInfrastructureReadPaths =
235
- override.piInfrastructureReadPaths ?? base.piInfrastructureReadPaths;
236
- if (piInfrastructureReadPaths !== undefined) {
237
- merged.piInfrastructureReadPaths = piInfrastructureReadPaths;
234
+ for (const key of ["piInfrastructureReadPaths", "authorizerChain"] as const) {
235
+ const value = override[key] ?? base[key];
236
+ if (value !== undefined) {
237
+ merged[key] = value;
238
+ }
238
239
  }
239
240
 
240
241
  // shellTools: shallow-merge by tool name so a project entry overrides a
@@ -206,6 +206,13 @@ export const unifiedConfigSchema = z
206
206
  "Additional directories to auto-allow for reads as Pi infrastructure, bypassing the `external_directory` gate.\n\nThe extension auto-discovers the global node_modules root (walks up from the extension's install path; falls back to `npm root -g` from a dev checkout), Pi's own install directory (via the coding-agent `getPackageDir()` API), `agentDir`, `agentDir/git`, and project-local `.pi/npm/` and `.pi/git/`. Add entries here for edge cases where auto-discovery is insufficient (e.g. custom `npmCommand` pointing to pnpm).\n\nSupports `~`/`$HOME` expansion. Entries may be plain directory prefixes or wildcard patterns using `*` (matches any characters, including `/`) and `?` (matches exactly one character). `**` and `*` are equivalent — both cross directory boundaries.\n\nOn Windows, matching is case-insensitive and tolerant of either path separator.",
207
207
  default: [],
208
208
  }),
209
+ authorizerChain: z.array(z.string().min(1)).optional().meta({
210
+ description:
211
+ "Ordered names of registered live-authority chain links to consult before the terminal authorizer. Config order (not registration order) fixes the chain order; an unregistered name is skipped fail-safe (more prompting, never less); a link decides nothing until it is named here.",
212
+ markdownDescription:
213
+ "Ordered names of registered **live-authority chain links** (e.g. a model judge) to consult before the terminal authorizer (the human, or the subagent-forwarding / headless-deny fallback).\n\nA link reviews an `ask` and returns `allow` / `deny` (with an optional teaching reason) / `defer` to the next link. Three invariants govern the chain:\n\n- **Config order wins.** The order here \u2014 not the order extensions register in \u2014 fixes the security-relevant chain order.\n- **Fail-safe skip.** A name with no registered link is skipped with a warning; the `ask` still reaches the terminal (more prompting, never less).\n- **Opt-in activation.** Installing a judge extension grants it no authority; a link decides nothing until you name it here.\n\nThe chain owner caps every verdict with a bounded-delegation checkpoint: a link's `allow` on an excluded surface (`external_directory` or `path`) is downgraded to `defer`, so a link cannot exceed your policy.\n\nDefaults to an empty list (no links).",
214
+ default: [],
215
+ }),
209
216
  permission: permissionSchema.optional(),
210
217
  shellTools: shellToolsSchema.optional(),
211
218
  })
@@ -23,6 +23,8 @@ export interface PermissionSystemExtensionConfig {
23
23
  toolTextSummaryMaxLength?: number;
24
24
  /** Non-bash tools that carry shell semantics, keyed by tool name. */
25
25
  shellTools?: ShellToolsConfig;
26
+ /** Ordered names of registered live-authority chain links to consult before the terminal authorizer. */
27
+ authorizerChain?: string[];
26
28
  }
27
29
 
28
30
  export const DEFAULT_EXTENSION_CONFIG: PermissionSystemExtensionConfig = {
@@ -75,6 +77,9 @@ export function normalizePermissionSystemConfig(
75
77
  if (raw.shellTools !== undefined) {
76
78
  result.shellTools = raw.shellTools;
77
79
  }
80
+ if (raw.authorizerChain !== undefined) {
81
+ result.authorizerChain = raw.authorizerChain;
82
+ }
78
83
  return result;
79
84
  }
80
85
 
@@ -5,6 +5,7 @@ import { deriveApprovalPattern } from "#src/session-rules";
5
5
  import type { GateResult } from "./descriptor";
6
6
  import { formatBashExternalDirectoryAskPrompt } from "./external-directory-messages";
7
7
  import { selectUncoveredExternalPaths } from "./external-directory-policy";
8
+ import { accessFactsFromPath } from "./helpers";
8
9
  import type { ToolCallContext } from "./types";
9
10
 
10
11
  /**
@@ -65,6 +66,10 @@ export function describeBashExternalDirectoryGate(
65
66
  // defined; the fallback keeps TypeScript happy across the early return. A
66
67
  // config-level "deny" is preserved (not downgraded to the catch-all "ask").
67
68
  const preCheck = worstCheck ?? uncoveredEntries[0].check;
69
+ // The AccessPath the decision was made against — its facts ride the wire.
70
+ const worstEntry =
71
+ uncoveredEntries.find(({ check }) => check === preCheck) ??
72
+ uncoveredEntries[0];
68
73
 
69
74
  const disclosures = uncoveredEntries.map(({ path }) => ({
70
75
  path: path.value(),
@@ -98,6 +103,7 @@ export function describeBashExternalDirectoryGate(
98
103
  toolCallId: tcc.toolCallId,
99
104
  toolName: tcc.toolName,
100
105
  command,
106
+ accessIntent: accessFactsFromPath("external_directory", worstEntry.path),
101
107
  },
102
108
  logContext: {
103
109
  source: "tool_call",
@@ -6,6 +6,7 @@ import { deriveApprovalPattern } from "#src/session-rules";
6
6
  import type { PermissionCheckResult } from "#src/types";
7
7
  import { pickMostRestrictive } from "./candidate-check";
8
8
  import type { GateResult } from "./descriptor";
9
+ import { accessFactsFromPath } from "./helpers";
9
10
  import { formatPathAskPrompt } from "./path";
10
11
  import type { ToolCallContext } from "./types";
11
12
 
@@ -135,6 +136,7 @@ export function describeBashPathGate(
135
136
  toolCallId: tcc.toolCallId,
136
137
  toolName: tcc.toolName,
137
138
  command,
139
+ accessIntent: accessFactsFromPath("path", worstEntry.path),
138
140
  },
139
141
  logContext: {
140
142
  source: "tool_call",
@@ -7,6 +7,7 @@ import type { ToolAccessExtractorLookup } from "#src/tool-access-extractor-regis
7
7
  import type { GateResult } from "./descriptor";
8
8
  import { formatExternalDirectoryAskPrompt } from "./external-directory-messages";
9
9
  import { resolveExternalDirectoryPolicy } from "./external-directory-policy";
10
+ import { accessFactsFromPath } from "./helpers";
10
11
  import type { ToolCallContext } from "./types";
11
12
 
12
13
  /**
@@ -104,6 +105,7 @@ export function describeExternalDirectoryGate(
104
105
  toolCallId: tcc.toolCallId,
105
106
  toolName: tcc.toolName,
106
107
  path: externalDirectoryPath,
108
+ accessIntent: accessFactsFromPath("external_directory", accessPath),
107
109
  },
108
110
  logContext: {
109
111
  source: "tool_call",
@@ -1,10 +1,43 @@
1
+ import type { AccessPath } from "#src/access-intent/access-path";
1
2
  import { classifyToolKind } from "#src/access-intent/tool-kind";
3
+ import type { ForwardedAccessFacts } from "#src/authority/permission-forwarding";
2
4
  import type {
3
5
  PermissionDecisionEvent,
4
6
  PermissionDecisionResolution,
5
7
  } from "#src/permission-events";
6
8
  import type { PermissionCheckResult } from "#src/types";
7
9
 
10
+ /**
11
+ * Build the child-fixed access facts for a path-shaped gate from its
12
+ * `AccessPath`.
13
+ *
14
+ * Converts the `AccessPath` to strings at the point of emission (ADR-0002: an
15
+ * `AccessPath` never crosses onto the wire), carrying the lexical ∪ canonical
16
+ * match set. An empty `boundaryValue()` (a literal-only path) becomes `null`,
17
+ * so the wire distinguishes "no canonical form" cleanly.
18
+ */
19
+ export function accessFactsFromPath(
20
+ surface: string,
21
+ path: AccessPath,
22
+ ): ForwardedAccessFacts {
23
+ return {
24
+ surface,
25
+ matchValues: path.matchValues(),
26
+ boundaryValue: path.boundaryValue() || null,
27
+ };
28
+ }
29
+
30
+ /**
31
+ * Build the child-fixed access facts for a non-path gate (bash command, MCP
32
+ * target, skill name, plain tool) from its already-portable single value.
33
+ */
34
+ export function accessFactsFromValue(
35
+ surface: string,
36
+ value: string,
37
+ ): ForwardedAccessFacts {
38
+ return { surface, matchValues: [value], boundaryValue: null };
39
+ }
40
+
8
41
  /**
9
42
  * Derive the human-readable value for a decision event from a check result.
10
43
  * Bash → extracted command; MCP → qualified target;
@@ -5,6 +5,7 @@ import { SessionApproval } from "#src/session-approval";
5
5
  import { deriveApprovalPattern } from "#src/session-rules";
6
6
  import type { ToolAccessExtractorLookup } from "#src/tool-access-extractor-registry";
7
7
  import type { GateDescriptor, GateResult } from "./descriptor";
8
+ import { accessFactsFromPath } from "./helpers";
8
9
  import type { ToolCallContext } from "./types";
9
10
 
10
11
  /**
@@ -67,6 +68,7 @@ export function describePathGate(
67
68
  toolCallId: tcc.toolCallId,
68
69
  toolName: tcc.toolName,
69
70
  path: filePath,
71
+ accessIntent: accessFactsFromPath("path", accessPath),
70
72
  },
71
73
  logContext: {
72
74
  source: "tool_call",
@@ -1,6 +1,7 @@
1
1
  import { formatSkillAskPrompt } from "#src/permission-prompts";
2
2
  import type { PermissionCheckResult } from "#src/types";
3
3
  import type { GateDescriptor } from "./descriptor";
4
+ import { accessFactsFromValue } from "./helpers";
4
5
 
5
6
  /**
6
7
  * Build a pure descriptor for the skill-input permission gate.
@@ -29,6 +30,7 @@ export function describeSkillInputGate(
29
30
  agentName,
30
31
  message,
31
32
  skillName,
33
+ accessIntent: accessFactsFromValue("skill", skillName),
32
34
  },
33
35
  logContext: {
34
36
  source: "skill_input",
@@ -4,6 +4,7 @@ import type { SkillPromptEntry } from "#src/skill-prompt-sanitizer";
4
4
  import { findSkillPathMatch } from "#src/skill-prompt-sanitizer";
5
5
  import { toRecord } from "#src/value-guards";
6
6
  import type { GateDescriptor } from "./descriptor";
7
+ import { accessFactsFromValue } from "./helpers";
7
8
  import type { ToolCallContext } from "./types";
8
9
 
9
10
  /**
@@ -64,6 +65,7 @@ export function describeSkillReadGate(
64
65
  toolName: tcc.toolName,
65
66
  skillName: matchedSkill.name,
66
67
  path,
68
+ accessIntent: accessFactsFromValue("skill", matchedSkill.name),
67
69
  },
68
70
  logContext: {
69
71
  source: "skill_read",
@@ -11,7 +11,11 @@ import { SessionApproval } from "#src/session-approval";
11
11
  import type { ToolPreviewFormatter } from "#src/tool-preview-formatter";
12
12
  import type { PermissionCheckResult } from "#src/types";
13
13
  import type { GateDescriptor } from "./descriptor";
14
- import { deriveDecisionValue } from "./helpers";
14
+ import {
15
+ accessFactsFromPath,
16
+ accessFactsFromValue,
17
+ deriveDecisionValue,
18
+ } from "./helpers";
15
19
  import type { ToolCallContext } from "./types";
16
20
 
17
21
  /**
@@ -75,6 +79,18 @@ export function describeToolGate(
75
79
  formatter,
76
80
  );
77
81
 
82
+ const decisionValue = deriveDecisionValue(
83
+ gateSurface,
84
+ check,
85
+ getPathBearingToolPath(tcc.toolName, tcc.input) ?? undefined,
86
+ );
87
+
88
+ // A path-bearing tool carries the AccessPath's alias set; every other surface
89
+ // (bash command, MCP target, plain tool) carries its already-portable value.
90
+ const accessIntent = accessPath
91
+ ? accessFactsFromPath(gateSurface, accessPath)
92
+ : accessFactsFromValue(gateSurface, decisionValue);
93
+
78
94
  return {
79
95
  surface: gateSurface,
80
96
  input: tcc.input,
@@ -95,6 +111,7 @@ export function describeToolGate(
95
111
  toolCallId: tcc.toolCallId,
96
112
  toolName: tcc.toolName,
97
113
  sessionLabel: suggestion.label,
114
+ accessIntent,
98
115
  ...permissionLogContext,
99
116
  },
100
117
  logContext: {
@@ -106,11 +123,7 @@ export function describeToolGate(
106
123
  },
107
124
  decision: {
108
125
  surface: gateSurface,
109
- value: deriveDecisionValue(
110
- gateSurface,
111
- check,
112
- getPathBearingToolPath(tcc.toolName, tcc.input) ?? undefined,
113
- ),
126
+ value: decisionValue,
114
127
  },
115
128
  };
116
129
  }
package/src/index.ts CHANGED
@@ -1,7 +1,8 @@
1
1
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
2
  import { getAgentDir, getPackageDir } from "@earendil-works/pi-coding-agent";
3
3
  import { warmBashParser } from "./access-intent/bash/parser";
4
- import { buildAccessIntentForSurface } from "./access-intent/input-normalizer";
4
+ import { buildResolvedIntentFromMatchValues } from "./access-intent/input-normalizer";
5
+ import { AuthorizerRegistry } from "./authority/authorizer-registry";
5
6
  import { AuthorizerSelection } from "./authority/authorizer-selection";
6
7
  import {
7
8
  ForwardedRequestServer,
@@ -64,6 +65,10 @@ export default function piPermissionSystemExtension(pi: ExtensionAPI): void {
64
65
  const formatterRegistry = new ToolInputFormatterRegistry();
65
66
  registerBuiltinToolInputFormatters(formatterRegistry);
66
67
  const accessExtractorRegistry = new ToolAccessExtractorRegistry();
68
+ // One registry instance backs both the registerAuthorizer service surface and
69
+ // AuthorizerSelection's chain resolution, so a registration is visible to
70
+ // composition.
71
+ const authorizerRegistry = new AuthorizerRegistry();
67
72
 
68
73
  // Both `configStore` and `session` are forward-declared so the logger's
69
74
  // lazy thunks can close over them without a cast or null-init holder.
@@ -108,6 +113,15 @@ export default function piPermissionSystemExtension(pi: ExtensionAPI): void {
108
113
  registry: subagentRegistry,
109
114
  logger,
110
115
  prompter,
116
+ // The published service is the narrow, session-scoped PermissionQuery a
117
+ // chain link is handed (it routes bash/path at gate parity against the live
118
+ // session cwd). A thunk because `permissionsService` is constructed below;
119
+ // it resolves at session_start (activate), well after assignment.
120
+ getPermissionQuery: () => permissionsService,
121
+ // Same registry instance the registerAuthorizer service surface writes to,
122
+ // resolved in config order at activation.
123
+ authorizerRegistry,
124
+ getAuthorizerChain: () => configStore.current().authorizerChain ?? [],
111
125
  });
112
126
 
113
127
  // Resolver composes the manager + session ruleset and owns the
@@ -116,20 +130,18 @@ export default function piPermissionSystemExtension(pi: ExtensionAPI): void {
116
130
  // service and gates below share this one instance.
117
131
  const resolver = new PermissionResolver(permissionManager, sessionRules);
118
132
 
119
- // Serving a forwarded request is resolution: evaluate (surface, value)
120
- // against the serving node's composed base ruleset (agentName undefined —
121
- // the child already applied its own per-agent overrides before forwarding).
122
- // The session.getPathNormalizer() read is deferred behind the closure: inbox
123
- // polling starts at session_start, after `session` is assigned — the same
124
- // deferred-binding precedent as the logger notify sink below.
133
+ // Serving a forwarded request is resolution: resolve the child-fixed
134
+ // ForwardedAccessIntent (ADR 0008) directly against the serving node's
135
+ // composed ruleset, agent-scoped to the requester (§3) — the match values
136
+ // are used as fixed by the child, never re-derived through this session's
137
+ // PathNormalizer/cwd (#597).
125
138
  const servingPolicy: ServingPolicy = {
126
- check: (surface, value) =>
139
+ resolve: (intent) =>
127
140
  resolver.resolve(
128
- buildAccessIntentForSurface(
129
- surface,
130
- value ?? undefined,
131
- session.getPathNormalizer(),
132
- undefined,
141
+ buildResolvedIntentFromMatchValues(
142
+ intent.surface,
143
+ intent.matchValues,
144
+ intent.principal.agentName,
133
145
  ),
134
146
  ),
135
147
  };
@@ -176,6 +188,7 @@ export default function piPermissionSystemExtension(pi: ExtensionAPI): void {
176
188
  session,
177
189
  formatterRegistry,
178
190
  accessExtractorRegistry,
191
+ authorizerRegistry,
179
192
  );
180
193
 
181
194
  // Subscribe to @gotgenes/pi-subagents' child lifecycle events so child
@@ -1,5 +1,6 @@
1
1
  import type {
2
2
  AccessIntent,
3
+ PathValuesAccessIntent,
3
4
  ResolvedAccessIntent,
4
5
  } from "./access-intent/access-intent";
5
6
  import type { ScopedPermissionManager } from "./permission-manager";
@@ -31,8 +32,14 @@ export interface ScopedPermissionResolver {
31
32
  * canonical alias set (#418) is derived. Keeping it here (not in the manager)
32
33
  * is the deliberate boundary formalized in ADR-0002
33
34
  * (`docs/decisions/0002-path-values-string-boundary.md`).
35
+ *
36
+ * Also accepts an already-resolved {@link PathValuesAccessIntent} (the
37
+ * forwarded-serving wire's producer, #597) as a pure passthrough — it is
38
+ * already a `ResolvedAccessIntent`, so there is nothing to unwrap.
34
39
  */
35
- function toResolvedIntent(intent: AccessIntent): ResolvedAccessIntent {
40
+ function toResolvedIntent(
41
+ intent: AccessIntent | PathValuesAccessIntent,
42
+ ): ResolvedAccessIntent {
36
43
  if (intent.kind === "access-path") {
37
44
  return {
38
45
  kind: "path-values",
@@ -66,8 +73,16 @@ export class PermissionResolver
66
73
  * Answer a gate-emitted access intent, composing the current session ruleset
67
74
  * so callers never thread it by hand. Unwraps the `access-path` variant via
68
75
  * `matchValues()` before handing a string-based intent to the manager.
76
+ *
77
+ * Also accepts a pre-fixed `path-values` intent (the forwarded-serving wire,
78
+ * #597) — a passthrough, since it is already a `ResolvedAccessIntent`. The
79
+ * gate-facing {@link ScopedPermissionResolver} interface stays narrow
80
+ * (`AccessIntent` only); this wider acceptance is available only through the
81
+ * concrete `PermissionResolver` instance the composition root holds.
69
82
  */
70
- resolve(intent: AccessIntent): PermissionCheckResult {
83
+ resolve(
84
+ intent: AccessIntent | PathValuesAccessIntent,
85
+ ): PermissionCheckResult {
71
86
  return this.permissionManager.check(
72
87
  toResolvedIntent(intent),
73
88
  this.sessionRules.getRuleset(),
@@ -1,5 +1,7 @@
1
1
  import type { AccessIntent } from "./access-intent/access-intent";
2
2
  import { buildAccessIntentForSurface } from "./access-intent/input-normalizer";
3
+ import type { Authorizer } from "./authority/authorizer";
4
+ import type { AuthorizerRegistrar } from "./authority/authorizer-registry";
3
5
  import { resolveBashAdvisoryCheck } from "./bash-advisory-check";
4
6
  import type { PathNormalizer } from "./path-normalizer";
5
7
  import type { PermissionsService } from "./service";
@@ -44,6 +46,7 @@ export class LocalPermissionsService implements PermissionsService {
44
46
  private readonly session: PathNormalizerProvider,
45
47
  private readonly formatterRegistry: ToolInputFormatterRegistrar,
46
48
  private readonly accessExtractorRegistry: ToolAccessExtractorRegistrar,
49
+ private readonly authorizerRegistry: AuthorizerRegistrar,
47
50
  ) {}
48
51
 
49
52
  checkPermission(
@@ -87,4 +90,11 @@ export class LocalPermissionsService implements PermissionsService {
87
90
  ): ReturnType<PermissionsService["registerToolAccessExtractor"]> {
88
91
  return this.accessExtractorRegistry.register(toolName, extractor);
89
92
  }
93
+
94
+ registerAuthorizer(
95
+ name: string,
96
+ authorize: Authorizer["authorize"],
97
+ ): ReturnType<PermissionsService["registerAuthorizer"]> {
98
+ return this.authorizerRegistry.register(name, authorize);
99
+ }
90
100
  }