@gotgenes/pi-permission-system 20.7.3 → 20.8.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
@@ -5,6 +5,25 @@ 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
+ ## [20.8.0](https://github.com/gotgenes/pi-packages/compare/pi-permission-system-v20.7.3...pi-permission-system-v20.8.0) (2026-07-18)
9
+
10
+
11
+ ### Features
12
+
13
+ * **pi-permission-system:** accept pre-fixed path-values intents for forwarded serving ([ab60874](https://github.com/gotgenes/pi-packages/commit/ab6087464c7719c01bd7f2a15fd4c56a1a7e9f93))
14
+ * **pi-permission-system:** declare ForwardedAccessIntent wire schema with tolerant read ([#596](https://github.com/gotgenes/pi-packages/issues/596)) ([66ddbef](https://github.com/gotgenes/pi-packages/commit/66ddbef74e3fa6cd41d17358a0b817ae91d49ed1))
15
+ * **pi-permission-system:** emit access-intent facts from the bash path gates ([#596](https://github.com/gotgenes/pi-packages/issues/596)) ([1a0e6de](https://github.com/gotgenes/pi-packages/commit/1a0e6de3fdf411ab576d0c9aa5571ff23cb5f179))
16
+ * **pi-permission-system:** emit access-intent facts from the per-tool gate ([#596](https://github.com/gotgenes/pi-packages/issues/596)) ([2e17256](https://github.com/gotgenes/pi-packages/commit/2e1725695442b10073ea627ce9699ef363e4c77c))
17
+ * **pi-permission-system:** emit access-intent facts from the skill gates ([#596](https://github.com/gotgenes/pi-packages/issues/596)) ([5a20033](https://github.com/gotgenes/pi-packages/commit/5a20033fca9f33dee576c5d39c0c04e468d2d031))
18
+ * **pi-permission-system:** emit access-intent facts from the tool path gates ([#596](https://github.com/gotgenes/pi-packages/issues/596)) ([93a3398](https://github.com/gotgenes/pi-packages/commit/93a3398245b06fe6cb80f841d2a2ca5dc8172de5))
19
+ * **pi-permission-system:** serialize the child-fixed access intent onto the forwarded request ([#596](https://github.com/gotgenes/pi-packages/issues/596)) ([5234614](https://github.com/gotgenes/pi-packages/commit/52346148c41a097719558175582b57d12e7832d0))
20
+ * **pi-permission-system:** serving resolves the forwarded access intent at gate parity ([#597](https://github.com/gotgenes/pi-packages/issues/597)) ([a8fe815](https://github.com/gotgenes/pi-packages/commit/a8fe815bd70e3243553b7cac6841ec270e20bf23))
21
+
22
+
23
+ ### Documentation
24
+
25
+ * **pi-permission-system:** fix stale ServingPolicy doc comment ([#597](https://github.com/gotgenes/pi-packages/issues/597)) ([64b1b8e](https://github.com/gotgenes/pi-packages/commit/64b1b8e13a8d22ee4f088bef7d54024a81d9a437))
26
+
8
27
  ## [20.7.3](https://github.com/gotgenes/pi-packages/compare/pi-permission-system-v20.7.2...pi-permission-system-v20.7.3) (2026-07-15)
9
28
 
10
29
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gotgenes/pi-permission-system",
3
- "version": "20.7.3",
3
+ "version": "20.8.0",
4
4
  "description": "Permission enforcement extension for the Pi coding agent.",
5
5
  "type": "module",
6
6
  "exports": {
@@ -1,7 +1,7 @@
1
1
  import { stripBashCommentLines } from "#src/bash-arity";
2
2
  import type { PathNormalizer } from "#src/path-normalizer";
3
3
  import { getNonEmptyString, toRecord } from "#src/value-guards";
4
- import type { AccessIntent } from "./access-intent";
4
+ import type { AccessIntent, ResolvedAccessIntent } from "./access-intent";
5
5
  import { createMcpPermissionTargets } from "./mcp-targets";
6
6
  import { PATH_SURFACES } from "./path-surfaces";
7
7
  import { classifyToolKind } from "./tool-kind";
@@ -41,6 +41,39 @@ export function buildAccessIntentForSurface(
41
41
  };
42
42
  }
43
43
 
44
+ /**
45
+ * Build a {@link ResolvedAccessIntent} directly from a forwarded request's
46
+ * child-fixed match values (ADR 0008 §2), for the forwarded-serving wire
47
+ * (#597).
48
+ *
49
+ * Unlike {@link buildAccessIntentForSurface}, this never touches a
50
+ * `PathNormalizer` and never rebuilds an `AccessPath` — a path-shaped surface
51
+ * gets a `path-values` intent carrying `matchValues` as-is (the values the
52
+ * child already fixed), and every other surface gets a `tool` intent built
53
+ * from its single portable value. `agentName` is always the requester's
54
+ * `principal.agentName` (ADR 0008 §3, agent-scoped serving).
55
+ */
56
+ export function buildResolvedIntentFromMatchValues(
57
+ surface: string,
58
+ matchValues: readonly string[],
59
+ agentName: string,
60
+ ): ResolvedAccessIntent {
61
+ if (PATH_SURFACES.has(surface)) {
62
+ return {
63
+ kind: "path-values",
64
+ surface,
65
+ values: [...matchValues],
66
+ agentName,
67
+ };
68
+ }
69
+ return {
70
+ kind: "tool",
71
+ surface,
72
+ input: buildInputForSurface(surface, matchValues[0]),
73
+ agentName,
74
+ };
75
+ }
76
+
44
77
  /**
45
78
  * Construct a surface-appropriate input object from a raw value string for the
46
79
  * `tool`-intent branch of {@link buildAccessIntentForSurface} (the non-path
@@ -6,6 +6,7 @@ import {
6
6
  } from "#src/active-agent";
7
7
  import {
8
8
  type ForwarderContext,
9
+ getCwd,
9
10
  getSessionId,
10
11
  } from "#src/authority/forwarder-context";
11
12
  import {
@@ -20,6 +21,7 @@ import {
20
21
  } from "#src/authority/forwarding-io";
21
22
  import type { PermissionPromptDecision } from "#src/authority/permission-dialog";
22
23
  import {
24
+ type ForwardedAccessFacts,
23
25
  type ForwardedPermissionRequest,
24
26
  type ForwardedPromptDisplay,
25
27
  type ForwardedSessionApproval,
@@ -61,6 +63,23 @@ function getContextSystemPrompt(ctx: ForwarderContext): string | undefined {
61
63
 
62
64
  // ── ParentAuthorizer ────────────────────────────────────────────────────
63
65
 
66
+ /**
67
+ * The facts a forwarded request relays unchanged from the child's ask: the
68
+ * prompt message, the optional display projection, and the optional
69
+ * session-approval suggestion.
70
+ *
71
+ * Bundled into one object so the two-hop private chain
72
+ * (`waitForForwardedApproval` → `buildForwardedRequest`) threads a single
73
+ * relayed value instead of three positional optionals.
74
+ */
75
+ interface ForwardedRequestFacts {
76
+ message: string;
77
+ display?: ForwardedPromptDisplay;
78
+ sessionApproval?: ForwardedSessionApproval;
79
+ /** The child-fixed access facts; the edge completes them into a `ForwardedAccessIntent`. */
80
+ accessIntent?: ForwardedAccessFacts;
81
+ }
82
+
64
83
  /** Constructor config for {@link ParentAuthorizer}. */
65
84
  export interface ParentAuthorizerDeps {
66
85
  forwardingDir: string;
@@ -99,25 +118,23 @@ export class ParentAuthorizer implements Authorizer {
99
118
  details: PromptPermissionDetails,
100
119
  ): Promise<PermissionPromptDecision> {
101
120
  const uiPrompt = buildUiPrompt(details);
102
- return this.waitForForwardedApproval(
103
- this.ctx,
104
- details.message,
105
- {
121
+ return this.waitForForwardedApproval(this.ctx, {
122
+ message: details.message,
123
+ display: {
106
124
  source: uiPrompt.source,
107
125
  surface: uiPrompt.surface,
108
126
  value: uiPrompt.value,
109
127
  },
110
- details.sessionApproval,
111
- );
128
+ sessionApproval: details.sessionApproval,
129
+ accessIntent: details.accessIntent,
130
+ });
112
131
  }
113
132
 
114
133
  // ── Private methods ────────────────────────────────────────────────────
115
134
 
116
135
  private async waitForForwardedApproval(
117
136
  ctx: ForwarderContext,
118
- message: string,
119
- forwarded?: ForwardedPromptDisplay,
120
- sessionApproval?: ForwardedSessionApproval,
137
+ facts: ForwardedRequestFacts,
121
138
  ): Promise<PermissionPromptDecision> {
122
139
  const requesterSessionId = getSessionId(ctx);
123
140
  const targetSessionId = resolvePermissionForwardingTargetSessionId({
@@ -159,11 +176,9 @@ export class ParentAuthorizer implements Authorizer {
159
176
 
160
177
  const request = this.buildForwardedRequest(
161
178
  ctx,
162
- message,
179
+ facts,
163
180
  requesterSessionId,
164
181
  targetSessionId,
165
- forwarded,
166
- sessionApproval,
167
182
  );
168
183
  const requestPath = join(location.requestsDir, `${request.id}.json`);
169
184
  const responsePath = join(location.responsesDir, `${request.id}.json`);
@@ -198,32 +213,47 @@ export class ParentAuthorizer implements Authorizer {
198
213
 
199
214
  private buildForwardedRequest(
200
215
  ctx: ForwarderContext,
201
- message: string,
216
+ facts: ForwardedRequestFacts,
202
217
  requesterSessionId: string,
203
218
  targetSessionId: string,
204
- forwarded?: ForwardedPromptDisplay,
205
- sessionApproval?: ForwardedSessionApproval,
206
219
  ): ForwardedPermissionRequest {
207
220
  const requestId = `${Date.now()}-${Math.random().toString(36).slice(2, 10)}-${process.pid}`;
208
221
  const requesterAgentName =
209
222
  getActiveAgentName(ctx) ??
210
223
  getActiveAgentNameFromSystemPrompt(getContextSystemPrompt(ctx)) ??
211
224
  "unknown";
225
+ // Complete the child-fixed facts into a full ForwardedAccessIntent: the
226
+ // gate fixed the access facts; the edge stamps the requester identity it
227
+ // alone knows (cwd + principal). The parent resolves against this intent
228
+ // and never re-derives the match set (ADR 0008).
229
+ const accessIntent = facts.accessIntent
230
+ ? {
231
+ ...facts.accessIntent,
232
+ requesterCwd: getCwd(ctx),
233
+ principal: {
234
+ sessionId: requesterSessionId,
235
+ agentName: requesterAgentName,
236
+ },
237
+ }
238
+ : undefined;
212
239
  return {
213
240
  id: requestId,
214
241
  createdAt: Date.now(),
215
242
  requesterSessionId,
216
243
  targetSessionId,
217
244
  requesterAgentName,
218
- message,
219
- ...(forwarded
245
+ message: facts.message,
246
+ ...(facts.display
220
247
  ? {
221
- source: forwarded.source,
222
- surface: forwarded.surface,
223
- value: forwarded.value,
248
+ source: facts.display.source,
249
+ surface: facts.display.surface,
250
+ value: facts.display.value,
224
251
  }
225
252
  : {}),
226
- ...(sessionApproval ? { sessionApproval } : {}),
253
+ ...(facts.sessionApproval
254
+ ? { sessionApproval: facts.sessionApproval }
255
+ : {}),
256
+ ...(accessIntent ? { accessIntent } : {}),
227
257
  };
228
258
  }
229
259
 
@@ -5,6 +5,7 @@ import {
5
5
  } from "#src/authority/forwarder-context";
6
6
  import type { PermissionPromptDecision } from "#src/authority/permission-dialog";
7
7
  import {
8
+ type ForwardedAccessIntent,
8
9
  type ForwardedPermissionRequest,
9
10
  type ForwardedPermissionResponse,
10
11
  isForwardedPermissionRequestForSession,
@@ -43,23 +44,25 @@ export interface InboxProcessor {
43
44
 
44
45
  /**
45
46
  * Recorded-authority view the serving node resolves a forwarded request
46
- * against: answer one `(surface, value)` query on the serving session's
47
- * composed base ruleset (agent-neutral the child already applied its own
48
- * per-agent overrides before forwarding).
47
+ * against: answer one {@link ForwardedAccessIntent} query on the serving
48
+ * session's composed ruleset, agent-scoped to the requester
49
+ * (`principal.agentName`, ADR 0008 §3) — the child-fixed `matchValues` are
50
+ * used as-is, never re-derived through this session's `PathNormalizer`/cwd.
49
51
  *
50
52
  * Narrow by design (ISP): the server needs one decision, not the whole
51
- * resolver. The composition root satisfies it with an access-intent build plus
52
- * `resolver.resolve`, the same primitives `LocalPermissionsService` composes.
53
+ * resolver. The composition root satisfies it with
54
+ * `buildResolvedIntentFromMatchValues` plus `resolver.resolve`, the same
55
+ * `resolve` entry point `LocalPermissionsService` composes.
53
56
  */
54
57
  export interface ServingPolicy {
55
- check(surface: string, value: string | null): PermissionCheckResult;
58
+ resolve(intent: ForwardedAccessIntent): PermissionCheckResult;
56
59
  }
57
60
 
58
61
  /** Constructor config for `ForwardedRequestServer`. */
59
62
  export interface ForwardedRequestServerDeps {
60
63
  forwardingDir: string;
61
64
  logger: DebugReviewLogger;
62
- /** Recorded-authority resolution for `(surface, value)` requests. */
65
+ /** Recorded-authority resolution for a forwarded `ForwardedAccessIntent`. */
63
66
  policy: ServingPolicy;
64
67
  /** Escalation seam to the serving session's selected `Authorizer` on `ask`. */
65
68
  escalator: AskEscalator;
@@ -87,22 +90,6 @@ function formatForwardedPermissionPrompt(
87
90
  ].join("\n");
88
91
  }
89
92
 
90
- /**
91
- * A request is resolvable against the ruleset only when it carries a concrete
92
- * `(surface, value)` display projection. A legacy/version-skew request without
93
- * them floors to `ask` (escalate), never a silent grant.
94
- */
95
- function hasDisplayFields(
96
- request: ForwardedPermissionRequest,
97
- ): request is ForwardedPermissionRequest & { surface: string; value: string } {
98
- return (
99
- typeof request.surface === "string" &&
100
- request.surface.length > 0 &&
101
- typeof request.value === "string" &&
102
- request.value.length > 0
103
- );
104
- }
105
-
106
93
  /**
107
94
  * Map a forwarded request onto the escalated ask's details, carrying the
108
95
  * forwarded provenance (requester agent/session + the child's original display
@@ -135,9 +122,10 @@ function buildForwardedAskDetails(
135
122
  /**
136
123
  * Owner of the serving-down role of the forwarded-permission behavior:
137
124
  * draining this session's forwarded-permission inbox and answering each
138
- * request the same way the session resolves a local action — `evaluate()`
139
- * against its recorded authority (`ServingPolicy`), then escalation to its
140
- * selected `Authorizer` (`AskEscalator`) on `ask`.
125
+ * request the same way the session resolves a local action — resolving its
126
+ * `ForwardedAccessIntent` against recorded authority (`ServingPolicy`), then
127
+ * escalation to its selected `Authorizer` (`AskEscalator`) on `ask` (ADR
128
+ * 0008).
141
129
  */
142
130
  export class ForwardedRequestServer implements InboxProcessor {
143
131
  private readonly forwardingDir: string;
@@ -343,17 +331,19 @@ export class ForwardedRequestServer implements InboxProcessor {
343
331
 
344
332
  /**
345
333
  * Resolve the request the same way the session resolves a local action:
346
- * recorded authority first (a request carrying `(surface, value)` resolves
347
- * against the serving node's composed ruleset — `allow`, including
348
- * yolo-rewritten, auto-approves; `deny` auto-denies), then escalate `ask`
349
- * (or a request without display fields) to the selected `Authorizer`.
334
+ * recorded authority first (a request carrying an `accessIntent` — the
335
+ * child-fixed facts, ADR 0008 §2 — resolves against the serving node's
336
+ * composed ruleset — `allow`, including yolo-rewritten, auto-approves;
337
+ * `deny` auto-denies), then escalate `ask` (or a request missing
338
+ * `accessIntent`, the version-skew floor, ADR 0008 §4) to the selected
339
+ * `Authorizer`.
350
340
  */
351
341
  private async resolveDecision(
352
342
  request: ForwardedPermissionRequest,
353
343
  logDetails: Record<string, unknown>,
354
344
  ): Promise<PermissionPromptDecision> {
355
- const state = hasDisplayFields(request)
356
- ? this.policy.check(request.surface, request.value).state
345
+ const state = request.accessIntent
346
+ ? this.policy.resolve(request.accessIntent).state
357
347
  : "ask";
358
348
 
359
349
  if (state === "allow") {
@@ -12,6 +12,8 @@ import type { PermissionDecisionUi } from "#src/authority/permission-dialog";
12
12
  export interface ForwarderContext {
13
13
  hasUI: boolean;
14
14
  ui: PermissionDecisionUi;
15
+ /** The session's working directory, stamped onto a forwarded request as the requester cwd. */
16
+ cwd: string;
15
17
  sessionManager: {
16
18
  getSessionId(): string;
17
19
  getSessionDir(): string;
@@ -19,6 +21,11 @@ export interface ForwarderContext {
19
21
  };
20
22
  }
21
23
 
24
+ /** Reads the current session cwd off `ctx`. */
25
+ export function getCwd(ctx: ForwarderContext): string {
26
+ return ctx.cwd;
27
+ }
28
+
22
29
  /** Reads the current session id off `ctx`, falling back to `"unknown"`. */
23
30
  export function getSessionId(ctx: ForwarderContext): string {
24
31
  try {
@@ -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(
@@ -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,5 +1,8 @@
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
7
  import type { Authorizer } from "./authorizer";
5
8
 
@@ -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
  /**
@@ -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,7 @@
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
5
  import { AuthorizerSelection } from "./authority/authorizer-selection";
6
6
  import {
7
7
  ForwardedRequestServer,
@@ -116,20 +116,18 @@ export default function piPermissionSystemExtension(pi: ExtensionAPI): void {
116
116
  // service and gates below share this one instance.
117
117
  const resolver = new PermissionResolver(permissionManager, sessionRules);
118
118
 
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.
119
+ // Serving a forwarded request is resolution: resolve the child-fixed
120
+ // ForwardedAccessIntent (ADR 0008) directly against the serving node's
121
+ // composed ruleset, agent-scoped to the requester (§3) the match values
122
+ // are used as fixed by the child, never re-derived through this session's
123
+ // PathNormalizer/cwd (#597).
125
124
  const servingPolicy: ServingPolicy = {
126
- check: (surface, value) =>
125
+ resolve: (intent) =>
127
126
  resolver.resolve(
128
- buildAccessIntentForSurface(
129
- surface,
130
- value ?? undefined,
131
- session.getPathNormalizer(),
132
- undefined,
127
+ buildResolvedIntentFromMatchValues(
128
+ intent.surface,
129
+ intent.matchValues,
130
+ intent.principal.agentName,
133
131
  ),
134
132
  ),
135
133
  };
@@ -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(),