@gotgenes/pi-permission-system 26.0.0 → 26.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +33 -0
- package/docs/configuration.md +14 -14
- package/docs/subagent-integration.md +36 -3
- package/package.json +1 -1
- package/src/authority/approval-escalator.ts +49 -16
- package/src/authority/authorizer-chain.ts +39 -11
- package/src/authority/authorizer-selection.ts +5 -5
- package/src/authority/authorizer.ts +17 -4
- package/src/authority/decision-source.ts +235 -0
- package/src/authority/denying-authorizer.ts +4 -0
- package/src/authority/forwarded-request-server.ts +44 -12
- package/src/authority/forwarding-io.ts +5 -0
- package/src/authority/forwarding-liveness.ts +450 -0
- package/src/authority/forwarding-manager.ts +21 -0
- package/src/authority/permission-dialog.ts +23 -2
- package/src/authority/permission-forwarding.ts +22 -1
- package/src/authority/permission-prompt-component.ts +34 -10
- package/src/authority/permission-prompt-decision.ts +7 -3
- package/src/authority/permission-prompter.ts +8 -0
- package/src/authority/serving-registry.ts +33 -0
- package/src/handlers/gates/bash-external-directory.ts +9 -0
- package/src/handlers/gates/bash-path.ts +8 -0
- package/src/handlers/gates/descriptor.ts +9 -0
- package/src/handlers/gates/external-directory.ts +2 -0
- package/src/handlers/gates/runner.ts +20 -0
- package/src/handlers/tool-call-boundary.ts +4 -1
- package/src/index.ts +24 -3
- package/src/permission-gate.ts +11 -0
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,39 @@ 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
|
+
## [26.2.0](https://github.com/gotgenes/pi-packages/compare/pi-permission-system-v26.1.0...pi-permission-system-v26.2.0) (2026-08-17)
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
### Features
|
|
12
|
+
|
|
13
|
+
* **pi-permission-system:** publish a serving heartbeat while draining the inbox ([2055e35](https://github.com/gotgenes/pi-packages/commit/2055e353e147af6738ddc23c2c755f56f8032f8b)), closes [#721](https://github.com/gotgenes/pi-packages/issues/721)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
### Bug Fixes
|
|
17
|
+
|
|
18
|
+
* **pi-permission-system:** fail fast when an out-of-process parent is not serving ([e190af9](https://github.com/gotgenes/pi-packages/commit/e190af9f06c49375432f19bb228e068ade01c912)), closes [#721](https://github.com/gotgenes/pi-packages/issues/721)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
### Documentation
|
|
22
|
+
|
|
23
|
+
* **pi-permission-system:** document out-of-process forwarding liveness ([b5a50d5](https://github.com/gotgenes/pi-packages/commit/b5a50d535c97ca0ec388c5f482afb395e15289fa)), closes [#721](https://github.com/gotgenes/pi-packages/issues/721)
|
|
24
|
+
|
|
25
|
+
## [26.1.0](https://github.com/gotgenes/pi-packages/compare/pi-permission-system-v26.0.0...pi-permission-system-v26.1.0) (2026-08-17)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
### Features
|
|
29
|
+
|
|
30
|
+
* **pi-permission-system:** attribute absent-authority denials ([225412d](https://github.com/gotgenes/pi-packages/commit/225412d485e68e886b6e958b2f53a87f9ff6cf25)), closes [#726](https://github.com/gotgenes/pi-packages/issues/726)
|
|
31
|
+
* **pi-permission-system:** carry decision provenance across the forwarding boundary ([0dbf13f](https://github.com/gotgenes/pi-packages/commit/0dbf13f86c9f2d070e8b33eb4ffe808331c9185c)), closes [#726](https://github.com/gotgenes/pi-packages/issues/726)
|
|
32
|
+
* **pi-permission-system:** name the authorizer link that decided an ask ([8556724](https://github.com/gotgenes/pi-packages/commit/8556724e5453ee002b24c48bebaf4d96f6502fe5)), closes [#726](https://github.com/gotgenes/pi-packages/issues/726)
|
|
33
|
+
* **pi-permission-system:** record the decider on non-prompting resolutions ([5e24abf](https://github.com/gotgenes/pi-packages/commit/5e24abf6311d13928858e730b14a712e155f2bc2)), closes [#726](https://github.com/gotgenes/pi-packages/issues/726)
|
|
34
|
+
* **pi-permission-system:** record the human decider on prompted decisions ([9f540ba](https://github.com/gotgenes/pi-packages/commit/9f540ba285b546976432733fced08b9fe9dc973c)), closes [#726](https://github.com/gotgenes/pi-packages/issues/726)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
### Documentation
|
|
38
|
+
|
|
39
|
+
* **pi-permission-system:** record decision provenance and mark Phase 13 Step 6 complete ([e2739e7](https://github.com/gotgenes/pi-packages/commit/e2739e7f188669ee4878bbe3732958d4e1c54cbd)), closes [#726](https://github.com/gotgenes/pi-packages/issues/726)
|
|
40
|
+
|
|
8
41
|
## [26.0.0](https://github.com/gotgenes/pi-packages/compare/pi-permission-system-v25.4.0...pi-permission-system-v26.0.0) (2026-08-16)
|
|
9
42
|
|
|
10
43
|
|
package/docs/configuration.md
CHANGED
|
@@ -97,20 +97,20 @@ This clamp is deny-preserving and, like `yoloMode`, applied at composition; when
|
|
|
97
97
|
|
|
98
98
|
## Runtime Knobs
|
|
99
99
|
|
|
100
|
-
| Key | Default | Description
|
|
101
|
-
| --------------------------- | -------- |
|
|
102
|
-
| `debugLog` | `false` | Enables verbose diagnostic logging to `logs/pi-permission-system-debug.jsonl`
|
|
103
|
-
| `permissionReviewLog` | `true` | Enables the permission request/denial review log at `logs/pi-permission-system-permission-review.jsonl`. Records bash command strings unredacted — see [Log file sensitivity](#log-file-sensitivity)
|
|
104
|
-
| `yoloMode` | `false` | Auto-approves `ask` results instead of prompting when yolo mode is enabled
|
|
105
|
-
| `doublePressToConfirm` | `true` | Requires a confirming second press of a decision hotkey in the inline TUI dialog (see below). TUI sessions only; set to `false` for single-press.
|
|
106
|
-
| `forwardingTimeoutMs` | `600000` | How long a subagent waits for the parent session to answer a forwarded permission request, in milliseconds. A child whose
|
|
107
|
-
| `promptMaxRows` | `24` | Max rows a permission prompt renders before eliding its evidence. The request's own facts are never elided by this budget; `Ctrl+O` expands the prompt to the complete request.
|
|
108
|
-
| `promptFieldMaxWidth` | `400` | Max characters of any one field shown in a permission prompt. This is what bounds a single long field (a here-string command, say) that would otherwise fill the prompt through wrapping.
|
|
109
|
-
| `reviewLogFieldMaxWidth` | `1000` | Max characters of any one value written to the review log. A longer value is stored shortened, marked with an ellipsis. Raise it to keep longer values; this is a length bound, not redaction.
|
|
110
|
-
| `toolInputPreviewMaxLength` | — | **Deprecated and ignored.** Superseded by `promptMaxRows` / `promptFieldMaxWidth`. Still accepted so an existing config is not rejected, but the value no longer applies; setting it logs a warning.
|
|
111
|
-
| `toolTextSummaryMaxLength` | — | **Deprecated and ignored.** Superseded by `promptMaxRows` / `promptFieldMaxWidth`. Still accepted so an existing config is not rejected, but the value no longer applies; setting it logs a warning.
|
|
112
|
-
| `piInfrastructureReadPaths` | `[]` | Extra directories to auto-allow for reads, bypassing the `external_directory` gate. Supports `~`/`$HOME`/`${HOME}` expansion and wildcard patterns (`*`, `?`).
|
|
113
|
-
| `authorizerChain` | `[]` | Ordered names of registered live-authority chain links to consult before the terminal authorizer (see [Authorizer chain](#authorizer-chain--case-by-case-decision-links)).
|
|
100
|
+
| Key | Default | Description |
|
|
101
|
+
| --------------------------- | -------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
102
|
+
| `debugLog` | `false` | Enables verbose diagnostic logging to `logs/pi-permission-system-debug.jsonl` |
|
|
103
|
+
| `permissionReviewLog` | `true` | Enables the permission request/denial review log at `logs/pi-permission-system-permission-review.jsonl`. Records bash command strings unredacted — see [Log file sensitivity](#log-file-sensitivity) |
|
|
104
|
+
| `yoloMode` | `false` | Auto-approves `ask` results instead of prompting when yolo mode is enabled |
|
|
105
|
+
| `doublePressToConfirm` | `true` | Requires a confirming second press of a decision hotkey in the inline TUI dialog (see below). TUI sessions only; set to `false` for single-press. |
|
|
106
|
+
| `forwardingTimeoutMs` | `600000` | How long a subagent waits for the parent session to answer a forwarded permission request, in milliseconds. A child whose parent is not draining its inbox gives up in ~2 s regardless, whether that parent runs in this process or its own. |
|
|
107
|
+
| `promptMaxRows` | `24` | Max rows a permission prompt renders before eliding its evidence. The request's own facts are never elided by this budget; `Ctrl+O` expands the prompt to the complete request. |
|
|
108
|
+
| `promptFieldMaxWidth` | `400` | Max characters of any one field shown in a permission prompt. This is what bounds a single long field (a here-string command, say) that would otherwise fill the prompt through wrapping. |
|
|
109
|
+
| `reviewLogFieldMaxWidth` | `1000` | Max characters of any one value written to the review log. A longer value is stored shortened, marked with an ellipsis. Raise it to keep longer values; this is a length bound, not redaction. |
|
|
110
|
+
| `toolInputPreviewMaxLength` | — | **Deprecated and ignored.** Superseded by `promptMaxRows` / `promptFieldMaxWidth`. Still accepted so an existing config is not rejected, but the value no longer applies; setting it logs a warning. |
|
|
111
|
+
| `toolTextSummaryMaxLength` | — | **Deprecated and ignored.** Superseded by `promptMaxRows` / `promptFieldMaxWidth`. Still accepted so an existing config is not rejected, but the value no longer applies; setting it logs a warning. |
|
|
112
|
+
| `piInfrastructureReadPaths` | `[]` | Extra directories to auto-allow for reads, bypassing the `external_directory` gate. Supports `~`/`$HOME`/`${HOME}` expansion and wildcard patterns (`*`, `?`). |
|
|
113
|
+
| `authorizerChain` | `[]` | Ordered names of registered live-authority chain links to consult before the terminal authorizer (see [Authorizer chain](#authorizer-chain--case-by-case-decision-links)). |
|
|
114
114
|
|
|
115
115
|
Both logs write to `~/.pi/agent/extensions/pi-permission-system/logs/`.
|
|
116
116
|
No debug output is printed to the terminal.
|
|
@@ -35,7 +35,20 @@ For in-process child sessions, detection and forwarding use the event-driven reg
|
|
|
35
35
|
### When nobody answers
|
|
36
36
|
|
|
37
37
|
A forwarded request is only useful if some session is draining the inbox it was written into.
|
|
38
|
-
The polling session publishes the session id it polls, and
|
|
38
|
+
The polling session publishes the session id it polls, and a child checks that its target is published before committing to a long wait.
|
|
39
|
+
|
|
40
|
+
The announcement goes out on two channels, because a child cannot always reach the same one.
|
|
41
|
+
A child running inside its parent's process reads a process-global registry.
|
|
42
|
+
A child running as a separate `pi` process (the `PI_SUBAGENT_PARENT_SESSION` path) shares no memory with its parent, so it reads a heartbeat record the serving session refreshes under `<agent dir>/sessions/permission-forwarding/serving/`, holding the served session id, the serving process id, and the time it was last refreshed.
|
|
43
|
+
|
|
44
|
+
For an out-of-process target, four things count as "not draining":
|
|
45
|
+
|
|
46
|
+
| What the child finds | What it means |
|
|
47
|
+
| ------------------------------------------------- | ---------------------------------------------------------------------------- |
|
|
48
|
+
| No record | The parent exited, never served, or runs a version that does not publish one |
|
|
49
|
+
| A record naming a process that is gone | The parent was killed rather than shut down |
|
|
50
|
+
| A record nobody has refreshed for several seconds | The parent's process survives but has stopped polling |
|
|
51
|
+
| A record for a different session id | The child is forwarding somewhere nobody is listening |
|
|
39
52
|
|
|
40
53
|
If the target is not draining its inbox, the child gives up after a two-second grace window rather than waiting out `forwardingTimeoutMs`, and the tool is blocked with:
|
|
41
54
|
|
|
@@ -47,6 +60,7 @@ permission requests.
|
|
|
47
60
|
|
|
48
61
|
The grace window exists so a request that arrives while the parent is switching sessions is not abandoned in the gap.
|
|
49
62
|
A target that *is* draining its inbox is waited on for the full `forwardingTimeoutMs`, however long the human takes to decide.
|
|
63
|
+
That includes a parent whose human is still deliberating at an earlier forwarded prompt: it keeps refreshing its heartbeat throughout, so a second child does not read it as gone.
|
|
50
64
|
|
|
51
65
|
Every other way the forwarding path can give up — an unresolvable parent session, forwarding directories that cannot be created, a request that cannot be written, an unreadable response, and the timeout itself — is reported the same way: as approval being unavailable, with a reason naming the specific failure.
|
|
52
66
|
None of them is reported as a user denial, because no user was ever asked.
|
|
@@ -54,8 +68,27 @@ None of them is reported as a user denial, because no user was ever asked.
|
|
|
54
68
|
The two sides of the exchange are correlatable in the review log: the serving session writes `forwarded_permission.serving_started` with the id it polls, and the child writes `forwarded_permission.request_created` with the `targetSessionId` it forwarded to.
|
|
55
69
|
When a forwarded request goes unanswered, comparing those two entries distinguishes a parent that was not polling from one polling a different session.
|
|
56
70
|
|
|
57
|
-
|
|
58
|
-
|
|
71
|
+
When a forwarded request *is* answered, the child's own terminal entry names both which session answered and what within it decided.
|
|
72
|
+
The serving node records its decider on the response — a rule of its own (with the surface, pattern, and origin that matched), the link that ruled, or the human who answered its dialog — and the child records it nested under a `forwarded` frame:
|
|
73
|
+
|
|
74
|
+
```json
|
|
75
|
+
{
|
|
76
|
+
"kind": "forwarded",
|
|
77
|
+
"responderSessionId": "019ff969-c34c-70be-9034-fae19c852932",
|
|
78
|
+
"decision": { "kind": "user", "via": "dialog" }
|
|
79
|
+
}
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
That is the difference between a human approving a subagent's request and the parent's policy approving it on their behalf — two outcomes that were previously indistinguishable in the log.
|
|
83
|
+
An older parent that sends no decider yields `"decision": null`: the hop is still recorded, and the answer is still honored.
|
|
84
|
+
|
|
85
|
+
### Upgrading
|
|
86
|
+
|
|
87
|
+
Upgrade the parent before relying on the out-of-process signal — in practice, restart the interactive session after upgrading the package.
|
|
88
|
+
|
|
89
|
+
A parent session still running a version that predates the heartbeat publishes none, and a child on a version that expects one reads that absence as "not draining" and gives up in about two seconds.
|
|
90
|
+
That only happens in the window where an upgrade lands while a parent session is already running, and it resolves as soon as that session restarts.
|
|
91
|
+
Nothing needs to be edited, and in-process children are unaffected: parent and child there are the same running copy by construction.
|
|
59
92
|
|
|
60
93
|
---
|
|
61
94
|
|
package/package.json
CHANGED
|
@@ -19,10 +19,12 @@ import {
|
|
|
19
19
|
sleep,
|
|
20
20
|
writeJsonFileAtomic,
|
|
21
21
|
} from "#src/authority/forwarding-io";
|
|
22
|
+
import type { TargetServingLookup } from "#src/authority/forwarding-liveness";
|
|
22
23
|
import type { PermissionPromptDecision } from "#src/authority/permission-dialog";
|
|
23
24
|
import {
|
|
24
25
|
type ForwardedAccessFacts,
|
|
25
26
|
type ForwardedPermissionRequest,
|
|
27
|
+
type ForwardedPermissionResponse,
|
|
26
28
|
type ForwardedPromptDisplay,
|
|
27
29
|
type ForwardedSessionApproval,
|
|
28
30
|
PERMISSION_FORWARDING_POLL_INTERVAL_MS,
|
|
@@ -32,7 +34,6 @@ import {
|
|
|
32
34
|
resolvePermissionForwardingTarget,
|
|
33
35
|
SUBAGENT_PARENT_SESSION_ENV_CANDIDATES,
|
|
34
36
|
} from "#src/authority/permission-forwarding";
|
|
35
|
-
import type { ServingLookup } from "#src/authority/serving-registry";
|
|
36
37
|
import type { SubagentSessionRegistry } from "#src/authority/subagent-registry";
|
|
37
38
|
import { createPermissionRequestId } from "#src/permission-request-id";
|
|
38
39
|
import { buildUiPrompt } from "#src/permission-ui-prompt";
|
|
@@ -96,8 +97,8 @@ export interface ParentAuthorizerDeps {
|
|
|
96
97
|
forwardingDir: string;
|
|
97
98
|
/** In-process subagent session registry for forwarding target resolution. */
|
|
98
99
|
registry?: SubagentSessionRegistry;
|
|
99
|
-
/** Whether the resolved target is draining its inbox
|
|
100
|
-
serving:
|
|
100
|
+
/** Whether the resolved target is draining its inbox, on whichever channel can say. */
|
|
101
|
+
serving: TargetServingLookup;
|
|
101
102
|
/** How long to wait for the target's answer, read live so config edits apply. */
|
|
102
103
|
getTimeoutMs: () => number;
|
|
103
104
|
logger: DebugReviewLogger;
|
|
@@ -110,6 +111,9 @@ export interface ParentAuthorizerDeps {
|
|
|
110
111
|
* `confirmationUnavailable` is what keeps this out of the "User denied …"
|
|
111
112
|
* message (#719): a user who was never asked denied nothing. `denialReason`
|
|
112
113
|
* names which path gave up, and the gate renders it to the model.
|
|
114
|
+
*
|
|
115
|
+
* The provenance record reuses that same string rather than restating it, so
|
|
116
|
+
* what the model is told and what the log attributes cannot drift (#726).
|
|
113
117
|
*/
|
|
114
118
|
function abandon(denialReason: string): PermissionPromptDecision {
|
|
115
119
|
return {
|
|
@@ -117,6 +121,31 @@ function abandon(denialReason: string): PermissionPromptDecision {
|
|
|
117
121
|
state: "denied",
|
|
118
122
|
confirmationUnavailable: true,
|
|
119
123
|
denialReason,
|
|
124
|
+
decidedBy: { kind: "unavailable", reason: denialReason },
|
|
125
|
+
};
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Adopt the responder's answer, recording the hop it came through.
|
|
130
|
+
*
|
|
131
|
+
* The requester's own terminal entry has to answer two questions, and they are
|
|
132
|
+
* different: *which session* answered, and *what within it* decided. Nesting
|
|
133
|
+
* keeps both rather than flattening the responder's source into this node's
|
|
134
|
+
* record, where it would read as a local decision (#726).
|
|
135
|
+
*
|
|
136
|
+
* A responder that sent no usable source yields `decision: null` — the hop is
|
|
137
|
+
* still a fact, and an older parent is not an error.
|
|
138
|
+
*/
|
|
139
|
+
function relayDecision(
|
|
140
|
+
response: ForwardedPermissionResponse,
|
|
141
|
+
): PermissionPromptDecision {
|
|
142
|
+
return {
|
|
143
|
+
...response,
|
|
144
|
+
decidedBy: {
|
|
145
|
+
kind: "forwarded",
|
|
146
|
+
responderSessionId: response.responderSessionId,
|
|
147
|
+
decision: response.decidedBy ?? null,
|
|
148
|
+
},
|
|
120
149
|
};
|
|
121
150
|
}
|
|
122
151
|
|
|
@@ -152,7 +181,7 @@ function forwardableRequestId(requesterRequestId: string): string {
|
|
|
152
181
|
export class ParentAuthorizer implements TerminalAuthorizer {
|
|
153
182
|
private readonly forwardingDir: string;
|
|
154
183
|
private readonly registry: SubagentSessionRegistry | undefined;
|
|
155
|
-
private readonly serving:
|
|
184
|
+
private readonly serving: TargetServingLookup;
|
|
156
185
|
private readonly getTimeoutMs: () => number;
|
|
157
186
|
private readonly logger: DebugReviewLogger;
|
|
158
187
|
|
|
@@ -335,6 +364,7 @@ export class ParentAuthorizer implements TerminalAuthorizer {
|
|
|
335
364
|
this.logger,
|
|
336
365
|
responsePath,
|
|
337
366
|
);
|
|
367
|
+
const relayed = response ? relayDecision(response) : null;
|
|
338
368
|
this.logger.review("forwarded_permission.response_received", {
|
|
339
369
|
requestId,
|
|
340
370
|
approved: response?.approved ?? null,
|
|
@@ -343,10 +373,11 @@ export class ParentAuthorizer implements TerminalAuthorizer {
|
|
|
343
373
|
responderSessionId: response?.responderSessionId ?? null,
|
|
344
374
|
targetSessionId,
|
|
345
375
|
responsePath,
|
|
376
|
+
decidedBy: relayed?.decidedBy,
|
|
346
377
|
});
|
|
347
378
|
this.discardRequest(location, requestPath, responsePath);
|
|
348
379
|
return (
|
|
349
|
-
|
|
380
|
+
relayed ??
|
|
350
381
|
abandon("The parent session's permission response could not be read")
|
|
351
382
|
);
|
|
352
383
|
}
|
|
@@ -356,11 +387,17 @@ export class ParentAuthorizer implements TerminalAuthorizer {
|
|
|
356
387
|
unservedSince !== null &&
|
|
357
388
|
Date.now() - unservedSince >= PERMISSION_FORWARDING_SERVING_GRACE_MS
|
|
358
389
|
) {
|
|
390
|
+
const observation = this.serving.describe(target);
|
|
359
391
|
this.logger.review("forwarded_permission.no_serving_session", {
|
|
360
392
|
requestId,
|
|
361
393
|
requesterSessionId: request.requesterSessionId,
|
|
362
394
|
targetSessionId,
|
|
363
|
-
|
|
395
|
+
// Which channel answered, and what it saw: the difference between a
|
|
396
|
+
// parent that exited, one that was killed, and one polling under a
|
|
397
|
+
// different session id is the whole diagnosis of a stalled forward.
|
|
398
|
+
servingChannel: observation.channel,
|
|
399
|
+
servingState: observation.state,
|
|
400
|
+
servingSessionIds: observation.servingIds,
|
|
364
401
|
});
|
|
365
402
|
this.discardRequest(location, requestPath);
|
|
366
403
|
return abandon(
|
|
@@ -390,21 +427,17 @@ export class ParentAuthorizer implements TerminalAuthorizer {
|
|
|
390
427
|
/**
|
|
391
428
|
* Track how long the target has looked unserved, or `null` while it looks fine.
|
|
392
429
|
*
|
|
393
|
-
*
|
|
394
|
-
*
|
|
395
|
-
*
|
|
430
|
+
* Which channel can answer for this target is the judge's decision, not this
|
|
431
|
+
* one's: a target it cannot judge answers `null`, which resets the window
|
|
432
|
+
* exactly as "serving" does, so an unjudgeable target waits out the timeout.
|
|
396
433
|
*/
|
|
397
434
|
private checkServingLiveness(
|
|
398
435
|
target: PermissionForwardingTarget,
|
|
399
436
|
unservedSince: number | null,
|
|
400
437
|
): number | null {
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
if (this.serving.isServing(target.sessionId)) {
|
|
405
|
-
return null;
|
|
406
|
-
}
|
|
407
|
-
return unservedSince ?? Date.now();
|
|
438
|
+
return this.serving.isServing(target) === false
|
|
439
|
+
? (unservedSince ?? Date.now())
|
|
440
|
+
: null;
|
|
408
441
|
}
|
|
409
442
|
|
|
410
443
|
/**
|
|
@@ -1,10 +1,14 @@
|
|
|
1
|
+
import type { DecisionSource } from "#src/authority/decision-source";
|
|
1
2
|
import type { AuthorizerLog, PermissionQuery } from "#src/service";
|
|
2
3
|
import type {
|
|
3
|
-
Authorizer,
|
|
4
4
|
AuthorizerVerdict,
|
|
5
|
+
NamedAuthorizer,
|
|
5
6
|
TerminalAuthorizer,
|
|
6
7
|
} from "./authorizer";
|
|
7
|
-
import {
|
|
8
|
+
import {
|
|
9
|
+
createDeniedPermissionDecision,
|
|
10
|
+
type PermissionPromptDecision,
|
|
11
|
+
} from "./permission-dialog";
|
|
8
12
|
|
|
9
13
|
/**
|
|
10
14
|
* Compose the live-authority chain (ADR 0007): try each non-terminal `link`
|
|
@@ -12,9 +16,9 @@ import { createDeniedPermissionDecision } from "./permission-dialog";
|
|
|
12
16
|
* context-selected `terminal` that always decides.
|
|
13
17
|
*
|
|
14
18
|
* The signature is the type-level terminal-cannot-defer invariant: `links` are
|
|
15
|
-
* deferring {@link
|
|
16
|
-
* (returns a full decision), so a deferring link
|
|
17
|
-
* slot.
|
|
19
|
+
* deferring {@link NamedAuthorizer}s while `terminal` is a
|
|
20
|
+
* {@link TerminalAuthorizer} (returns a full decision), so a deferring link
|
|
21
|
+
* cannot occupy the terminal slot.
|
|
18
22
|
*
|
|
19
23
|
* Each link is handed the session-scoped `query` and the review-log `log` at
|
|
20
24
|
* `authorize` time (ADR 0007 §3) so it queries the deterministic engine at gate
|
|
@@ -24,7 +28,7 @@ import { createDeniedPermissionDecision } from "./permission-dialog";
|
|
|
24
28
|
* ships until a link registers.
|
|
25
29
|
*/
|
|
26
30
|
export function composeAuthorizerChain(
|
|
27
|
-
links: readonly
|
|
31
|
+
links: readonly NamedAuthorizer[],
|
|
28
32
|
terminal: TerminalAuthorizer,
|
|
29
33
|
query: PermissionQuery,
|
|
30
34
|
log: AuthorizerLog,
|
|
@@ -36,7 +40,7 @@ export function composeAuthorizerChain(
|
|
|
36
40
|
async authorize(details) {
|
|
37
41
|
for (const link of links) {
|
|
38
42
|
const verdict = await link.authorize(details, query, log);
|
|
39
|
-
const decision = decideFromVerdict(verdict);
|
|
43
|
+
const decision = decideFromVerdict(link.name, verdict);
|
|
40
44
|
if (decision) {
|
|
41
45
|
return decision;
|
|
42
46
|
}
|
|
@@ -47,16 +51,40 @@ export function composeAuthorizerChain(
|
|
|
47
51
|
};
|
|
48
52
|
}
|
|
49
53
|
|
|
50
|
-
/**
|
|
51
|
-
|
|
54
|
+
/**
|
|
55
|
+
* Map a link's decisive verdict to a decision; `defer` yields `null`.
|
|
56
|
+
*
|
|
57
|
+
* The deciding link is named on the decision, not merely counted among the
|
|
58
|
+
* consulted set the selection already records: a link ahead of it that
|
|
59
|
+
* deferred decided nothing and must not be credited (#726).
|
|
60
|
+
*/
|
|
61
|
+
function decideFromVerdict(
|
|
62
|
+
name: string,
|
|
63
|
+
verdict: AuthorizerVerdict,
|
|
64
|
+
): PermissionPromptDecision | null {
|
|
52
65
|
switch (verdict.kind) {
|
|
53
66
|
case "allow":
|
|
54
67
|
// A link grant is non-persistent (state `approved`, never
|
|
55
68
|
// `approved_for_session`), per ADR 0007's off-by-default envelope.
|
|
56
|
-
return {
|
|
69
|
+
return {
|
|
70
|
+
approved: true,
|
|
71
|
+
state: "approved",
|
|
72
|
+
decidedBy: decidedByLink(name, "allow", null),
|
|
73
|
+
};
|
|
57
74
|
case "deny":
|
|
58
|
-
return
|
|
75
|
+
return {
|
|
76
|
+
...createDeniedPermissionDecision(verdict.reason),
|
|
77
|
+
decidedBy: decidedByLink(name, "deny", verdict.reason ?? null),
|
|
78
|
+
};
|
|
59
79
|
case "defer":
|
|
60
80
|
return null;
|
|
61
81
|
}
|
|
62
82
|
}
|
|
83
|
+
|
|
84
|
+
function decidedByLink(
|
|
85
|
+
name: string,
|
|
86
|
+
verdict: "allow" | "deny",
|
|
87
|
+
reason: string | null,
|
|
88
|
+
): DecisionSource {
|
|
89
|
+
return { kind: "authorizer", name, verdict, reason };
|
|
90
|
+
}
|
|
@@ -2,8 +2,8 @@ import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
|
2
2
|
import type { PermissionPromptDecision } from "#src/authority/permission-dialog";
|
|
3
3
|
import type { PermissionQuery } from "#src/service";
|
|
4
4
|
import {
|
|
5
|
-
type Authorizer,
|
|
6
5
|
type AuthorizerSelectionDeps,
|
|
6
|
+
type NamedAuthorizer,
|
|
7
7
|
type SelectedAuthority,
|
|
8
8
|
selectAuthorizer,
|
|
9
9
|
} from "./authorizer";
|
|
@@ -93,7 +93,7 @@ export class AuthorizerSelection
|
|
|
93
93
|
private linksFor(
|
|
94
94
|
authority: SelectedAuthority,
|
|
95
95
|
requestId: string,
|
|
96
|
-
):
|
|
96
|
+
): NamedAuthorizer[] {
|
|
97
97
|
const configured = this.deps.getAuthorizerChain();
|
|
98
98
|
if (configured.length === 0) {
|
|
99
99
|
return [];
|
|
@@ -123,8 +123,8 @@ export class AuthorizerSelection
|
|
|
123
123
|
private resolveConfiguredLinks(
|
|
124
124
|
configured: readonly string[],
|
|
125
125
|
requestId: string,
|
|
126
|
-
):
|
|
127
|
-
const links:
|
|
126
|
+
): NamedAuthorizer[] {
|
|
127
|
+
const links: NamedAuthorizer[] = [];
|
|
128
128
|
const resolved: string[] = [];
|
|
129
129
|
for (const name of configured) {
|
|
130
130
|
const authorize = this.deps.authorizerRegistry.get(name);
|
|
@@ -136,7 +136,7 @@ export class AuthorizerSelection
|
|
|
136
136
|
continue;
|
|
137
137
|
}
|
|
138
138
|
resolved.push(name);
|
|
139
|
-
links.push({ authorize: encloseInDelegationEnvelope(authorize) });
|
|
139
|
+
links.push({ name, authorize: encloseInDelegationEnvelope(authorize) });
|
|
140
140
|
}
|
|
141
141
|
if (resolved.length > 0) {
|
|
142
142
|
this.deps.logger.review("authorizer_chain_resolved", {
|
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import type { TargetServingLookup } from "#src/authority/forwarding-liveness";
|
|
2
3
|
import type { PermissionPromptDecision } from "#src/authority/permission-dialog";
|
|
3
4
|
import type {
|
|
4
5
|
PromptPreferences,
|
|
5
6
|
requestPermissionDecision,
|
|
6
7
|
} from "#src/authority/permission-prompt-component";
|
|
7
|
-
import type { ServingLookup } from "#src/authority/serving-registry";
|
|
8
8
|
import type { SubagentSessionRegistry } from "#src/authority/subagent-registry";
|
|
9
9
|
import type { PermissionEventBus } from "#src/permission-events";
|
|
10
10
|
import type { AuthorizerLog, PermissionQuery } from "#src/service";
|
|
@@ -42,6 +42,19 @@ export interface Authorizer {
|
|
|
42
42
|
): Promise<AuthorizerVerdict>;
|
|
43
43
|
}
|
|
44
44
|
|
|
45
|
+
/**
|
|
46
|
+
* A resolved chain link together with the operator-configured name it came
|
|
47
|
+
* from.
|
|
48
|
+
*
|
|
49
|
+
* `AuthorizerRegistry` already keys links by name, and `AuthorizerSelection`
|
|
50
|
+
* has the name in scope when it resolves the operator's `authorizerChain`; the
|
|
51
|
+
* name is carried through composition so a decision record can say *which*
|
|
52
|
+
* link decided rather than only which links were consulted.
|
|
53
|
+
*/
|
|
54
|
+
export interface NamedAuthorizer extends Authorizer {
|
|
55
|
+
readonly name: string;
|
|
56
|
+
}
|
|
57
|
+
|
|
45
58
|
/**
|
|
46
59
|
* The terminal link: on `ask`, rules on a single request and is told the
|
|
47
60
|
* decision. Structurally cannot defer — it always returns a full
|
|
@@ -95,8 +108,8 @@ export interface AuthorizerSelectionDeps {
|
|
|
95
108
|
forwardingDir: string;
|
|
96
109
|
/** In-process subagent session registry for forwarding target resolution. */
|
|
97
110
|
registry?: SubagentSessionRegistry;
|
|
98
|
-
/**
|
|
99
|
-
|
|
111
|
+
/** Whether a forwarding target is draining its inbox, on whichever channel can say. */
|
|
112
|
+
serving: TargetServingLookup;
|
|
100
113
|
/** The forwarding timeout, read live so a config edit applies to the next ask. */
|
|
101
114
|
getForwardingTimeoutMs: () => number;
|
|
102
115
|
logger: DebugReviewLogger;
|
|
@@ -132,7 +145,7 @@ export function selectAuthorizer(
|
|
|
132
145
|
terminal: new ParentAuthorizer(ctx, {
|
|
133
146
|
forwardingDir: deps.forwardingDir,
|
|
134
147
|
registry: deps.registry,
|
|
135
|
-
serving: deps.
|
|
148
|
+
serving: deps.serving,
|
|
136
149
|
getTimeoutMs: deps.getForwardingTimeoutMs,
|
|
137
150
|
logger: deps.logger,
|
|
138
151
|
}),
|