@gotgenes/pi-permission-system 26.0.0 → 26.1.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 +16 -0
- package/docs/subagent-integration.md +14 -0
- package/package.json +1 -1
- package/src/authority/approval-escalator.ts +32 -1
- package/src/authority/authorizer-chain.ts +39 -11
- package/src/authority/authorizer-selection.ts +5 -5
- package/src/authority/authorizer.ts +13 -0
- 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/permission-dialog.ts +23 -2
- package/src/authority/permission-forwarding.ts +13 -0
- 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/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/permission-gate.ts +11 -0
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,22 @@ 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.1.0](https://github.com/gotgenes/pi-packages/compare/pi-permission-system-v26.0.0...pi-permission-system-v26.1.0) (2026-08-17)
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
### Features
|
|
12
|
+
|
|
13
|
+
* **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)
|
|
14
|
+
* **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)
|
|
15
|
+
* **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)
|
|
16
|
+
* **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)
|
|
17
|
+
* **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)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
### Documentation
|
|
21
|
+
|
|
22
|
+
* **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)
|
|
23
|
+
|
|
8
24
|
## [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
25
|
|
|
10
26
|
|
|
@@ -54,6 +54,20 @@ None of them is reported as a user denial, because no user was ever asked.
|
|
|
54
54
|
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
55
|
When a forwarded request goes unanswered, comparing those two entries distinguishes a parent that was not polling from one polling a different session.
|
|
56
56
|
|
|
57
|
+
When a forwarded request *is* answered, the child's own terminal entry names both which session answered and what within it decided.
|
|
58
|
+
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:
|
|
59
|
+
|
|
60
|
+
```json
|
|
61
|
+
{
|
|
62
|
+
"kind": "forwarded",
|
|
63
|
+
"responderSessionId": "019ff969-c34c-70be-9034-fae19c852932",
|
|
64
|
+
"decision": { "kind": "user", "via": "dialog" }
|
|
65
|
+
}
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
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.
|
|
69
|
+
An older parent that sends no decider yields `"decision": null`: the hop is still recorded, and the answer is still honored.
|
|
70
|
+
|
|
57
71
|
This liveness signal is process-local, so it applies to in-process children only.
|
|
58
72
|
A child running as a separate `pi` process (the `PI_SUBAGENT_PARENT_SESSION` path) cannot observe its parent's polling and still waits the full timeout.
|
|
59
73
|
|
package/package.json
CHANGED
|
@@ -23,6 +23,7 @@ import type { PermissionPromptDecision } from "#src/authority/permission-dialog"
|
|
|
23
23
|
import {
|
|
24
24
|
type ForwardedAccessFacts,
|
|
25
25
|
type ForwardedPermissionRequest,
|
|
26
|
+
type ForwardedPermissionResponse,
|
|
26
27
|
type ForwardedPromptDisplay,
|
|
27
28
|
type ForwardedSessionApproval,
|
|
28
29
|
PERMISSION_FORWARDING_POLL_INTERVAL_MS,
|
|
@@ -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
|
|
|
@@ -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
|
}
|
|
@@ -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", {
|
|
@@ -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
|
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What decided a permission request, recorded at the site that decided it.
|
|
3
|
+
*
|
|
4
|
+
* The decision paths are already distinct in the code — a session hit, a yolo
|
|
5
|
+
* grant, an infrastructure read, a config rule, a chain link, a human at a
|
|
6
|
+
* dialog, an unreachable authority — and each one knows what it is at the
|
|
7
|
+
* moment it decides. This is that fact, carried to the record instead of being
|
|
8
|
+
* discarded and re-guessed from an event name downstream.
|
|
9
|
+
*
|
|
10
|
+
* Every variant is **self-contained**: it repeats the detail that made it
|
|
11
|
+
* decisive rather than leaning on a sibling log column. That duplicates
|
|
12
|
+
* `surface` and the pattern on a local review line, and it is the only shape
|
|
13
|
+
* that survives the forwarding hop, where the response file has no such
|
|
14
|
+
* columns to lean on.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
/** Which human-facing surface the operator answered on. */
|
|
18
|
+
export type UserDecisionSurface = "dialog" | "select";
|
|
19
|
+
|
|
20
|
+
export type DecisionSource =
|
|
21
|
+
/** A human ruled, at the inline dialog or the `select`/`input` fallback. */
|
|
22
|
+
| { kind: "user"; via: UserDecisionSurface }
|
|
23
|
+
/** A registered `authorizerChain` link ruled; `name` is the configured name. */
|
|
24
|
+
| {
|
|
25
|
+
kind: "authorizer";
|
|
26
|
+
name: string;
|
|
27
|
+
verdict: "allow" | "deny";
|
|
28
|
+
reason: string | null;
|
|
29
|
+
}
|
|
30
|
+
/** Recorded authority: a rule in the composed ruleset matched. */
|
|
31
|
+
| {
|
|
32
|
+
kind: "rule";
|
|
33
|
+
surface: string;
|
|
34
|
+
pattern: string | null;
|
|
35
|
+
origin: string | null;
|
|
36
|
+
}
|
|
37
|
+
/** A session-scoped grant the operator made earlier in this session. */
|
|
38
|
+
| { kind: "session_approval"; surface: string; pattern: string | null }
|
|
39
|
+
/**
|
|
40
|
+
* `yoloMode`. `pattern` preserves the ask's matched rule — including a
|
|
41
|
+
* synthetic sentinel such as `<opaque-bash-wrapper>` — which is what makes a
|
|
42
|
+
* yolo grant over a synthesized ask legible.
|
|
43
|
+
*/
|
|
44
|
+
| { kind: "yolo"; pattern: string | null }
|
|
45
|
+
/** A Pi infrastructure read, allowed by containment rather than by a rule. */
|
|
46
|
+
| { kind: "infrastructure_read" }
|
|
47
|
+
/**
|
|
48
|
+
* No authority ever ruled: none was reachable, or the forwarding path gave
|
|
49
|
+
* up before reaching one. `reason` names which path gave up.
|
|
50
|
+
*/
|
|
51
|
+
| { kind: "unavailable"; reason: string }
|
|
52
|
+
/** A gate threw, and the boundary blocked rather than allowed. */
|
|
53
|
+
| { kind: "gate_error"; reason: string }
|
|
54
|
+
/**
|
|
55
|
+
* Another session decided. Recursive by design: the requesting side records
|
|
56
|
+
* both that the decider was elsewhere and what, within that session, decided
|
|
57
|
+
* — which is the distinction an audit of a forwarded ask needs.
|
|
58
|
+
*
|
|
59
|
+
* `decision` is `null` when the responder sent none (an older parent).
|
|
60
|
+
*/
|
|
61
|
+
| {
|
|
62
|
+
kind: "forwarded";
|
|
63
|
+
responderSessionId: string | null;
|
|
64
|
+
decision: DecisionSource | null;
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* How deep a `forwarded` chain may nest before {@link asDecisionSource} gives
|
|
69
|
+
* up.
|
|
70
|
+
*
|
|
71
|
+
* Forwarding is depth-1 by invariant (child → root) and a relay hop makes it
|
|
72
|
+
* two, so this is headroom rather than a working limit. It exists because the
|
|
73
|
+
* value is read off disk: a recursive reader over a file another process wrote
|
|
74
|
+
* is a stack-overflow surface, and the fail-closed answer is to stop.
|
|
75
|
+
*/
|
|
76
|
+
export const MAX_DECISION_SOURCE_DEPTH = 4;
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Narrow an unknown value to a {@link DecisionSource}, or `undefined`.
|
|
80
|
+
*
|
|
81
|
+
* Lives beside its type so a new variant updates the guard next door, following
|
|
82
|
+
* `asPromptPayload` and `isPermissionDecisionState`. All-or-nothing: a
|
|
83
|
+
* malformed field — at any nesting level — yields `undefined` rather than a
|
|
84
|
+
* half-parsed record, because a provenance record that names a decider who did
|
|
85
|
+
* not decide is worse than one that names none.
|
|
86
|
+
*/
|
|
87
|
+
export function asDecisionSource(value: unknown): DecisionSource | undefined {
|
|
88
|
+
return narrowSource(value, MAX_DECISION_SOURCE_DEPTH);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function narrowSource(
|
|
92
|
+
value: unknown,
|
|
93
|
+
depthBudget: number,
|
|
94
|
+
): DecisionSource | undefined {
|
|
95
|
+
const candidate = asObject(value);
|
|
96
|
+
if (!candidate) return undefined;
|
|
97
|
+
|
|
98
|
+
switch (candidate.kind) {
|
|
99
|
+
case "user":
|
|
100
|
+
return narrowUser(candidate);
|
|
101
|
+
case "authorizer":
|
|
102
|
+
return narrowAuthorizer(candidate);
|
|
103
|
+
case "rule":
|
|
104
|
+
return narrowRule(candidate);
|
|
105
|
+
case "session_approval":
|
|
106
|
+
return narrowSessionApproval(candidate);
|
|
107
|
+
case "yolo":
|
|
108
|
+
return isNullableString(candidate.pattern)
|
|
109
|
+
? { kind: "yolo", pattern: candidate.pattern }
|
|
110
|
+
: undefined;
|
|
111
|
+
case "infrastructure_read":
|
|
112
|
+
return { kind: "infrastructure_read" };
|
|
113
|
+
case "unavailable":
|
|
114
|
+
return typeof candidate.reason === "string"
|
|
115
|
+
? { kind: "unavailable", reason: candidate.reason }
|
|
116
|
+
: undefined;
|
|
117
|
+
case "gate_error":
|
|
118
|
+
return typeof candidate.reason === "string"
|
|
119
|
+
? { kind: "gate_error", reason: candidate.reason }
|
|
120
|
+
: undefined;
|
|
121
|
+
case "forwarded":
|
|
122
|
+
return narrowForwarded(candidate, depthBudget);
|
|
123
|
+
default:
|
|
124
|
+
return undefined;
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function narrowUser(
|
|
129
|
+
candidate: Record<string, unknown>,
|
|
130
|
+
): DecisionSource | undefined {
|
|
131
|
+
const via = USER_DECISION_SURFACES.find((entry) => entry === candidate.via);
|
|
132
|
+
return via ? { kind: "user", via } : undefined;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function narrowAuthorizer(
|
|
136
|
+
candidate: Record<string, unknown>,
|
|
137
|
+
): DecisionSource | undefined {
|
|
138
|
+
const verdict = AUTHORIZER_VERDICTS.find(
|
|
139
|
+
(entry) => entry === candidate.verdict,
|
|
140
|
+
);
|
|
141
|
+
if (
|
|
142
|
+
!verdict ||
|
|
143
|
+
typeof candidate.name !== "string" ||
|
|
144
|
+
!isNullableString(candidate.reason)
|
|
145
|
+
) {
|
|
146
|
+
return undefined;
|
|
147
|
+
}
|
|
148
|
+
return {
|
|
149
|
+
kind: "authorizer",
|
|
150
|
+
name: candidate.name,
|
|
151
|
+
verdict,
|
|
152
|
+
reason: candidate.reason,
|
|
153
|
+
};
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function narrowRule(
|
|
157
|
+
candidate: Record<string, unknown>,
|
|
158
|
+
): DecisionSource | undefined {
|
|
159
|
+
if (
|
|
160
|
+
typeof candidate.surface !== "string" ||
|
|
161
|
+
!isNullableString(candidate.pattern) ||
|
|
162
|
+
!isNullableString(candidate.origin)
|
|
163
|
+
) {
|
|
164
|
+
return undefined;
|
|
165
|
+
}
|
|
166
|
+
return {
|
|
167
|
+
kind: "rule",
|
|
168
|
+
surface: candidate.surface,
|
|
169
|
+
pattern: candidate.pattern,
|
|
170
|
+
origin: candidate.origin,
|
|
171
|
+
};
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function narrowSessionApproval(
|
|
175
|
+
candidate: Record<string, unknown>,
|
|
176
|
+
): DecisionSource | undefined {
|
|
177
|
+
if (
|
|
178
|
+
typeof candidate.surface !== "string" ||
|
|
179
|
+
!isNullableString(candidate.pattern)
|
|
180
|
+
) {
|
|
181
|
+
return undefined;
|
|
182
|
+
}
|
|
183
|
+
return {
|
|
184
|
+
kind: "session_approval",
|
|
185
|
+
surface: candidate.surface,
|
|
186
|
+
pattern: candidate.pattern,
|
|
187
|
+
};
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* The inner decision is narrowed against a decremented budget, so a chain
|
|
192
|
+
* deeper than {@link MAX_DECISION_SOURCE_DEPTH} is rejected whole rather than
|
|
193
|
+
* truncated — a truncated chain would silently attribute the decision to the
|
|
194
|
+
* last frame that fit.
|
|
195
|
+
*/
|
|
196
|
+
function narrowForwarded(
|
|
197
|
+
candidate: Record<string, unknown>,
|
|
198
|
+
depthBudget: number,
|
|
199
|
+
): DecisionSource | undefined {
|
|
200
|
+
if (depthBudget <= 0 || !isNullableString(candidate.responderSessionId)) {
|
|
201
|
+
return undefined;
|
|
202
|
+
}
|
|
203
|
+
if (candidate.decision === null) {
|
|
204
|
+
return {
|
|
205
|
+
kind: "forwarded",
|
|
206
|
+
responderSessionId: candidate.responderSessionId,
|
|
207
|
+
decision: null,
|
|
208
|
+
};
|
|
209
|
+
}
|
|
210
|
+
const decision = narrowSource(candidate.decision, depthBudget - 1);
|
|
211
|
+
return decision
|
|
212
|
+
? {
|
|
213
|
+
kind: "forwarded",
|
|
214
|
+
responderSessionId: candidate.responderSessionId,
|
|
215
|
+
decision,
|
|
216
|
+
}
|
|
217
|
+
: undefined;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
const USER_DECISION_SURFACES = [
|
|
221
|
+
"dialog",
|
|
222
|
+
"select",
|
|
223
|
+
] as const satisfies readonly UserDecisionSurface[];
|
|
224
|
+
|
|
225
|
+
const AUTHORIZER_VERDICTS = ["allow", "deny"] as const;
|
|
226
|
+
|
|
227
|
+
function asObject(value: unknown): Record<string, unknown> | undefined {
|
|
228
|
+
return typeof value === "object" && value !== null && !Array.isArray(value)
|
|
229
|
+
? (value as Record<string, unknown>)
|
|
230
|
+
: undefined;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
function isNullableString(value: unknown): value is string | null {
|
|
234
|
+
return value === null || typeof value === "string";
|
|
235
|
+
}
|
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
import type { PermissionPromptDecision } from "#src/authority/permission-dialog";
|
|
2
2
|
import type { TerminalAuthorizer } from "./authorizer";
|
|
3
3
|
|
|
4
|
+
/** Why this authorizer denies; the provenance record's `reason` (#726). */
|
|
5
|
+
const NO_AUTHORITY_REASON = "No live authority was reachable for this session";
|
|
6
|
+
|
|
4
7
|
/**
|
|
5
8
|
* Least-privilege Authorizer: no authority is reachable for this session
|
|
6
9
|
* (no UI, not a subagent), so every ask is denied.
|
|
@@ -15,6 +18,7 @@ export class DenyingAuthorizer implements TerminalAuthorizer {
|
|
|
15
18
|
approved: false,
|
|
16
19
|
state: "denied",
|
|
17
20
|
confirmationUnavailable: true,
|
|
21
|
+
decidedBy: { kind: "unavailable", reason: NO_AUTHORITY_REASON },
|
|
18
22
|
});
|
|
19
23
|
}
|
|
20
24
|
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { join } from "node:path";
|
|
2
|
+
import type { DecisionSource } from "#src/authority/decision-source";
|
|
2
3
|
import {
|
|
3
4
|
type ForwarderContext,
|
|
4
5
|
getSessionId,
|
|
@@ -22,6 +23,7 @@ import type { AskEscalator } from "./authorizer-selection";
|
|
|
22
23
|
import {
|
|
23
24
|
cleanupPermissionForwardingLocationIfEmpty,
|
|
24
25
|
ensureDirectoryExists,
|
|
26
|
+
formatUnknownErrorMessage,
|
|
25
27
|
getExistingPermissionForwardingLocation,
|
|
26
28
|
listRequestFiles,
|
|
27
29
|
logPermissionForwardingError,
|
|
@@ -276,6 +278,9 @@ export class ForwardedRequestServer implements InboxProcessor {
|
|
|
276
278
|
* `approved` so the child records nothing (its next identical action
|
|
277
279
|
* re-forwards and resolves as recorded authority). Every other decision
|
|
278
280
|
* passes through unchanged (`approved_for_session` → the child records).
|
|
281
|
+
*
|
|
282
|
+
* The translation rewrites the grant's *scope*, never its decider: the human
|
|
283
|
+
* who chose the wider scope is still the one who decided (#726).
|
|
279
284
|
*/
|
|
280
285
|
private applyGrantScope(
|
|
281
286
|
request: ForwardedPermissionRequest,
|
|
@@ -298,7 +303,11 @@ export class ForwardedRequestServer implements InboxProcessor {
|
|
|
298
303
|
patterns: request.sessionApproval.patterns,
|
|
299
304
|
});
|
|
300
305
|
}
|
|
301
|
-
return {
|
|
306
|
+
return {
|
|
307
|
+
approved: true,
|
|
308
|
+
state: "approved",
|
|
309
|
+
decidedBy: decision.decidedBy,
|
|
310
|
+
};
|
|
302
311
|
}
|
|
303
312
|
|
|
304
313
|
/**
|
|
@@ -327,6 +336,7 @@ export class ForwardedRequestServer implements InboxProcessor {
|
|
|
327
336
|
responsePath,
|
|
328
337
|
resolution: decision.state,
|
|
329
338
|
denialReason: decision.denialReason ?? null,
|
|
339
|
+
decidedBy: decision.decidedBy,
|
|
330
340
|
},
|
|
331
341
|
);
|
|
332
342
|
try {
|
|
@@ -336,6 +346,9 @@ export class ForwardedRequestServer implements InboxProcessor {
|
|
|
336
346
|
denialReason: decision.denialReason,
|
|
337
347
|
responderSessionId: currentSessionId,
|
|
338
348
|
respondedAt: Date.now(),
|
|
349
|
+
// Carried onto the wire so the requester can name what decided inside
|
|
350
|
+
// this session, not merely that this session answered (#726).
|
|
351
|
+
decidedBy: decision.decidedBy,
|
|
339
352
|
} satisfies ForwardedPermissionResponse);
|
|
340
353
|
} catch (error) {
|
|
341
354
|
logPermissionForwardingError(
|
|
@@ -366,29 +379,48 @@ export class ForwardedRequestServer implements InboxProcessor {
|
|
|
366
379
|
request: ForwardedPermissionRequest,
|
|
367
380
|
logDetails: Record<string, unknown>,
|
|
368
381
|
): Promise<PermissionPromptDecision> {
|
|
369
|
-
const
|
|
370
|
-
? this.policy.resolve(request.accessIntent)
|
|
371
|
-
:
|
|
382
|
+
const check = request.accessIntent
|
|
383
|
+
? this.policy.resolve(request.accessIntent)
|
|
384
|
+
: null;
|
|
372
385
|
|
|
373
|
-
if (state
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
386
|
+
if (check && check.state !== "ask") {
|
|
387
|
+
// The rule is carried in full rather than left to the event name: the
|
|
388
|
+
// response file has no surface, pattern, or origin column for the
|
|
389
|
+
// requester's record to lean on.
|
|
390
|
+
const decidedBy: DecisionSource = {
|
|
391
|
+
kind: "rule",
|
|
392
|
+
surface: request.accessIntent?.surface ?? check.toolName,
|
|
393
|
+
pattern: check.matchedPattern ?? null,
|
|
394
|
+
origin: check.origin,
|
|
395
|
+
};
|
|
396
|
+
const approved = check.state === "allow";
|
|
397
|
+
this.logger.review(
|
|
398
|
+
approved
|
|
399
|
+
? "forwarded_permission.auto_approved"
|
|
400
|
+
: "forwarded_permission.auto_denied",
|
|
401
|
+
{ ...logDetails, decidedBy },
|
|
402
|
+
);
|
|
403
|
+
return approved
|
|
404
|
+
? { approved: true, state: "approved", decidedBy }
|
|
405
|
+
: { approved: false, state: "denied", decidedBy };
|
|
380
406
|
}
|
|
381
407
|
|
|
382
408
|
this.logger.review("forwarded_permission.prompted", logDetails);
|
|
383
409
|
try {
|
|
384
410
|
return await this.escalator.escalate(buildForwardedAskDetails(request));
|
|
385
411
|
} catch (error) {
|
|
412
|
+
const reason = formatUnknownErrorMessage(error);
|
|
386
413
|
logPermissionForwardingError(
|
|
387
414
|
this.logger,
|
|
388
415
|
`Failed to escalate forwarded permission request '${request.id}'`,
|
|
389
416
|
error,
|
|
390
417
|
);
|
|
391
|
-
|
|
418
|
+
// Nobody denied this; the escalation broke and the node failed closed.
|
|
419
|
+
return {
|
|
420
|
+
approved: false,
|
|
421
|
+
state: "denied",
|
|
422
|
+
decidedBy: { kind: "gate_error", reason },
|
|
423
|
+
};
|
|
392
424
|
}
|
|
393
425
|
}
|
|
394
426
|
|
|
@@ -9,6 +9,7 @@ import {
|
|
|
9
9
|
writeFileSync,
|
|
10
10
|
} from "node:fs";
|
|
11
11
|
|
|
12
|
+
import { asDecisionSource } from "#src/authority/decision-source";
|
|
12
13
|
import { isPermissionDecisionState } from "#src/authority/permission-dialog";
|
|
13
14
|
import {
|
|
14
15
|
createPermissionForwardingLocation,
|
|
@@ -466,6 +467,10 @@ export function readForwardedPermissionResponse(
|
|
|
466
467
|
typeof parsed.respondedAt === "number"
|
|
467
468
|
? parsed.respondedAt
|
|
468
469
|
: Date.now(),
|
|
470
|
+
// Tolerant like the request's `accessIntent`: an unusable provenance
|
|
471
|
+
// record is dropped, but the decision itself still has to reach the
|
|
472
|
+
// requester, so it never rejects the response.
|
|
473
|
+
decidedBy: asDecisionSource(parsed.decidedBy),
|
|
469
474
|
};
|
|
470
475
|
} catch (error) {
|
|
471
476
|
logPermissionForwardingWarning(
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import type { DecisionSource } from "#src/authority/decision-source";
|
|
2
|
+
|
|
1
3
|
export type PermissionDecisionState =
|
|
2
4
|
| "approved"
|
|
3
5
|
| "approved_for_session"
|
|
@@ -26,8 +28,27 @@ export type PermissionPromptDecision = {
|
|
|
26
28
|
* denial — a user who was never asked denied nothing (#719).
|
|
27
29
|
*/
|
|
28
30
|
confirmationUnavailable?: true;
|
|
31
|
+
/**
|
|
32
|
+
* What decided this request, stamped by the site that decided it.
|
|
33
|
+
*
|
|
34
|
+
* Required: every decision names its decider, and the type is what
|
|
35
|
+
* guarantees it rather than a convention each producer has to remember — the
|
|
36
|
+
* same discipline `PromptPermissionDetails.payload` carries (#726).
|
|
37
|
+
*/
|
|
38
|
+
decidedBy: DecisionSource;
|
|
29
39
|
};
|
|
30
40
|
|
|
41
|
+
/**
|
|
42
|
+
* A decision before its decider is known.
|
|
43
|
+
*
|
|
44
|
+
* The inner producers — the dialog's decision model, the `select`/`input`
|
|
45
|
+
* fallback, the verdict mapper — state the outcome; which decider to attribute
|
|
46
|
+
* it to is settled one layer up, at the site that chose the producer. The same
|
|
47
|
+
* shape `GateBypass.decision` uses for the request id: a producer emits only
|
|
48
|
+
* what it knows.
|
|
49
|
+
*/
|
|
50
|
+
export type UnattributedDecision = Omit<PermissionPromptDecision, "decidedBy">;
|
|
51
|
+
|
|
31
52
|
export interface PermissionDecisionUi {
|
|
32
53
|
select(title: string, options: string[]): Promise<string | undefined>;
|
|
33
54
|
input(title: string, placeholder?: string): Promise<string | undefined>;
|
|
@@ -51,7 +72,7 @@ export function normalizePermissionDenialReason(
|
|
|
51
72
|
|
|
52
73
|
export function createDeniedPermissionDecision(
|
|
53
74
|
denialReason?: string,
|
|
54
|
-
):
|
|
75
|
+
): UnattributedDecision {
|
|
55
76
|
const normalizedReason = normalizePermissionDenialReason(denialReason);
|
|
56
77
|
return normalizedReason
|
|
57
78
|
? {
|
|
@@ -96,7 +117,7 @@ export async function requestPermissionDecisionFromUi(
|
|
|
96
117
|
title: string,
|
|
97
118
|
message: string,
|
|
98
119
|
options?: RequestPermissionOptions,
|
|
99
|
-
): Promise<
|
|
120
|
+
): Promise<UnattributedDecision> {
|
|
100
121
|
const sessionOption = options?.sessionLabel ?? APPROVE_FOR_SESSION_OPTION;
|
|
101
122
|
const decisionOptions = [
|
|
102
123
|
APPROVE_OPTION,
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { join } from "node:path";
|
|
2
|
+
import type { DecisionSource } from "#src/authority/decision-source";
|
|
2
3
|
import type { PermissionUiPromptSource } from "#src/permission-events";
|
|
3
4
|
import type { PromptPayload } from "#src/presentation/prompt-payload";
|
|
4
5
|
import type { PermissionDecisionState } from "./permission-dialog";
|
|
@@ -163,6 +164,18 @@ export type ForwardedPermissionResponse = {
|
|
|
163
164
|
denialReason?: string;
|
|
164
165
|
responderSessionId: string;
|
|
165
166
|
respondedAt: number;
|
|
167
|
+
/**
|
|
168
|
+
* What decided, inside the responding session (#726).
|
|
169
|
+
*
|
|
170
|
+
* `responderSessionId` names *where* the decision was made; this names
|
|
171
|
+
* *what* made it, which is the difference between a human at the parent's
|
|
172
|
+
* dialog and the parent's policy answering on their behalf.
|
|
173
|
+
*
|
|
174
|
+
* Optional for version-skew tolerance: an older responder omits it, and the
|
|
175
|
+
* requester records the hop with a `null` inner decision rather than
|
|
176
|
+
* rejecting the answer.
|
|
177
|
+
*/
|
|
178
|
+
decidedBy?: DecisionSource;
|
|
166
179
|
};
|
|
167
180
|
|
|
168
181
|
export type PermissionForwardingLocation = {
|
|
@@ -4,10 +4,15 @@ import type {
|
|
|
4
4
|
KeybindingsManager,
|
|
5
5
|
} from "@earendil-works/pi-coding-agent";
|
|
6
6
|
import { type Component, matchesKey } from "@earendil-works/pi-tui";
|
|
7
|
+
import type {
|
|
8
|
+
DecisionSource,
|
|
9
|
+
UserDecisionSurface,
|
|
10
|
+
} from "#src/authority/decision-source";
|
|
7
11
|
import {
|
|
8
12
|
type PermissionPromptDecision,
|
|
9
13
|
type RequestPermissionOptions,
|
|
10
14
|
requestPermissionDecisionFromUi,
|
|
15
|
+
type UnattributedDecision,
|
|
11
16
|
} from "#src/authority/permission-dialog";
|
|
12
17
|
import {
|
|
13
18
|
initialPromptState,
|
|
@@ -64,15 +69,23 @@ export interface PromptPreferences {
|
|
|
64
69
|
*
|
|
65
70
|
* The single entry the `LocalUserAuthorizer` calls; keeps the mode dispatch in
|
|
66
71
|
* one place so the fallback and the inline component never both render.
|
|
72
|
+
*
|
|
73
|
+
* It is therefore also the one place that knows which surface the human
|
|
74
|
+
* answered on, so it is where the decision is attributed to that surface
|
|
75
|
+
* (#726). Having the dialog model and the fallback each name themselves would
|
|
76
|
+
* be two sites that must agree with this branch.
|
|
67
77
|
*/
|
|
68
|
-
export function requestPermissionDecision(
|
|
78
|
+
export async function requestPermissionDecision(
|
|
69
79
|
view: PermissionPromptView,
|
|
70
80
|
title: string,
|
|
71
81
|
payload: PromptPayload,
|
|
72
82
|
options?: RequestPermissionOptions,
|
|
73
83
|
): Promise<PermissionPromptDecision> {
|
|
74
84
|
if (view.mode === "tui") {
|
|
75
|
-
return
|
|
85
|
+
return attributeToHuman(
|
|
86
|
+
await presentInlinePermissionPrompt(view, title, payload, options),
|
|
87
|
+
"dialog",
|
|
88
|
+
);
|
|
76
89
|
}
|
|
77
90
|
// The fallback renders once and cannot re-render, so it neither paints nor
|
|
78
91
|
// offers an expansion; it substitutes a nominal width for the terminal size
|
|
@@ -81,14 +94,25 @@ export function requestPermissionDecision(
|
|
|
81
94
|
...view.budget,
|
|
82
95
|
width: FALLBACK_RENDER_WIDTH,
|
|
83
96
|
});
|
|
84
|
-
return
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
97
|
+
return attributeToHuman(
|
|
98
|
+
await requestPermissionDecisionFromUi(
|
|
99
|
+
view.ui,
|
|
100
|
+
title,
|
|
101
|
+
rendered.lines.join("\n"),
|
|
102
|
+
options,
|
|
103
|
+
),
|
|
104
|
+
"select",
|
|
89
105
|
);
|
|
90
106
|
}
|
|
91
107
|
|
|
108
|
+
function attributeToHuman(
|
|
109
|
+
decision: UnattributedDecision,
|
|
110
|
+
via: UserDecisionSurface,
|
|
111
|
+
): PermissionPromptDecision {
|
|
112
|
+
const decidedBy: DecisionSource = { kind: "user", via };
|
|
113
|
+
return { ...decision, decidedBy };
|
|
114
|
+
}
|
|
115
|
+
|
|
92
116
|
/** The width the `select`/`input` fallback renders against. */
|
|
93
117
|
const FALLBACK_RENDER_WIDTH = 80;
|
|
94
118
|
|
|
@@ -113,13 +137,13 @@ export function presentInlinePermissionPrompt(
|
|
|
113
137
|
title: string,
|
|
114
138
|
payload: PromptPayload,
|
|
115
139
|
options?: RequestPermissionOptions,
|
|
116
|
-
): Promise<
|
|
140
|
+
): Promise<UnattributedDecision> {
|
|
117
141
|
const config: PromptModelConfig = {
|
|
118
142
|
doublePressToConfirm: view.doublePressToConfirm,
|
|
119
143
|
sessionLabel: options?.sessionLabel ?? DEFAULT_SESSION_LABEL,
|
|
120
144
|
sessionScope: options?.sessionScope,
|
|
121
145
|
};
|
|
122
|
-
return view.ui.custom<
|
|
146
|
+
return view.ui.custom<UnattributedDecision>(
|
|
123
147
|
(tui, theme, keybindings, done) =>
|
|
124
148
|
new PermissionPromptComponent(
|
|
125
149
|
theme,
|
|
@@ -176,7 +200,7 @@ class PermissionPromptComponent implements Component {
|
|
|
176
200
|
private readonly budget: RenderBudget,
|
|
177
201
|
private readonly handleAppAction: (data: string) => boolean,
|
|
178
202
|
private readonly requestRender: () => void,
|
|
179
|
-
private readonly done: (decision:
|
|
203
|
+
private readonly done: (decision: UnattributedDecision) => void,
|
|
180
204
|
) {
|
|
181
205
|
this.state = initialPromptState(config);
|
|
182
206
|
}
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import {
|
|
2
2
|
createDeniedPermissionDecision,
|
|
3
3
|
normalizePermissionDenialReason,
|
|
4
|
-
type PermissionPromptDecision,
|
|
5
4
|
type RequestPermissionOptions,
|
|
5
|
+
type UnattributedDecision,
|
|
6
6
|
} from "#src/authority/permission-dialog";
|
|
7
7
|
|
|
8
8
|
/**
|
|
@@ -70,7 +70,7 @@ export type PromptEvent =
|
|
|
70
70
|
/** Either a re-render or a terminal decision. */
|
|
71
71
|
export type PromptOutcome =
|
|
72
72
|
| { kind: "render"; state: PromptViewState }
|
|
73
|
-
| { kind: "decision"; decision:
|
|
73
|
+
| { kind: "decision"; decision: UnattributedDecision };
|
|
74
74
|
|
|
75
75
|
export function initialPromptState(
|
|
76
76
|
_config: PromptModelConfig,
|
|
@@ -88,7 +88,11 @@ export function initialPromptState(
|
|
|
88
88
|
|
|
89
89
|
/**
|
|
90
90
|
* Advance the dialog by one input event, returning either the next view state
|
|
91
|
-
* to render or the committed {@link
|
|
91
|
+
* to render or the committed {@link UnattributedDecision}.
|
|
92
|
+
*
|
|
93
|
+
* The model states the outcome and not the decider: which human surface this
|
|
94
|
+
* is gets attributed by the dispatcher that chose to render this dialog, so
|
|
95
|
+
* the two cannot disagree about the surface.
|
|
92
96
|
*/
|
|
93
97
|
export function reducePrompt(
|
|
94
98
|
config: PromptModelConfig,
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { DecisionSource } from "#src/authority/decision-source";
|
|
1
2
|
import type { PermissionPromptDecision } from "#src/authority/permission-dialog";
|
|
2
3
|
import type {
|
|
3
4
|
ForwardedAccessFacts,
|
|
@@ -131,6 +132,7 @@ export class PermissionPrompter implements PermissionPrompterApi {
|
|
|
131
132
|
? "confirmation_unavailable"
|
|
132
133
|
: decision.state,
|
|
133
134
|
denialReason: decision.denialReason,
|
|
135
|
+
decidedBy: decision.decidedBy,
|
|
134
136
|
},
|
|
135
137
|
);
|
|
136
138
|
|
|
@@ -139,14 +141,20 @@ export class PermissionPrompter implements PermissionPrompterApi {
|
|
|
139
141
|
|
|
140
142
|
// ── Private helpers ──────────────────────────────────────────────────────
|
|
141
143
|
|
|
144
|
+
/**
|
|
145
|
+
* The `waiting` entry carries no `decidedBy` — nothing has decided yet, and
|
|
146
|
+
* a `null` there would read as "decided by nobody" rather than "not yet".
|
|
147
|
+
*/
|
|
142
148
|
private writeReviewEntry(
|
|
143
149
|
event: string,
|
|
144
150
|
details: PromptPermissionDetails & {
|
|
145
151
|
resolution?: string;
|
|
146
152
|
denialReason?: string;
|
|
153
|
+
decidedBy?: DecisionSource;
|
|
147
154
|
},
|
|
148
155
|
): void {
|
|
149
156
|
this.deps.logger.review(event, {
|
|
157
|
+
...(details.decidedBy ? { decidedBy: details.decidedBy } : {}),
|
|
150
158
|
requestId: details.requestId,
|
|
151
159
|
source: details.source,
|
|
152
160
|
agentName: details.agentName,
|
|
@@ -47,6 +47,15 @@ export function describeBashExternalDirectoryGate(
|
|
|
47
47
|
if (uncoveredPaths.length === 0) {
|
|
48
48
|
return {
|
|
49
49
|
action: "allow",
|
|
50
|
+
// A whole-command bypass covers every external path at once, and each
|
|
51
|
+
// may have matched a different session pattern -- so the surface is one
|
|
52
|
+
// value and the pattern is not. The entry's `externalPaths` lists what
|
|
53
|
+
// was covered.
|
|
54
|
+
decidedBy: {
|
|
55
|
+
kind: "session_approval",
|
|
56
|
+
surface: "external_directory",
|
|
57
|
+
pattern: null,
|
|
58
|
+
},
|
|
50
59
|
log: {
|
|
51
60
|
event: "permission_request.session_approved",
|
|
52
61
|
details: {
|
|
@@ -84,6 +84,14 @@ export function describeBashPathGate(
|
|
|
84
84
|
if (allSessionCovered) {
|
|
85
85
|
return {
|
|
86
86
|
action: "allow",
|
|
87
|
+
// Every token was covered, each possibly by a different session pattern
|
|
88
|
+
// -- the surface is one value and the pattern is not. The entry's
|
|
89
|
+
// `tokens` lists what was covered.
|
|
90
|
+
decidedBy: {
|
|
91
|
+
kind: "session_approval",
|
|
92
|
+
surface: "path",
|
|
93
|
+
pattern: null,
|
|
94
|
+
},
|
|
87
95
|
log: {
|
|
88
96
|
event: "permission_request.session_approved",
|
|
89
97
|
details: {
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { DecisionSource } from "#src/authority/decision-source";
|
|
1
2
|
import type { PromptPermissionDetails } from "#src/authority/permission-prompter";
|
|
2
3
|
import type { PermissionDecisionEvent } from "#src/permission-events";
|
|
3
4
|
import type { PromptPayload } from "#src/presentation/prompt-payload";
|
|
@@ -79,6 +80,14 @@ export type DecisionEventFacts = Omit<PermissionDecisionEvent, "requestId">;
|
|
|
79
80
|
*/
|
|
80
81
|
export interface GateBypass {
|
|
81
82
|
action: "allow";
|
|
83
|
+
/**
|
|
84
|
+
* What decided this short-circuit.
|
|
85
|
+
*
|
|
86
|
+
* The gate that bypasses *is* the decider, so it states its own provenance
|
|
87
|
+
* and the runner relays it onto the log entry rather than inferring one from
|
|
88
|
+
* the event name (#726). Required, so a bypass added later cannot omit it.
|
|
89
|
+
*/
|
|
90
|
+
decidedBy: DecisionSource;
|
|
82
91
|
/** Optional review log entry to emit. */
|
|
83
92
|
log?: { event: string; details: Record<string, unknown> };
|
|
84
93
|
/** Optional decision event to emit. */
|
|
@@ -45,6 +45,8 @@ export function describeExternalDirectoryGate(
|
|
|
45
45
|
if (normalizer.isInfrastructureRead(tcc.toolName, accessPath, infraDirs)) {
|
|
46
46
|
return {
|
|
47
47
|
action: "allow",
|
|
48
|
+
// Containment allowed this, not a rule the operator wrote.
|
|
49
|
+
decidedBy: { kind: "infrastructure_read" },
|
|
48
50
|
log: {
|
|
49
51
|
event: "permission_request.infrastructure_auto_allowed",
|
|
50
52
|
details: {
|
|
@@ -66,6 +66,7 @@ export class GateRunner {
|
|
|
66
66
|
this.reporter.writeReviewLog(gate.log.event, {
|
|
67
67
|
...gate.log.details,
|
|
68
68
|
requestId,
|
|
69
|
+
decidedBy: gate.decidedBy,
|
|
69
70
|
});
|
|
70
71
|
}
|
|
71
72
|
if (gate.decision) {
|
|
@@ -123,12 +124,22 @@ export class GateRunner {
|
|
|
123
124
|
requestId,
|
|
124
125
|
};
|
|
125
126
|
|
|
127
|
+
// Each resolution below states its own decider. The provenance is built
|
|
128
|
+
// at the branch that decides rather than merged into `logContext`: that
|
|
129
|
+
// context holds what every resolution of this gate shares, and who decided
|
|
130
|
+
// is by definition not shared (#726).
|
|
131
|
+
|
|
126
132
|
// 2. Session-hit fast path
|
|
127
133
|
if (check.source === "session") {
|
|
128
134
|
this.reporter.writeReviewLog("permission_request.session_approved", {
|
|
129
135
|
...logContext,
|
|
130
136
|
resolution: "session_approved",
|
|
131
137
|
sessionApprovalPattern: check.matchedPattern,
|
|
138
|
+
decidedBy: {
|
|
139
|
+
kind: "session_approval",
|
|
140
|
+
surface: descriptor.surface,
|
|
141
|
+
pattern: check.matchedPattern ?? null,
|
|
142
|
+
},
|
|
132
143
|
});
|
|
133
144
|
this.emitDecision(
|
|
134
145
|
requestId,
|
|
@@ -152,6 +163,9 @@ export class GateRunner {
|
|
|
152
163
|
this.reporter.writeReviewLog("permission_request.auto_approved", {
|
|
153
164
|
...logContext,
|
|
154
165
|
resolution: "auto_approved",
|
|
166
|
+
// The pattern that raised the ask, sentinel included: "yolo allowed
|
|
167
|
+
// it" alone does not say why it was asked in the first place.
|
|
168
|
+
decidedBy: { kind: "yolo", pattern: check.matchedPattern ?? null },
|
|
155
169
|
});
|
|
156
170
|
this.emitDecision(
|
|
157
171
|
requestId,
|
|
@@ -202,6 +216,12 @@ export class GateRunner {
|
|
|
202
216
|
writeLog: (event, details) =>
|
|
203
217
|
this.reporter.writeReviewLog(event, details),
|
|
204
218
|
logContext,
|
|
219
|
+
decidedByRule: {
|
|
220
|
+
kind: "rule",
|
|
221
|
+
surface: descriptor.surface,
|
|
222
|
+
pattern: check.matchedPattern ?? null,
|
|
223
|
+
origin: check.origin,
|
|
224
|
+
},
|
|
205
225
|
messages,
|
|
206
226
|
});
|
|
207
227
|
|
|
@@ -76,12 +76,15 @@ function recordGateError(
|
|
|
76
76
|
): void {
|
|
77
77
|
try {
|
|
78
78
|
audit.recordError();
|
|
79
|
+
const reason = errorMessage(error);
|
|
79
80
|
reporter.writeReviewLog("permission_request.blocked", {
|
|
80
81
|
requestId: createPermissionRequestId(),
|
|
81
82
|
toolName: bestEffortToolName(event),
|
|
82
83
|
command: bestEffortCommand(event),
|
|
83
84
|
resolution: "gate_error",
|
|
84
|
-
error:
|
|
85
|
+
error: reason,
|
|
86
|
+
// The boundary decided, by failing closed -- no rule and no human did.
|
|
87
|
+
decidedBy: { kind: "gate_error", reason },
|
|
85
88
|
});
|
|
86
89
|
} catch {
|
|
87
90
|
// The block is the guarantee; its bookkeeping is not.
|
package/src/permission-gate.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { DecisionSource } from "#src/authority/decision-source";
|
|
1
2
|
import type { PermissionPromptDecision } from "#src/authority/permission-dialog";
|
|
2
3
|
|
|
3
4
|
/** Result of applying the permission gate. */
|
|
@@ -30,6 +31,15 @@ export interface PermissionGateParams {
|
|
|
30
31
|
/** Log context fields shared across all log calls for this gate. */
|
|
31
32
|
logContext: Record<string, unknown>;
|
|
32
33
|
|
|
34
|
+
/**
|
|
35
|
+
* The rule that resolved this gate, for the deny arm's review entry.
|
|
36
|
+
*
|
|
37
|
+
* A sibling of `logContext` rather than a member of it: the context holds
|
|
38
|
+
* what every resolution of this gate shares, and the decider is by
|
|
39
|
+
* definition not shared (#726).
|
|
40
|
+
*/
|
|
41
|
+
decidedByRule: DecisionSource;
|
|
42
|
+
|
|
33
43
|
/** Message strings/factories for each outcome. */
|
|
34
44
|
messages: {
|
|
35
45
|
denyReason: string;
|
|
@@ -52,6 +62,7 @@ export async function applyPermissionGate(
|
|
|
52
62
|
writeLog("permission_request.blocked", {
|
|
53
63
|
...logContext,
|
|
54
64
|
resolution: "policy_denied",
|
|
65
|
+
decidedBy: params.decidedByRule,
|
|
55
66
|
});
|
|
56
67
|
return { action: "block", reason: messages.denyReason };
|
|
57
68
|
}
|