@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 +23 -0
- package/README.md +1 -0
- package/docs/configuration.md +4 -0
- package/docs/subagent-integration.md +14 -1
- package/package.json +1 -1
- package/src/authority/approval-escalator.ts +8 -8
- package/src/authority/authorizer-selection.ts +43 -4
- package/src/authority/authorizer.ts +77 -23
- package/src/authority/forwarding-liveness.ts +5 -6
- package/src/authority/permission-forwarding.ts +9 -11
- package/src/authority/subagent-detection.ts +4 -3
- package/src/handlers/gates/descriptor.ts +75 -0
- package/src/handlers/gates/runner.ts +6 -16
- package/src/handlers/gates/tool-call-gate-pipeline.ts +14 -3
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`.
|
package/docs/configuration.md
CHANGED
|
@@ -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
|
|
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
|
@@ -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
|
|
177
|
-
*
|
|
178
|
-
* (formerly `ApprovalEscalator.requestApproval`'s
|
|
179
|
-
* arms, both dead once every caller routes through
|
|
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
|
-
|
|
225
|
-
//
|
|
226
|
-
//
|
|
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 `
|
|
52
|
-
* `
|
|
53
|
-
*
|
|
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
|
-
|
|
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:
|
|
81
|
-
*
|
|
82
|
-
* `detection.isSubagent(ctx)` alone would get that
|
|
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
|
|
121
|
-
*
|
|
130
|
+
* three-way local / relay / deny dispatch, and of the chain role that dispatch
|
|
131
|
+
* implies.
|
|
122
132
|
*
|
|
123
|
-
* Evaluated
|
|
124
|
-
*
|
|
125
|
-
*
|
|
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:
|
|
134
|
-
|
|
135
|
-
|
|
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:
|
|
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
|
|
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
|
|
291
|
-
* forwarding location.
|
|
290
|
+
* where that signal is unavailable.
|
|
292
291
|
*/
|
|
293
|
-
export type PermissionForwardingTargetSource = "
|
|
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.
|
|
19
|
-
*
|
|
20
|
-
*
|
|
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 —
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
}
|