@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
@@ -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,
@@ -33,7 +35,7 @@ import type { SubagentSessionRegistry } from "#src/authority/subagent-registry";
33
35
  import { buildUiPrompt } from "#src/permission-ui-prompt";
34
36
  import type { DebugReviewLogger } from "#src/session-logger";
35
37
  import { toRecord } from "#src/value-guards";
36
- import type { Authorizer } from "./authorizer";
38
+ import type { TerminalAuthorizer } from "./authorizer";
37
39
  import type { PromptPermissionDetails } from "./permission-prompter";
38
40
 
39
41
  // ── Module-private helpers ────────────────────────────────────────────────
@@ -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;
@@ -81,7 +100,7 @@ export interface ParentAuthorizerDeps {
81
100
  * (formerly `ApprovalEscalator.requestApproval`'s `hasUI` / `!isSubagent`
82
101
  * arms, both dead once every caller routes through `selectAuthorizer`).
83
102
  */
84
- export class ParentAuthorizer implements Authorizer {
103
+ export class ParentAuthorizer implements TerminalAuthorizer {
85
104
  private readonly forwardingDir: string;
86
105
  private readonly registry: SubagentSessionRegistry | undefined;
87
106
  private readonly logger: DebugReviewLogger;
@@ -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
 
@@ -0,0 +1,60 @@
1
+ import type { PermissionQuery } from "#src/service";
2
+ import type {
3
+ Authorizer,
4
+ AuthorizerVerdict,
5
+ TerminalAuthorizer,
6
+ } from "./authorizer";
7
+ import { createDeniedPermissionDecision } from "./permission-dialog";
8
+
9
+ /**
10
+ * Compose the live-authority chain (ADR 0007): try each non-terminal `link`
11
+ * in order, and on `defer` fall through to the next link, ending at the
12
+ * context-selected `terminal` that always decides.
13
+ *
14
+ * The signature is the type-level terminal-cannot-defer invariant: `links` are
15
+ * deferring {@link Authorizer}s while `terminal` is a {@link TerminalAuthorizer}
16
+ * (returns a full decision), so a deferring link cannot occupy the terminal
17
+ * slot.
18
+ *
19
+ * Each link is handed the session-scoped `query` at `authorize` time (ADR 0007
20
+ * §3) so it queries the deterministic engine at gate parity; the terminal never
21
+ * queries. With zero links the composed chain **is** the terminal instance
22
+ * (identity), so behavior is byte-identical to the pre-chain spine — the
23
+ * empty-links case that ships until a link registers.
24
+ */
25
+ export function composeAuthorizerChain(
26
+ links: readonly Authorizer[],
27
+ terminal: TerminalAuthorizer,
28
+ query: PermissionQuery,
29
+ ): TerminalAuthorizer {
30
+ if (links.length === 0) {
31
+ return terminal;
32
+ }
33
+ return {
34
+ async authorize(details) {
35
+ for (const link of links) {
36
+ const verdict = await link.authorize(details, query);
37
+ const decision = decideFromVerdict(verdict);
38
+ if (decision) {
39
+ return decision;
40
+ }
41
+ // `defer` \u2014 try the next link.
42
+ }
43
+ return terminal.authorize(details);
44
+ },
45
+ };
46
+ }
47
+
48
+ /** Map a link's decisive verdict to a decision; `defer` yields `null`. */
49
+ function decideFromVerdict(verdict: AuthorizerVerdict) {
50
+ switch (verdict.kind) {
51
+ case "allow":
52
+ // A link grant is non-persistent (state `approved`, never
53
+ // `approved_for_session`), per ADR 0007's off-by-default envelope.
54
+ return { approved: true, state: "approved" } as const;
55
+ case "deny":
56
+ return createDeniedPermissionDecision(verdict.reason);
57
+ case "defer":
58
+ return null;
59
+ }
60
+ }
@@ -0,0 +1,69 @@
1
+ /**
2
+ * Registry for named live-authority chain links (ADR 0007 §4).
3
+ *
4
+ * A downstream extension offers a named `Authorizer` link via
5
+ * `PermissionsService.registerAuthorizer`; this registry stores the link's
6
+ * `authorize` callback so composition can bind names to capabilities. One link
7
+ * per name; duplicate registration throws.
8
+ *
9
+ * Registration alone grants no authority — a link decides nothing until the
10
+ * operator names it in the `authorizerChain` config (the opt-in activation
11
+ * model). `AuthorizerSelection` owns that config-order resolution; this registry
12
+ * is storage only.
13
+ */
14
+
15
+ import type { Authorizer } from "./authorizer";
16
+
17
+ /**
18
+ * Read-only lookup used by chain composition (ISP — exposes only the read side,
19
+ * not the registration surface).
20
+ */
21
+ export interface AuthorizerLookup {
22
+ get(name: string): Authorizer["authorize"] | undefined;
23
+ }
24
+
25
+ /**
26
+ * Registration side of the registry (ISP — exposes only the write surface,
27
+ * mirroring the read-only {@link AuthorizerLookup}).
28
+ */
29
+ export interface AuthorizerRegistrar {
30
+ register(name: string, authorize: Authorizer["authorize"]): () => void;
31
+ }
32
+
33
+ /**
34
+ * Persistent registry mapping link names to their `authorize` callbacks.
35
+ *
36
+ * Owned by the extension factory (`index.ts`) so it survives across session
37
+ * activations. Exposed to sibling extensions via
38
+ * `PermissionsService.registerAuthorizer` and consulted by
39
+ * `AuthorizerSelection` during chain resolution.
40
+ */
41
+ export class AuthorizerRegistry
42
+ implements AuthorizerLookup, AuthorizerRegistrar
43
+ {
44
+ private readonly links = new Map<string, Authorizer["authorize"]>();
45
+
46
+ /**
47
+ * Register a link under `name`.
48
+ *
49
+ * Throws if a link is already registered for that name — keeps resolution
50
+ * deterministic (a pi-permission-system package priority). Returns a disposer
51
+ * that removes the link; the disposer is identity-guarded so a stale call
52
+ * cannot evict a later registration.
53
+ */
54
+ register(name: string, authorize: Authorizer["authorize"]): () => void {
55
+ if (this.links.has(name)) {
56
+ throw new Error(`An authorizer is already registered for '${name}'.`);
57
+ }
58
+ this.links.set(name, authorize);
59
+ return () => {
60
+ if (this.links.get(name) === authorize) {
61
+ this.links.delete(name);
62
+ }
63
+ };
64
+ }
65
+
66
+ get(name: string): Authorizer["authorize"] | undefined {
67
+ return this.links.get(name);
68
+ }
69
+ }
@@ -1,10 +1,15 @@
1
1
  import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
2
2
  import type { PermissionPromptDecision } from "#src/authority/permission-dialog";
3
+ import type { PermissionQuery } from "#src/service";
3
4
  import {
4
5
  type Authorizer,
5
6
  type AuthorizerSelectionDeps,
6
7
  selectAuthorizer,
8
+ type TerminalAuthorizer,
7
9
  } from "./authorizer";
10
+ import { composeAuthorizerChain } from "./authorizer-chain";
11
+ import type { AuthorizerLookup } from "./authorizer-registry";
12
+ import { encloseInDelegationEnvelope } from "./delegation-envelope";
8
13
  import type {
9
14
  PermissionPrompterApi,
10
15
  PromptPermissionDetails,
@@ -49,38 +54,79 @@ export interface AskEscalator {
49
54
  export class AuthorizerSelection
50
55
  implements AskEscalator, AuthorizerSelectionLifecycle
51
56
  {
52
- private selected: Authorizer | null = null;
57
+ private terminal: TerminalAuthorizer | null = null;
53
58
 
54
59
  constructor(
55
60
  private readonly deps: AuthorizerSelectionDeps & {
56
61
  prompter: PermissionPrompterApi;
62
+ /** The session-scoped query injected into each chain link (ADR 0007 §3). */
63
+ getPermissionQuery: () => PermissionQuery;
64
+ /** Read-only lookup of registered links by name. */
65
+ authorizerRegistry: AuthorizerLookup;
66
+ /** The operator's configured link names, read live per ask. */
67
+ getAuthorizerChain: () => string[];
57
68
  },
58
69
  ) {}
59
70
 
60
- /** Select the Authorizer for `ctx` and store it. */
71
+ /**
72
+ * Select the terminal Authorizer for `ctx` and store it. The non-terminal
73
+ * chain is composed per ask in {@link escalate}, not here: ADR 0007 §4 lets a
74
+ * link register in a `permissions:ready` handler that may fire after
75
+ * activation, so link resolution is deferred to the session's first ask.
76
+ */
61
77
  activate(ctx: ExtensionContext): void {
62
- this.selected = selectAuthorizer(ctx, this.deps);
78
+ this.terminal = selectAuthorizer(ctx, this.deps);
79
+ }
80
+
81
+ /**
82
+ * Resolve the operator's `authorizerChain` names to registered links, in
83
+ * config order (ADR 0007 invariant 1). An unregistered name is skipped with a
84
+ * warning (invariant 2 — more prompting, never less); each resolved link is
85
+ * wrapped in the bounded-delegation envelope so an `allow` on an excluded
86
+ * surface cannot exceed the operator's policy.
87
+ */
88
+ private resolveConfiguredLinks(): Authorizer[] {
89
+ const links: Authorizer[] = [];
90
+ for (const name of this.deps.getAuthorizerChain()) {
91
+ const authorize = this.deps.authorizerRegistry.get(name);
92
+ if (authorize === undefined) {
93
+ this.deps.logger.review("authorizer_chain_unregistered_link", { name });
94
+ continue;
95
+ }
96
+ links.push({ authorize: encloseInDelegationEnvelope(authorize) });
97
+ }
98
+ return links;
63
99
  }
64
100
 
65
101
  /** Clear the stored selection. */
66
102
  deactivate(): void {
67
- this.selected = null;
103
+ this.terminal = null;
68
104
  }
69
105
 
70
106
  /**
71
- * Escalate an ask to the selected authorizer and return its decision.
107
+ * Escalate an ask through the composed chain and return its decision.
108
+ *
109
+ * Resolves the configured links freshly (so a link registered any time before
110
+ * this first ask is honored) and composes them ahead of the selected
111
+ * terminal. With zero links the composed value **is** the terminal instance,
112
+ * so behavior is identical to a bare terminal escalation.
72
113
  *
73
- * Rejects if no authorizer has been selected — i.e. before the session was
114
+ * Rejects if no terminal has been selected — i.e. before the session was
74
115
  * activated. Implements {@link AskEscalator}.
75
116
  */
76
117
  escalate(
77
118
  details: PromptPermissionDetails,
78
119
  ): Promise<PermissionPromptDecision> {
79
- if (this.selected === null) {
120
+ if (this.terminal === null) {
80
121
  return Promise.reject(
81
122
  new Error("escalate called before the session was activated"),
82
123
  );
83
124
  }
84
- return this.deps.prompter.prompt(this.selected, details);
125
+ const chain = composeAuthorizerChain(
126
+ this.resolveConfiguredLinks(),
127
+ this.terminal,
128
+ this.deps.getPermissionQuery(),
129
+ );
130
+ return this.deps.prompter.prompt(chain, details);
85
131
  }
86
132
  }
@@ -6,6 +6,7 @@ import type {
6
6
  } from "#src/authority/permission-prompt-component";
7
7
  import type { SubagentSessionRegistry } from "#src/authority/subagent-registry";
8
8
  import type { PermissionEventBus } from "#src/permission-events";
9
+ import type { PermissionQuery } from "#src/service";
9
10
  import type { DebugReviewLogger } from "#src/session-logger";
10
11
  import { ParentAuthorizer } from "./approval-escalator";
11
12
  import { DenyingAuthorizer } from "./denying-authorizer";
@@ -14,15 +15,41 @@ import type { PromptPermissionDetails } from "./permission-prompter";
14
15
  import type { SubagentDetector } from "./subagent-detection";
15
16
 
16
17
  /**
17
- * The live-authority role: on `ask`, an `Authorizer` rules on a single
18
- * request and is told the decision.
18
+ * A non-terminal chain link's ruling on an `ask`: decide (`allow`/`deny`) or
19
+ * pass the ask on to the next link (`defer`). A `deny` carries an optional
20
+ * teaching `reason` the invoking model sees, so it can self-correct.
21
+ */
22
+ export type AuthorizerVerdict =
23
+ | { kind: "allow" }
24
+ | { kind: "deny"; reason?: string }
25
+ | { kind: "defer" };
26
+
27
+ /**
28
+ * A non-terminal link in the live-authority chain: reviews an `ask` and may
29
+ * decide it or defer to the next link (ADR 0007). The chain injects a narrow,
30
+ * session-scoped {@link PermissionQuery} at `authorize` time (§3), so a link
31
+ * queries the deterministic engine at gate parity rather than reaching for the
32
+ * cross-extension service via `Symbol.for()`.
33
+ */
34
+ export interface Authorizer {
35
+ authorize(
36
+ details: PromptPermissionDetails,
37
+ query: PermissionQuery,
38
+ ): Promise<AuthorizerVerdict>;
39
+ }
40
+
41
+ /**
42
+ * The terminal link: on `ask`, rules on a single request and is told the
43
+ * decision. Structurally cannot defer — it always returns a full
44
+ * {@link PermissionPromptDecision}, which is the type-level enforcement of
45
+ * ADR 0007's terminal-cannot-defer invariant.
19
46
  *
20
47
  * One method, one responsibility. `DenyingAuthorizer` ignores `details`;
21
48
  * `LocalUserAuthorizer` reads `message`/`sessionLabel` and derives the UI
22
49
  * event from it; `ParentAuthorizer` reads `message` and derives the
23
50
  * forwarded display from it.
24
51
  */
25
- export interface Authorizer {
52
+ export interface TerminalAuthorizer {
26
53
  authorize(
27
54
  details: PromptPermissionDetails,
28
55
  ): Promise<PermissionPromptDecision>;
@@ -56,7 +83,7 @@ export interface AuthorizerSelectionDeps {
56
83
  export function selectAuthorizer(
57
84
  ctx: ExtensionContext,
58
85
  deps: AuthorizerSelectionDeps,
59
- ): Authorizer {
86
+ ): TerminalAuthorizer {
60
87
  if (ctx.hasUI) {
61
88
  return new LocalUserAuthorizer({
62
89
  ui: ctx.ui,
@@ -0,0 +1,52 @@
1
+ /**
2
+ * The bounded-delegation enforcement checkpoint (ADR 0007 §5).
3
+ *
4
+ * The chain owner caps every registered link's verdict so a buggy or over-eager
5
+ * external judge can never exceed the operator's policy: a link's `allow` on an
6
+ * excluded surface is downgraded to `defer`, letting the `ask` fall through to
7
+ * the terminal (a prompt) instead. The checkpoint only ever *tightens* a
8
+ * verdict — it never turns a `defer`/`deny` into an `allow`.
9
+ *
10
+ * The excluded set is the whole `path` surface plus `external_directory`. A
11
+ * finer secret-shaped-`path` exclusion (letting a link allow a non-secret path)
12
+ * is deferred to the allow-capable slice that needs it (#620); until then the
13
+ * conservative whole-surface exclusion ships. The checkpoint is dormant while
14
+ * the only registered links are deny-first (they never `allow`).
15
+ */
16
+
17
+ import type { Authorizer } from "./authorizer";
18
+ import type { PromptPermissionDetails } from "./permission-prompter";
19
+
20
+ /** Surfaces on which a link may never grant an `allow` (ADR 0007 §5). */
21
+ export const DELEGATION_EXCLUDED_SURFACES: ReadonlySet<string> = new Set([
22
+ "external_directory",
23
+ "path",
24
+ ]);
25
+
26
+ /**
27
+ * Wrap a link's `authorize` so an `allow` on an excluded surface is capped to
28
+ * `defer`. All other verdicts, and `allow`s on non-excluded surfaces, pass
29
+ * through unchanged. `details` and the injected `query` are forwarded as-is.
30
+ */
31
+ export function encloseInDelegationEnvelope(
32
+ authorize: Authorizer["authorize"],
33
+ ): Authorizer["authorize"] {
34
+ return async (details, query) => {
35
+ const verdict = await authorize(details, query);
36
+ if (verdict.kind === "allow" && isExcludedSurface(details)) {
37
+ return { kind: "defer" };
38
+ }
39
+ return verdict;
40
+ };
41
+ }
42
+
43
+ /**
44
+ * Whether the ask's surface is excluded from link grants. Reads the
45
+ * gate-authoritative `accessIntent.surface`, falling back to the display
46
+ * `surface`. Fail-safe: an ask whose surface cannot be determined is treated as
47
+ * excluded (more prompting, never less — ADR 0007 invariant 2).
48
+ */
49
+ function isExcludedSurface(details: PromptPermissionDetails): boolean {
50
+ const surface = details.accessIntent?.surface ?? details.surface ?? undefined;
51
+ return surface === undefined || DELEGATION_EXCLUDED_SURFACES.has(surface);
52
+ }
@@ -1,5 +1,5 @@
1
1
  import type { PermissionPromptDecision } from "#src/authority/permission-dialog";
2
- import type { Authorizer } from "./authorizer";
2
+ import type { TerminalAuthorizer } from "./authorizer";
3
3
 
4
4
  /**
5
5
  * Least-privilege Authorizer: no authority is reachable for this session
@@ -9,7 +9,7 @@ import type { Authorizer } from "./authorizer";
9
9
  * distinguish "nobody could answer" from an interactive user denial when it
10
10
  * derives the review-entry and decision-event resolution.
11
11
  */
12
- export class DenyingAuthorizer implements Authorizer {
12
+ export class DenyingAuthorizer implements TerminalAuthorizer {
13
13
  authorize(): Promise<PermissionPromptDecision> {
14
14
  return Promise.resolve({
15
15
  approved: false,
@@ -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 {