@gotgenes/pi-permission-system 25.4.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 +49 -0
- package/README.md +14 -12
- package/config/config.example.json +1 -2
- package/dist/public.d.ts +31 -10
- package/docs/configuration.md +23 -19
- package/docs/cross-extension-api.md +30 -4
- package/docs/migration/0745-prompt-payload-contracts.md +68 -0
- package/docs/migration/0746-review-log-fields.md +69 -0
- package/docs/subagent-integration.md +14 -0
- package/docs/troubleshooting.md +2 -1
- package/package.json +1 -1
- package/schemas/permissions.schema.json +14 -4
- package/src/access-intent/tool-kind.ts +1 -1
- package/src/authority/approval-escalator.ts +38 -5
- package/src/authority/authorizer-chain.ts +39 -11
- package/src/authority/authorizer-selection.ts +5 -5
- package/src/authority/authorizer.ts +16 -3
- package/src/authority/decision-source.ts +235 -0
- package/src/authority/denying-authorizer.ts +4 -0
- package/src/authority/forwarded-request-server.ts +44 -14
- package/src/authority/forwarding-io.ts +12 -5
- package/src/authority/permission-dialog.ts +23 -2
- package/src/authority/permission-forwarding.ts +25 -3
- package/src/authority/permission-prompt-component.ts +34 -10
- package/src/authority/permission-prompt-decision.ts +7 -3
- package/src/authority/permission-prompter.ts +13 -4
- package/src/config-loader.ts +31 -0
- package/src/config-schema.ts +13 -4
- package/src/extension-config.ts +8 -9
- package/src/handlers/gates/bash-external-directory.ts +10 -12
- package/src/handlers/gates/bash-path.ts +9 -10
- package/src/handlers/gates/descriptor.ts +25 -5
- package/src/handlers/gates/external-directory.ts +3 -13
- package/src/handlers/gates/path.ts +1 -9
- package/src/handlers/gates/runner.ts +44 -14
- package/src/handlers/gates/skill-input-gate-pipeline.ts +2 -2
- package/src/handlers/gates/skill-input.ts +1 -10
- package/src/handlers/gates/skill-read.ts +1 -11
- package/src/handlers/gates/tool.ts +1 -11
- package/src/handlers/tool-call-boundary.ts +4 -1
- package/src/log-field-cap.ts +82 -0
- package/src/logging.ts +24 -3
- package/src/permission-events.ts +15 -2
- package/src/permission-gate.ts +11 -0
- package/src/permission-prompts.ts +4 -3
- package/src/permission-session.ts +1 -1
- package/src/permission-ui-prompt.ts +4 -2
- package/src/presentation/agent-renderer.ts +215 -0
- package/src/presentation/dialog-renderer.ts +8 -64
- package/src/presentation/fact-vocabulary.ts +103 -0
- package/src/presentation/forwarded-ask-payload.ts +42 -17
- package/src/presentation/path-ask-payload.ts +8 -1
- package/src/presentation/prompt-payload.ts +165 -4
- package/src/presentation/review-log-renderer.ts +51 -0
- package/src/service.ts +11 -0
- package/src/tool-input-preview.ts +0 -1
- package/src/tool-preview-formatter.ts +18 -33
- package/src/denial-messages.ts +0 -269
- package/src/presentation/legacy-message.ts +0 -117
|
@@ -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,
|
|
@@ -36,6 +37,7 @@ 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";
|
|
40
|
+
import type { PromptPayload } from "#src/presentation/prompt-payload";
|
|
39
41
|
import type { DebugReviewLogger } from "#src/session-logger";
|
|
40
42
|
import { toRecord } from "#src/value-guards";
|
|
41
43
|
import type { TerminalAuthorizer } from "./authorizer";
|
|
@@ -68,7 +70,7 @@ function getContextSystemPrompt(ctx: ForwarderContext): string | undefined {
|
|
|
68
70
|
|
|
69
71
|
/**
|
|
70
72
|
* The facts a forwarded request relays unchanged from the child's ask: the
|
|
71
|
-
* prompt
|
|
73
|
+
* prompt payload, the optional display projection, and the optional
|
|
72
74
|
* session-approval suggestion.
|
|
73
75
|
*
|
|
74
76
|
* Bundled into one object so the two-hop private chain
|
|
@@ -82,7 +84,8 @@ interface ForwardedRequestFacts {
|
|
|
82
84
|
* decision instead of a third being minted here.
|
|
83
85
|
*/
|
|
84
86
|
requestId: string;
|
|
85
|
-
|
|
87
|
+
/** The child's complete prompt payload, relayed for the serving node to render. */
|
|
88
|
+
payload: PromptPayload;
|
|
86
89
|
display?: ForwardedPromptDisplay;
|
|
87
90
|
sessionApproval?: ForwardedSessionApproval;
|
|
88
91
|
/** The child-fixed access facts; the edge completes them into a `ForwardedAccessIntent`. */
|
|
@@ -108,6 +111,9 @@ export interface ParentAuthorizerDeps {
|
|
|
108
111
|
* `confirmationUnavailable` is what keeps this out of the "User denied …"
|
|
109
112
|
* message (#719): a user who was never asked denied nothing. `denialReason`
|
|
110
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).
|
|
111
117
|
*/
|
|
112
118
|
function abandon(denialReason: string): PermissionPromptDecision {
|
|
113
119
|
return {
|
|
@@ -115,6 +121,31 @@ function abandon(denialReason: string): PermissionPromptDecision {
|
|
|
115
121
|
state: "denied",
|
|
116
122
|
confirmationUnavailable: true,
|
|
117
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
|
+
},
|
|
118
149
|
};
|
|
119
150
|
}
|
|
120
151
|
|
|
@@ -171,7 +202,7 @@ export class ParentAuthorizer implements TerminalAuthorizer {
|
|
|
171
202
|
const uiPrompt = buildUiPrompt(details);
|
|
172
203
|
return this.waitForForwardedApproval(this.ctx, {
|
|
173
204
|
requestId: details.requestId,
|
|
174
|
-
|
|
205
|
+
payload: details.payload,
|
|
175
206
|
display: {
|
|
176
207
|
source: uiPrompt.source,
|
|
177
208
|
surface: uiPrompt.surface,
|
|
@@ -300,7 +331,7 @@ export class ParentAuthorizer implements TerminalAuthorizer {
|
|
|
300
331
|
requesterSessionId,
|
|
301
332
|
targetSessionId,
|
|
302
333
|
requesterAgentName,
|
|
303
|
-
|
|
334
|
+
payload: facts.payload,
|
|
304
335
|
...(facts.display
|
|
305
336
|
? {
|
|
306
337
|
source: facts.display.source,
|
|
@@ -333,6 +364,7 @@ export class ParentAuthorizer implements TerminalAuthorizer {
|
|
|
333
364
|
this.logger,
|
|
334
365
|
responsePath,
|
|
335
366
|
);
|
|
367
|
+
const relayed = response ? relayDecision(response) : null;
|
|
336
368
|
this.logger.review("forwarded_permission.response_received", {
|
|
337
369
|
requestId,
|
|
338
370
|
approved: response?.approved ?? null,
|
|
@@ -341,10 +373,11 @@ export class ParentAuthorizer implements TerminalAuthorizer {
|
|
|
341
373
|
responderSessionId: response?.responderSessionId ?? null,
|
|
342
374
|
targetSessionId,
|
|
343
375
|
responsePath,
|
|
376
|
+
decidedBy: relayed?.decidedBy,
|
|
344
377
|
});
|
|
345
378
|
this.discardRequest(location, requestPath, responsePath);
|
|
346
379
|
return (
|
|
347
|
-
|
|
380
|
+
relayed ??
|
|
348
381
|
abandon("The parent session's permission response could not be read")
|
|
349
382
|
);
|
|
350
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
|
|
@@ -49,9 +62,9 @@ export interface Authorizer {
|
|
|
49
62
|
* ADR 0007's terminal-cannot-defer invariant.
|
|
50
63
|
*
|
|
51
64
|
* One method, one responsibility. `DenyingAuthorizer` ignores `details`;
|
|
52
|
-
* `LocalUserAuthorizer`
|
|
53
|
-
* event from
|
|
54
|
-
*
|
|
65
|
+
* `LocalUserAuthorizer` renders `payload` for the human and derives the UI
|
|
66
|
+
* event from the request facts; `ParentAuthorizer` ships `payload` over the
|
|
67
|
+
* wire so the serving node renders it under its own budget.
|
|
55
68
|
*/
|
|
56
69
|
export interface TerminalAuthorizer {
|
|
57
70
|
authorize(
|
|
@@ -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,
|
|
@@ -14,7 +15,6 @@ import {
|
|
|
14
15
|
} from "#src/authority/permission-forwarding";
|
|
15
16
|
import type { SubagentSessionRegistry } from "#src/authority/subagent-registry";
|
|
16
17
|
import { buildForwardedAskPayload } from "#src/presentation/forwarded-ask-payload";
|
|
17
|
-
import { renderLegacyMessage } from "#src/presentation/legacy-message";
|
|
18
18
|
import { SessionApproval } from "#src/session-approval";
|
|
19
19
|
import type { SessionApprovalRecorder } from "#src/session-approval-recorder";
|
|
20
20
|
import type { DebugReviewLogger } from "#src/session-logger";
|
|
@@ -23,6 +23,7 @@ import type { AskEscalator } from "./authorizer-selection";
|
|
|
23
23
|
import {
|
|
24
24
|
cleanupPermissionForwardingLocationIfEmpty,
|
|
25
25
|
ensureDirectoryExists,
|
|
26
|
+
formatUnknownErrorMessage,
|
|
26
27
|
getExistingPermissionForwardingLocation,
|
|
27
28
|
listRequestFiles,
|
|
28
29
|
logPermissionForwardingError,
|
|
@@ -100,7 +101,6 @@ function buildForwardedAskDetails(
|
|
|
100
101
|
requestId: request.id,
|
|
101
102
|
source: request.source ?? "tool_call",
|
|
102
103
|
agentName: request.requesterAgentName || null,
|
|
103
|
-
message: renderLegacyMessage(payload),
|
|
104
104
|
payload,
|
|
105
105
|
surface: request.surface ?? null,
|
|
106
106
|
value: request.value ?? null,
|
|
@@ -278,6 +278,9 @@ export class ForwardedRequestServer implements InboxProcessor {
|
|
|
278
278
|
* `approved` so the child records nothing (its next identical action
|
|
279
279
|
* re-forwards and resolves as recorded authority). Every other decision
|
|
280
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).
|
|
281
284
|
*/
|
|
282
285
|
private applyGrantScope(
|
|
283
286
|
request: ForwardedPermissionRequest,
|
|
@@ -300,7 +303,11 @@ export class ForwardedRequestServer implements InboxProcessor {
|
|
|
300
303
|
patterns: request.sessionApproval.patterns,
|
|
301
304
|
});
|
|
302
305
|
}
|
|
303
|
-
return {
|
|
306
|
+
return {
|
|
307
|
+
approved: true,
|
|
308
|
+
state: "approved",
|
|
309
|
+
decidedBy: decision.decidedBy,
|
|
310
|
+
};
|
|
304
311
|
}
|
|
305
312
|
|
|
306
313
|
/**
|
|
@@ -329,6 +336,7 @@ export class ForwardedRequestServer implements InboxProcessor {
|
|
|
329
336
|
responsePath,
|
|
330
337
|
resolution: decision.state,
|
|
331
338
|
denialReason: decision.denialReason ?? null,
|
|
339
|
+
decidedBy: decision.decidedBy,
|
|
332
340
|
},
|
|
333
341
|
);
|
|
334
342
|
try {
|
|
@@ -338,6 +346,9 @@ export class ForwardedRequestServer implements InboxProcessor {
|
|
|
338
346
|
denialReason: decision.denialReason,
|
|
339
347
|
responderSessionId: currentSessionId,
|
|
340
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,
|
|
341
352
|
} satisfies ForwardedPermissionResponse);
|
|
342
353
|
} catch (error) {
|
|
343
354
|
logPermissionForwardingError(
|
|
@@ -368,29 +379,48 @@ export class ForwardedRequestServer implements InboxProcessor {
|
|
|
368
379
|
request: ForwardedPermissionRequest,
|
|
369
380
|
logDetails: Record<string, unknown>,
|
|
370
381
|
): Promise<PermissionPromptDecision> {
|
|
371
|
-
const
|
|
372
|
-
? this.policy.resolve(request.accessIntent)
|
|
373
|
-
:
|
|
382
|
+
const check = request.accessIntent
|
|
383
|
+
? this.policy.resolve(request.accessIntent)
|
|
384
|
+
: null;
|
|
374
385
|
|
|
375
|
-
if (state
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
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 };
|
|
382
406
|
}
|
|
383
407
|
|
|
384
408
|
this.logger.review("forwarded_permission.prompted", logDetails);
|
|
385
409
|
try {
|
|
386
410
|
return await this.escalator.escalate(buildForwardedAskDetails(request));
|
|
387
411
|
} catch (error) {
|
|
412
|
+
const reason = formatUnknownErrorMessage(error);
|
|
388
413
|
logPermissionForwardingError(
|
|
389
414
|
this.logger,
|
|
390
415
|
`Failed to escalate forwarded permission request '${request.id}'`,
|
|
391
416
|
error,
|
|
392
417
|
);
|
|
393
|
-
|
|
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
|
+
};
|
|
394
424
|
}
|
|
395
425
|
}
|
|
396
426
|
|