@gotgenes/pi-permission-system 31.1.4 → 32.0.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,29 @@ 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
+ ## [32.0.1](https://github.com/gotgenes/pi-packages/compare/pi-permission-system-v32.0.0...pi-permission-system-v32.0.1) (2026-09-11)
9
+
10
+
11
+ ### Bug Fixes
12
+
13
+ * **pi-permission-system:** deny a forbidden command without prompting first ([b840196](https://github.com/gotgenes/pi-packages/commit/b8401962a9eeb012317771fc204c3fde8aa8a9a2)), closes [#899](https://github.com/gotgenes/pi-packages/issues/899)
14
+
15
+ ### Documentation
16
+
17
+ * **pi-permission-system:** document that a deny on any layer needs no prompt ([e526e95](https://github.com/gotgenes/pi-packages/commit/e526e950ab6d957e6b5bc4d6f7740bcce2e35dca)), closes [#899](https://github.com/gotgenes/pi-packages/issues/899)
18
+
19
+ ## [32.0.0](https://github.com/gotgenes/pi-packages/compare/pi-permission-system-v31.1.4...pi-permission-system-v32.0.0) (2026-09-11)
20
+
21
+
22
+ ### Features
23
+
24
+ * **pi-permission-system:** **breaking:** prompt in the parent session for a subagent that has its own UI ([24b56a2](https://github.com/gotgenes/pi-packages/commit/24b56a2181df3db8b17e45b1acefd5a53fe2eddf)), closes [#907](https://github.com/gotgenes/pi-packages/issues/907), closes [#909](https://github.com/gotgenes/pi-packages/issues/909)
25
+ * **pi-permission-system:** record when a session starts or stops relaying its asks ([7d1e2bb](https://github.com/gotgenes/pi-packages/commit/7d1e2bbe69d59360a66a27d3277d41aedecbefb5)), closes [#909](https://github.com/gotgenes/pi-packages/issues/909)
26
+
27
+ ### Documentation
28
+
29
+ * **pi-permission-system:** document relaying from a session that has its own UI ([0250d68](https://github.com/gotgenes/pi-packages/commit/0250d68bb3a89e32782836ed1b6a17d4e52d25e8)), closes [#909](https://github.com/gotgenes/pi-packages/issues/909)
30
+
8
31
  ## [31.1.4](https://github.com/gotgenes/pi-packages/compare/pi-permission-system-v31.1.3...pi-permission-system-v31.1.4) (2026-09-11)
9
32
 
10
33
 
package/README.md CHANGED
@@ -97,6 +97,7 @@ The trailing `*` is greedy and crosses subdirectory boundaries, so it allows eve
97
97
 
98
98
  Four layers compose with most-restrictive-wins: `path` (cross-cutting) → `external_directory` (CWD boundary) → per-tool patterns → `bash` command patterns.
99
99
  Because `ask` is more restrictive than `allow`, a `path` allow cannot loosen an `external_directory: ask` boundary — allow outside-CWD directories on `external_directory`.
100
+ And because `deny` is more restrictive than `ask`, a `deny` on any layer refuses the call without prompting, naming the rule that decided.
100
101
  See [docs/configuration.md](docs/configuration.md) for the full recipe.
101
102
 
102
103
  Both path surfaces also carry a **direction**, so you can permit reading somewhere without permitting writing there: `path_read`, `path_write`, `external_directory_read`, and `external_directory_write`.
@@ -559,6 +559,10 @@ Use `path` to **deny** sensitive files everywhere (`.env`, `~/.ssh/*`); use `ext
559
559
  Because the layers compose with most-restrictive-wins, a `path` allow cannot loosen an `external_directory: ask` boundary — `ask` is more restrictive than `allow`, so the prompt still fires.
560
560
  Adding `"~/.cargo/registry": "allow"` to the `path` surface therefore does **not** stop the outside-CWD prompt; put the rule on `external_directory` instead (see below).
561
561
 
562
+ The same ordering runs the other way at the top of the scale.
563
+ `deny` is more restrictive than `ask`, so a `deny` on any layer refuses the call **without prompting**, whichever layer carries the rule.
564
+ A `bash: {"find / *": "deny"}` rule therefore suppresses the outside-CWD prompt that `find /` would otherwise raise, and the refusal names the `bash` rule that decided rather than the boundary that asked.
565
+
562
566
  Configs without a `path` key behave identically to before — the gate does not fire.
563
567
  When no `path` key is present, the universal fallback (`permission["*"]`) applies: `"*": "allow"` keeps the gate transparent, while `"*": "deny"` would deny all file access via every surface including `path`.
564
568
 
@@ -63,6 +63,19 @@ A value naming the reading session itself is ignored as a forwarding target, sin
63
63
  That matters when an implementation rewrites an inherited marker with the current session's id: doing so in a child destroys the only record of its real parent, and the child's asks then fail closed with an unresolved-target error.
64
64
  Guard such a rewrite on the process being a root — for example, skip it when a child marker such as `PI_SUBAGENT_CHILD` is present.
65
65
 
66
+ #### Children that keep a UI of their own
67
+
68
+ An implementation may spawn each child as a full interactive session — a visible pane per agent, for observability and direct inspection — while keeping permission authority with the human at the lead session.
69
+ A child like that forwards its asks to the session the variable names, rather than opening its own dialog, for as long as that session is draining its forwarded-permission inbox.
70
+ It is the declared target that decides this, so an implementation opts in by naming a parent, and opts out by not naming one; nothing else is required of it.
71
+
72
+ The liveness condition is what keeps a visible child usable.
73
+ A child whose named parent has exited, been killed, or stopped polling opens its own dialog instead of refusing the tool call, and the decision is remade on every turn: a pane whose lead exits mid-run returns to prompting locally at its next turn, and one whose lead comes back starts forwarding again.
74
+ Only an ask already in flight when the parent disappears is refused, with the usual approval-unavailable reason.
75
+
76
+ A session that relays still serves its own inbox, so a `lead → agent → agent` tree relays through the middle hop.
77
+ What it does not do is run its own authorizer chain: live authority converges at the node that decides, which is the serving one ([ADR 0007] §7).
78
+
66
79
  ### What an implementation does not owe
67
80
 
68
81
  None of the following is an implementation's responsibility, on either process shape:
@@ -132,7 +145,7 @@ When `@gotgenes/pi-permission-system` is not installed, an implementation emits
132
145
 
133
146
  ## Permission Forwarding
134
147
 
135
- When a delegated or routed subagent runs without direct UI access, `ask` permissions can still be enforced by forwarding the confirmation request through Pi session directories.
148
+ When a delegated or routed subagent cannot decide an `ask` where it runs — because it has no UI of its own, or because it names a parent session that is answering for it — the confirmation request is forwarded through Pi session directories instead.
136
149
  The main interactive session polls for forwarded requests, shows the confirmation prompt, writes the response, and the subagent resumes once that decision is available.
137
150
  A parent `allow`/`deny` rule governs a child's escalation directly (the serving node resolves it as recorded authority before prompting), and a "whole session" grant recorded on the parent auto-approves later forwards of the same pattern.
138
151
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gotgenes/pi-permission-system",
3
- "version": "31.1.4",
3
+ "version": "32.0.1",
4
4
  "description": "Permission enforcement extension for the Pi coding agent.",
5
5
  "type": "module",
6
6
  "exports": {
@@ -173,10 +173,11 @@ function forwardableRequestId(requesterRequestId: string): string {
173
173
  * Owns the escalation-up role of the forwarded-permission behavior: builds
174
174
  * and persists a request file, then polls for the parent session's
175
175
  * response. `ctx` is bound once at construction — `selectAuthorizer` only
176
- * constructs a `ParentAuthorizer` for a context it has already confirmed has
177
- * no UI and is a subagent, so `authorize` never re-derives that dispatch
178
- * (formerly `ApprovalEscalator.requestApproval`'s `hasUI` / `!isSubagent`
179
- * arms, both dead once every caller routes through `selectAuthorizer`).
176
+ * constructs a `ParentAuthorizer` for a context it has already established is a
177
+ * child, whether that child has a UI of its own (#909) or not, so `authorize`
178
+ * never re-derives that dispatch (formerly `ApprovalEscalator.requestApproval`'s
179
+ * `hasUI` / `!isSubagent` arms, both dead once every caller routes through
180
+ * `selectAuthorizer`).
180
181
  */
181
182
  export class ParentAuthorizer implements TerminalAuthorizer {
182
183
  private readonly forwardingDir: string;
@@ -221,10 +222,9 @@ export class ParentAuthorizer implements TerminalAuthorizer {
221
222
  ): Promise<PermissionPromptDecision> {
222
223
  const requesterSessionId = getSessionId(ctx);
223
224
  const target = resolvePermissionForwardingTarget({
224
- hasUI: ctx.hasUI,
225
- // Invariant: selectAuthorizer only selects ParentAuthorizer for a
226
- // no-UI subagent context, so this is always true — no detection dep
227
- // needed to re-derive it here.
225
+ // Invariant: selectAuthorizer only selects ParentAuthorizer for a context
226
+ // it has already established is a child — no detection dep needed to
227
+ // re-derive it here.
228
228
  isSubagent: true,
229
229
  currentSessionId: requesterSessionId,
230
230
  env: process.env,
@@ -10,6 +10,7 @@ import { composeAuthorizerChain } from "./authorizer-chain";
10
10
  import type { AuthorizerLookup } from "./authorizer-registry";
11
11
  import { encloseInDelegationEnvelope } from "./delegation-envelope";
12
12
  import type { PermissionPromptDecision } from "./permission-dialog";
13
+ import type { PermissionForwardingTarget } from "./permission-forwarding";
13
14
  import type {
14
15
  PermissionPrompterApi,
15
16
  PromptPermissionDetails,
@@ -48,9 +49,13 @@ export interface AskEscalator {
48
49
  * so a sibling extension learns it without knowing what a subagent is) and by
49
50
  * the registration observer (which records a link registered where no chain
50
51
  * runs). Both depend on this single-method view rather than the selection
51
- * itself, and neither may re-derive the role from `detection.isSubagent(ctx)`:
52
- * `selectAuthorizer` tests `hasUI` first, so a subagent with its own UI
53
- * adjudicates locally.
52
+ * itself, and neither may re-derive the role from `ctx.hasUI` or
53
+ * `detection.isSubagent(ctx)`: a node with a UI relays when it names another
54
+ * session that is draining its inbox, and decides locally otherwise (#909).
55
+ *
56
+ * Because the selection is remade on every activation, the answer can change
57
+ * within one session — a node stops relaying as soon as its declared parent
58
+ * stops serving.
54
59
  */
55
60
  export interface AdjudicationRole {
56
61
  adjudicatesLocally(): boolean;
@@ -72,6 +77,7 @@ export class AuthorizerSelection
72
77
  implements AskEscalator, AuthorizerSelectionLifecycle, AdjudicationRole
73
78
  {
74
79
  private authority: SelectedAuthority | null = null;
80
+ private relayTarget: PermissionForwardingTarget | null = null;
75
81
 
76
82
  constructor(
77
83
  private readonly deps: AuthorizerSelectionDeps & {
@@ -92,7 +98,39 @@ export class AuthorizerSelection
92
98
  * activation, so link resolution is deferred to the session's first ask.
93
99
  */
94
100
  activate(ctx: ExtensionContext): void {
95
- this.authority = selectAuthorizer(ctx, this.deps);
101
+ const authority = selectAuthorizer(ctx, this.deps);
102
+ this.recordRelayTransition(authority.relayTarget ?? null);
103
+ this.authority = authority;
104
+ }
105
+
106
+ /**
107
+ * Record that this node started, stopped, or redirected its relaying.
108
+ *
109
+ * `activate` runs on every turn event, so only a change is worth a line: the
110
+ * pair reads beside the serving node's own
111
+ * `forwarded_permission.serving_started`/`serving_stopped`, which is what
112
+ * makes a misdirected relay a one-line diff across the two sessions. A node
113
+ * that never relays writes nothing at all.
114
+ */
115
+ private recordRelayTransition(
116
+ target: PermissionForwardingTarget | null,
117
+ ): void {
118
+ const previous = this.relayTarget;
119
+ if (previous?.sessionId === target?.sessionId) {
120
+ return;
121
+ }
122
+ this.relayTarget = target;
123
+ if (previous !== null) {
124
+ this.deps.logger.review("forwarded_permission.relay_stopped", {
125
+ targetSessionId: previous.sessionId,
126
+ });
127
+ }
128
+ if (target !== null) {
129
+ this.deps.logger.review("forwarded_permission.relay_started", {
130
+ targetSessionId: target.sessionId,
131
+ channel: target.source,
132
+ });
133
+ }
96
134
  }
97
135
 
98
136
  /**
@@ -181,6 +219,7 @@ export class AuthorizerSelection
181
219
 
182
220
  /** Clear the stored selection. */
183
221
  deactivate(): void {
222
+ this.recordRelayTransition(null);
184
223
  this.authority = null;
185
224
  }
186
225
 
@@ -4,9 +4,12 @@ import type { AuthorizerLog, PermissionQuery } from "#src/service";
4
4
  import type { PermissionEventBus } from "#src/service/permission-events";
5
5
  import { ParentAuthorizer } from "./approval-escalator";
6
6
  import { DenyingAuthorizer } from "./denying-authorizer";
7
+ import { getSessionId } from "./forwarder-context";
7
8
  import type { TargetServingLookup } from "./forwarding-liveness";
8
9
  import { LocalUserAuthorizer } from "./local-user-authorizer";
9
10
  import type { PermissionPromptDecision } from "./permission-dialog";
11
+ import type { PermissionForwardingTarget } from "./permission-forwarding";
12
+ import { resolvePermissionForwardingTarget } from "./permission-forwarding";
10
13
  import type {
11
14
  PromptPreferences,
12
15
  requestPermissionDecision,
@@ -77,9 +80,10 @@ export interface TerminalAuthorizer {
77
80
  * whether this node adjudicates them with its own chain.
78
81
  *
79
82
  * The chain role is the selection's product, not a discriminator a consumer
80
- * re-derives: `selectAuthorizer` tests `hasUI` before `isSubagent`, so a
81
- * subagent that has its own UI decides locally, and re-deriving the role from
82
- * `detection.isSubagent(ctx)` alone would get that case wrong.
83
+ * re-derives: a node with a UI decides locally unless it names another session
84
+ * that is draining its forwarded-permission inbox (#909), so re-deriving the
85
+ * role from `ctx.hasUI` or `detection.isSubagent(ctx)` alone would get that
86
+ * case wrong in opposite directions.
83
87
  */
84
88
  export interface SelectedAuthority {
85
89
  /** The terminal that decides this node's asks, or relays them upward. */
@@ -92,6 +96,12 @@ export interface SelectedAuthority {
92
96
  * twice.
93
97
  */
94
98
  readonly adjudicatesLocally: boolean;
99
+ /**
100
+ * The target this selection itself verified as live, for the relay-transition
101
+ * record. Absent on the headless relay arm, where the target is resolved per
102
+ * ask by `ParentAuthorizer` rather than at selection.
103
+ */
104
+ readonly relayTarget?: PermissionForwardingTarget;
95
105
  }
96
106
 
97
107
  /** Construction inputs for {@link selectAuthorizer}. */
@@ -117,40 +127,84 @@ export interface AuthorizerSelectionDeps {
117
127
 
118
128
  /**
119
129
  * Select the live authority for the current context: the single owner of the
120
- * three-way `hasUI` / `isSubagent` / deny dispatch, and of the chain role that
121
- * dispatch implies.
130
+ * three-way local / relay / deny dispatch, and of the chain role that dispatch
131
+ * implies.
122
132
  *
123
- * Evaluated once per session activation (`AuthorizerSelection.activate`),
124
- * replacing the re-derivation of the same predicates across
125
- * `PromptingGateway`, `PermissionPrompter`, and `ApprovalEscalator`.
133
+ * Evaluated on every session activation (`AuthorizerSelection.activate`), which
134
+ * is what lets a node with a UI follow its declared parent's liveness: the
135
+ * moment that parent stops serving, the next activation selects the local
136
+ * dialog again.
126
137
  */
127
138
  export function selectAuthorizer(
128
139
  ctx: ExtensionContext,
129
140
  deps: AuthorizerSelectionDeps,
130
141
  ): SelectedAuthority {
131
142
  if (ctx.hasUI) {
143
+ const relayTarget = resolveLiveRelayTarget(ctx, deps);
144
+ if (relayTarget === null) {
145
+ return {
146
+ terminal: new LocalUserAuthorizer({
147
+ ui: ctx.ui,
148
+ mode: ctx.mode,
149
+ events: deps.events,
150
+ getPromptPreferences: deps.getPromptPreferences,
151
+ requestPermissionDecision: deps.requestPermissionDecision,
152
+ }),
153
+ adjudicatesLocally: true,
154
+ };
155
+ }
132
156
  return {
133
- terminal: new LocalUserAuthorizer({
134
- ui: ctx.ui,
135
- mode: ctx.mode,
136
- events: deps.events,
137
- getPromptPreferences: deps.getPromptPreferences,
138
- requestPermissionDecision: deps.requestPermissionDecision,
139
- }),
140
- adjudicatesLocally: true,
157
+ terminal: buildParentAuthorizer(ctx, deps),
158
+ adjudicatesLocally: false,
159
+ relayTarget,
141
160
  };
142
161
  }
143
162
  if (deps.detection.isSubagent(ctx)) {
144
163
  return {
145
- terminal: new ParentAuthorizer(ctx, {
146
- forwardingDir: deps.forwardingDir,
147
- registry: deps.registry,
148
- serving: deps.serving,
149
- getTimeoutMs: deps.getForwardingTimeoutMs,
150
- logger: deps.logger,
151
- }),
164
+ terminal: buildParentAuthorizer(ctx, deps),
152
165
  adjudicatesLocally: false,
153
166
  };
154
167
  }
155
168
  return { terminal: new DenyingAuthorizer(), adjudicatesLocally: true };
156
169
  }
170
+
171
+ /**
172
+ * The session a node with a UI should relay to, or `null` when it should decide
173
+ * for itself.
174
+ *
175
+ * A human is present here, so only a definite "yes" relays: a target nobody can
176
+ * confirm is draining its inbox leaves the ask with the human who is already
177
+ * watching. That is the opposite burden of proof from
178
+ * `ParentAuthorizer.checkServingLiveness`, where an unjudgeable target waits
179
+ * out the timeout because a headless child has no alternative.
180
+ */
181
+ function resolveLiveRelayTarget(
182
+ ctx: ExtensionContext,
183
+ deps: AuthorizerSelectionDeps,
184
+ ): PermissionForwardingTarget | null {
185
+ const sessionId = getSessionId(ctx);
186
+ const target = resolvePermissionForwardingTarget({
187
+ isSubagent: deps.detection.isSubagent(ctx),
188
+ currentSessionId: sessionId,
189
+ sessionId,
190
+ registry: deps.registry,
191
+ });
192
+ if (target === null || deps.serving.isServing(target) !== true) {
193
+ return null;
194
+ }
195
+ return target;
196
+ }
197
+
198
+ /** The relaying terminal for `ctx`, built from the selection's own deps. */
199
+ function buildParentAuthorizer(
200
+ ctx: ExtensionContext,
201
+ deps: AuthorizerSelectionDeps,
202
+ ): ParentAuthorizer {
203
+ return new ParentAuthorizer(ctx, {
204
+ forwardingDir: deps.forwardingDir,
205
+ registry: deps.registry,
206
+ serving: deps.serving,
207
+ getTimeoutMs: deps.getForwardingTimeoutMs,
208
+ logger: deps.logger,
209
+ });
210
+ }
@@ -92,8 +92,7 @@ export interface HeartbeatReader {
92
92
  * Keyed on the target rather than a session id because the answer depends on
93
93
  * how the target was resolved. An in-process child and its parent share a
94
94
  * `globalThis`, so the registry answers for them; an out-of-process pair shares
95
- * only the filesystem; and a session that owns the inbox it is forwarding to is
96
- * not a case either channel describes.
95
+ * only the filesystem.
97
96
  *
98
97
  * Consolidating that into one collaborator is what keeps `ParentAuthorizer`
99
98
  * from holding two lookups and re-deciding which one applies — the decision has
@@ -108,6 +107,10 @@ export interface TargetServingLookup {
108
107
 
109
108
  /** What answered a liveness question, and what it saw. */
110
109
  export interface ServingObservation {
110
+ /**
111
+ * Which channel answered. `"none"` belongs to a lookup that consulted none —
112
+ * the real judge never gives it, since every target it sees names a channel.
113
+ */
111
114
  channel: "registry" | "heartbeat" | "none";
112
115
  /** The heartbeat state behind a `"heartbeat"` answer; `null` on the other channels. */
113
116
  state: HeartbeatState | null;
@@ -138,8 +141,6 @@ export class ForwardingLivenessJudge implements TargetServingLookup {
138
141
  return this.deps.registry.isServing(target.sessionId);
139
142
  case "env":
140
143
  return this.deps.heartbeats.read(target.sessionId) === "alive";
141
- case "self":
142
- return null;
143
144
  }
144
145
  }
145
146
 
@@ -157,8 +158,6 @@ export class ForwardingLivenessJudge implements TargetServingLookup {
157
158
  state: this.deps.heartbeats.read(target.sessionId),
158
159
  servingIds: this.deps.heartbeats.servingIds(),
159
160
  };
160
- case "self":
161
- return { channel: "none", state: null, servingIds: [] };
162
161
  }
163
162
  }
164
163
  }
@@ -287,10 +287,9 @@ export function createPermissionForwardingLocation(
287
287
  * **in-process** child of `sessionId`, so the two share a `globalThis` and the
288
288
  * requester may consult the serving-session registry to decide whether anyone
289
289
  * is draining its inbox. `"env"` means the target lives in another process,
290
- * where that signal is unavailable; `"self"` is the UI host owning its own
291
- * forwarding location.
290
+ * where that signal is unavailable.
292
291
  */
293
- export type PermissionForwardingTargetSource = "self" | "registry" | "env";
292
+ export type PermissionForwardingTargetSource = "registry" | "env";
294
293
 
295
294
  /** The resolved forwarding target together with how it was found. */
296
295
  export interface PermissionForwardingTarget {
@@ -298,8 +297,14 @@ export interface PermissionForwardingTarget {
298
297
  source: PermissionForwardingTargetSource;
299
298
  }
300
299
 
300
+ /**
301
+ * The session this node relays its asks to, or `null` when it has none.
302
+ *
303
+ * Answers only "which *other* session", never "myself": a node that owns its
304
+ * forwarding location has nothing to resolve, and a request filed into one's
305
+ * own inbox is drained by no watcher.
306
+ */
301
307
  export function resolvePermissionForwardingTarget(options: {
302
- hasUI: boolean;
303
308
  isSubagent: boolean;
304
309
  currentSessionId?: string | null;
305
310
  env?: NodeJS.ProcessEnv;
@@ -308,13 +313,6 @@ export function resolvePermissionForwardingTarget(options: {
308
313
  /** In-process subagent session registry (checked before env vars). */
309
314
  registry?: SubagentSessionRegistry;
310
315
  }): PermissionForwardingTarget | null {
311
- if (options.hasUI) {
312
- const own = normalizePermissionForwardingSessionId(
313
- options.currentSessionId,
314
- );
315
- return own === null ? null : { sessionId: own, source: "self" };
316
- }
317
-
318
316
  if (!options.isSubagent) {
319
317
  return null;
320
318
  }
@@ -15,9 +15,10 @@ import type { SubagentSessionRegistry } from "./subagent-registry";
15
15
  * It answers "is this process a child", which is **not** "should this node relay
16
16
  * rather than decide". A UI host answers `true` here whenever its process
17
17
  * carries a parent-session marker — a spawner may export one from the root so
18
- * the children it launches inherit it. Every consumer therefore tests `hasUI`
19
- * first: `selectAuthorizer` returns before reaching this predicate, and serving
20
- * eligibility does not consult it at all (#907).
18
+ * the children it launches inherit it. No consumer may read it as a relay
19
+ * decision: `selectAuthorizer` relays a node with a UI only when a forwarding
20
+ * target resolves *and* that target is serving (#909), and serving eligibility
21
+ * does not consult this predicate at all (#907).
21
22
  */
22
23
  export interface SubagentDetector {
23
24
  isSubagent(ctx: SubagentDetectionContext): boolean;
@@ -97,6 +97,81 @@ export interface GateBypass {
97
97
  /** Union of possible gate function return values. */
98
98
  export type GateResult = GateDescriptor | GateBypass | null;
99
99
 
100
+ // ── Resolved-state readers ─────────────────────────────────────────────────
101
+
102
+ /**
103
+ * The permission check a descriptor already carries, or `null` when it
104
+ * resolves nothing of its own.
105
+ *
106
+ * Every tool-call gate resolves its own state before the runner sees it —
107
+ * five of the six stamp a full `preCheck`, and the skill-read gate stamps the
108
+ * `preResolved` state it read off the matched skill entry. This is the one
109
+ * place that precedence is expressed, so the runner and the pre-emption
110
+ * predicate cannot answer it differently.
111
+ *
112
+ * A `null` answer is not "allow": it means the caller must resolve the
113
+ * descriptor itself.
114
+ */
115
+ export function preResolvedCheckOf(
116
+ descriptor: GateDescriptor,
117
+ ): PermissionCheckResult | null {
118
+ if (descriptor.preCheck) {
119
+ return descriptor.preCheck;
120
+ }
121
+ if (descriptor.preResolved) {
122
+ return {
123
+ state: descriptor.preResolved.state,
124
+ toolName: descriptor.surface,
125
+ source: "tool",
126
+ origin: "builtin",
127
+ };
128
+ }
129
+ return null;
130
+ }
131
+
132
+ /**
133
+ * Whether this gate blocks without escalating, whatever the other gates say.
134
+ *
135
+ * A `deny` is absorbing: wherever it sits in the pipeline's order, the call is
136
+ * refused, so no other gate's answer — and no human's — can change the
137
+ * outcome. That is what makes running it first an ordering change rather than
138
+ * a semantic one, and it is why the same treatment is *not* extended to `ask`
139
+ * (#915): two asking gates ask two different questions.
140
+ *
141
+ * Subordinate to {@link GateRunner.runDescriptor}'s own precedence, which
142
+ * tests `source === "session"` before the deny/ask/allow gate is reached — a
143
+ * session-sourced check is allowed there, so it is not pre-emptive here.
144
+ * `SessionRules` records only allows, so that combination is unreachable
145
+ * today; the clause is kept so the predicate is correct on its own terms
146
+ * rather than by way of a distant invariant, and it errs toward today's
147
+ * behavior by declining to pre-empt.
148
+ *
149
+ * Yolo needs no clause: `resolveYoloGrant` matches an `allow` of origin
150
+ * `yolo` and an `ask`, never a `deny`.
151
+ */
152
+ export function isUnconditionalDeny(gate: GateResult): boolean {
153
+ if (!isGateDescriptor(gate)) {
154
+ return false;
155
+ }
156
+ const check = preResolvedCheckOf(gate);
157
+ return check !== null && check.state === "deny" && check.source !== "session";
158
+ }
159
+
160
+ /**
161
+ * The gates in run order, with any unconditional deny moved to the front.
162
+ *
163
+ * A stable partition, so two denying gates keep their relative order (the
164
+ * earlier one still decides, exactly as before) and the remainder keeps its
165
+ * own. With no deny present the array is returned unchanged.
166
+ */
167
+ export function orderDenyFirst(gates: GateResult[]): GateResult[] {
168
+ const denying = gates.filter((gate) => isUnconditionalDeny(gate));
169
+ if (denying.length === 0) {
170
+ return gates;
171
+ }
172
+ return [...denying, ...gates.filter((gate) => !isUnconditionalDeny(gate))];
173
+ }
174
+
100
175
  // ── Type guard helpers ─────────────────────────────────────────────────────
101
176
 
102
177
  /** Check whether a GateResult is a GateBypass (early allow). */
@@ -12,13 +12,12 @@ import {
12
12
  } from "#src/presentation/agent-renderer";
13
13
  import { renderReviewLogFacts } from "#src/presentation/review-log-renderer";
14
14
  import type { SessionApprovalRecorder } from "#src/session/session-approval-recorder";
15
- import type { PermissionCheckResult } from "#src/types";
16
15
  import type {
17
16
  DecisionEventFacts,
18
17
  GateDescriptor,
19
18
  GateResult,
20
19
  } from "./descriptor";
21
- import { isGateBypass } from "./descriptor";
20
+ import { isGateBypass, preResolvedCheckOf } from "./descriptor";
22
21
  import { buildDecisionEvent, resolveYoloGrant } from "./helpers";
23
22
  import type { GateOutcome } from "./types";
24
23
 
@@ -89,25 +88,16 @@ export class GateRunner {
89
88
  agentName: string | null,
90
89
  requestId: string,
91
90
  ): Promise<GateOutcome> {
92
- // 1. Resolve permission state — pre-check, pre-resolved, or via resolver
93
- let check: PermissionCheckResult;
94
- if (descriptor.preCheck) {
95
- check = descriptor.preCheck;
96
- } else if (descriptor.preResolved) {
97
- check = {
98
- state: descriptor.preResolved.state,
99
- toolName: descriptor.surface,
100
- source: "tool",
101
- origin: "builtin",
102
- };
103
- } else {
104
- check = this.resolver.resolve({
91
+ // 1. Resolve permission state — what the descriptor already carries, or
92
+ // via the resolver when it carries nothing.
93
+ const check =
94
+ preResolvedCheckOf(descriptor) ??
95
+ this.resolver.resolve({
105
96
  kind: "tool",
106
97
  surface: descriptor.surface,
107
98
  input: descriptor.input,
108
99
  agentName: agentName ?? undefined,
109
100
  });
110
- }
111
101
 
112
102
  // The fields every review-log write for this gate shares, whatever the
113
103
  // resolution — built once so a field added here reaches all of them. The
@@ -18,7 +18,7 @@ import type { PermissionCheckResult } from "#src/types";
18
18
  import { resolveBashCommandCheck } from "./bash-command";
19
19
  import { describeBashExternalDirectoryGate } from "./bash-external-directory";
20
20
  import { describeBashPathGate } from "./bash-path";
21
- import type { GateResult } from "./descriptor";
21
+ import { type GateResult, orderDenyFirst } from "./descriptor";
22
22
  import { describeExternalDirectoryGate } from "./external-directory";
23
23
  import { describePathGate } from "./path";
24
24
  import type { GateRunner } from "./runner";
@@ -62,7 +62,8 @@ export interface ToolCallGateInputs {
62
62
  * - `ToolPreviewFormatter` construction from `getToolPreviewLimits()`
63
63
  * - infrastructure-dir list from `getInfrastructureReadDirs()`
64
64
  * - all six gate producers in their prescribed order
65
- * - the run loop that returns the first block outcome, or allow
65
+ * - the run loop, which runs an unconditionally denying gate ahead of the
66
+ * rest and returns the first block outcome, or allow
66
67
  */
67
68
  export class ToolCallGatePipeline {
68
69
  constructor(
@@ -141,8 +142,18 @@ export class ToolCallGatePipeline {
141
142
  },
142
143
  ];
143
144
 
145
+ // Produce every gate before running any of them, so an unconditional deny
146
+ // on a later gate is known before an earlier one suspends the call on an
147
+ // `ask` nobody's answer could change (#899). Producing is side-effect-free
148
+ // — all logging and event emission happens inside `runner.run` — and the
149
+ // loop below already produced every gate on any call it did not block.
150
+ const gates: GateResult[] = [];
144
151
  for (const produce of gateProducers) {
145
- const outcome = await runner.run(await produce(), tcc.agentName);
152
+ gates.push(await produce());
153
+ }
154
+
155
+ for (const gate of orderDenyFirst(gates)) {
156
+ const outcome = await runner.run(gate, tcc.agentName);
146
157
  if (outcome.action === "block") {
147
158
  return outcome;
148
159
  }