@gotgenes/pi-permission-system 20.1.0 → 20.3.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 +25 -0
- package/docs/cross-extension-api.md +2 -1
- package/docs/subagent-integration.md +2 -0
- package/package.json +1 -1
- package/src/authority/approval-escalator.ts +17 -7
- package/src/authority/authorizer-selection.ts +26 -27
- package/src/authority/denying-authorizer.ts +9 -1
- package/src/authority/forwarded-request-server.ts +209 -87
- package/src/authority/forwarding-io.ts +27 -0
- package/src/authority/local-user-authorizer.ts +35 -5
- package/src/authority/permission-prompter.ts +30 -1
- package/src/handlers/gates/helpers.ts +4 -3
- package/src/handlers/gates/runner.ts +11 -7
- package/src/index.ts +38 -15
- package/src/pattern-suggest.ts +29 -0
- package/src/permission-dialog.ts +34 -0
- package/src/permission-forwarding.ts +23 -1
- package/src/permission-gate.ts +15 -21
- package/src/permission-ui-prompt.ts +39 -54
- package/src/session-approval.ts +11 -0
- package/src/gate-prompter.ts +0 -12
|
@@ -15,6 +15,7 @@ import {
|
|
|
15
15
|
createPermissionForwardingLocation,
|
|
16
16
|
type ForwardedPermissionRequest,
|
|
17
17
|
type ForwardedPermissionResponse,
|
|
18
|
+
type ForwardedSessionApproval,
|
|
18
19
|
type PermissionForwardingLocation,
|
|
19
20
|
} from "#src/permission-forwarding";
|
|
20
21
|
import type { DebugReviewLogger } from "#src/session-logger";
|
|
@@ -41,6 +42,31 @@ function asNullableDisplayString(value: unknown): string | null | undefined {
|
|
|
41
42
|
return undefined;
|
|
42
43
|
}
|
|
43
44
|
|
|
45
|
+
/**
|
|
46
|
+
* Narrow an unknown value to a `ForwardedSessionApproval`, or `undefined`.
|
|
47
|
+
*
|
|
48
|
+
* Tolerant read: the child's session-approval suggestion is optional (absent
|
|
49
|
+
* on an older child) and only accepted when well-formed — a non-empty surface
|
|
50
|
+
* and an all-string patterns array.
|
|
51
|
+
*/
|
|
52
|
+
function asForwardedSessionApproval(
|
|
53
|
+
value: unknown,
|
|
54
|
+
): ForwardedSessionApproval | undefined {
|
|
55
|
+
if (typeof value !== "object" || value === null) {
|
|
56
|
+
return undefined;
|
|
57
|
+
}
|
|
58
|
+
const candidate = value as Partial<ForwardedSessionApproval>;
|
|
59
|
+
if (
|
|
60
|
+
typeof candidate.surface !== "string" ||
|
|
61
|
+
candidate.surface.length === 0 ||
|
|
62
|
+
!Array.isArray(candidate.patterns) ||
|
|
63
|
+
!candidate.patterns.every((pattern) => typeof pattern === "string")
|
|
64
|
+
) {
|
|
65
|
+
return undefined;
|
|
66
|
+
}
|
|
67
|
+
return { surface: candidate.surface, patterns: [...candidate.patterns] };
|
|
68
|
+
}
|
|
69
|
+
|
|
44
70
|
export function formatUnknownErrorMessage(error: unknown): string {
|
|
45
71
|
if (error instanceof Error && error.message) {
|
|
46
72
|
return error.message;
|
|
@@ -323,6 +349,7 @@ export function readForwardedPermissionRequest(
|
|
|
323
349
|
source: asUiPromptSource(parsed.source),
|
|
324
350
|
surface: asNullableDisplayString(parsed.surface),
|
|
325
351
|
value: asNullableDisplayString(parsed.value),
|
|
352
|
+
sessionApproval: asForwardedSessionApproval(parsed.sessionApproval),
|
|
326
353
|
};
|
|
327
354
|
} catch (error) {
|
|
328
355
|
logPermissionForwardingWarning(
|
|
@@ -1,13 +1,15 @@
|
|
|
1
|
+
import { buildForwardedScopeLabels } from "#src/pattern-suggest";
|
|
1
2
|
import type {
|
|
2
3
|
PermissionDecisionUi,
|
|
3
4
|
PermissionPromptDecision,
|
|
5
|
+
RequestPermissionOptions,
|
|
4
6
|
requestPermissionDecisionFromUi,
|
|
5
7
|
} from "#src/permission-dialog";
|
|
6
8
|
import {
|
|
7
9
|
emitUiPromptEvent,
|
|
8
10
|
type PermissionEventBus,
|
|
9
11
|
} from "#src/permission-events";
|
|
10
|
-
import {
|
|
12
|
+
import { buildUiPrompt } from "#src/permission-ui-prompt";
|
|
11
13
|
import type { Authorizer } from "./authorizer";
|
|
12
14
|
import type { PromptPermissionDetails } from "./permission-prompter";
|
|
13
15
|
|
|
@@ -26,7 +28,10 @@ export interface LocalUserAuthorizerDeps {
|
|
|
26
28
|
*
|
|
27
29
|
* Emits the `permissions:ui_prompt` broadcast (moved here from
|
|
28
30
|
* `PermissionPrompter`'s `ctx.hasUI` arm) before showing the dialog, so
|
|
29
|
-
* observers know a decision is imminent.
|
|
31
|
+
* observers know a decision is imminent. This is the single emit site: a
|
|
32
|
+
* forwarded ask carries its provenance on `details.forwarding`, which this
|
|
33
|
+
* class renders (populated `forwarding` context + "(Subagent)" title) so the
|
|
34
|
+
* broadcast stays non-degraded (#292) without a second emission path.
|
|
30
35
|
*/
|
|
31
36
|
export class LocalUserAuthorizer implements Authorizer {
|
|
32
37
|
constructor(private readonly deps: LocalUserAuthorizerDeps) {}
|
|
@@ -34,13 +39,38 @@ export class LocalUserAuthorizer implements Authorizer {
|
|
|
34
39
|
authorize(
|
|
35
40
|
details: PromptPermissionDetails,
|
|
36
41
|
): Promise<PermissionPromptDecision> {
|
|
37
|
-
const uiPrompt =
|
|
42
|
+
const uiPrompt = buildUiPrompt(details);
|
|
38
43
|
emitUiPromptEvent(this.deps.events, uiPrompt);
|
|
39
44
|
return this.deps.requestPermissionDecisionFromUi(
|
|
40
45
|
this.deps.ui,
|
|
41
|
-
|
|
46
|
+
details.forwarding
|
|
47
|
+
? "Permission Required (Subagent)"
|
|
48
|
+
: "Permission Required",
|
|
42
49
|
details.message,
|
|
43
|
-
details
|
|
50
|
+
buildRequestOptions(details),
|
|
44
51
|
);
|
|
45
52
|
}
|
|
46
53
|
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* A forwarded ask carrying a session-approval suggestion offers the scope
|
|
57
|
+
* choice (subagent vs whole session); any other ask keeps its single
|
|
58
|
+
* "for this session" option (custom label when the gate supplied one).
|
|
59
|
+
*/
|
|
60
|
+
function buildRequestOptions(
|
|
61
|
+
details: PromptPermissionDetails,
|
|
62
|
+
): RequestPermissionOptions | undefined {
|
|
63
|
+
const pattern = details.sessionApproval?.patterns[0];
|
|
64
|
+
if (details.forwarding && details.sessionApproval && pattern) {
|
|
65
|
+
return {
|
|
66
|
+
sessionScope: buildForwardedScopeLabels(
|
|
67
|
+
details.forwarding.requesterAgentName,
|
|
68
|
+
details.sessionApproval.surface,
|
|
69
|
+
pattern,
|
|
70
|
+
),
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
return details.sessionLabel
|
|
74
|
+
? { sessionLabel: details.sessionLabel }
|
|
75
|
+
: undefined;
|
|
76
|
+
}
|
|
@@ -1,9 +1,23 @@
|
|
|
1
1
|
import type { PermissionPromptDecision } from "#src/permission-dialog";
|
|
2
|
+
import type { ForwardedSessionApproval } from "#src/permission-forwarding";
|
|
2
3
|
import type { ReviewLogger } from "#src/session-logger";
|
|
3
4
|
import type { Authorizer } from "./authorizer";
|
|
4
5
|
|
|
5
6
|
export type PermissionReviewSource = "tool_call" | "skill_input" | "skill_read";
|
|
6
7
|
|
|
8
|
+
/**
|
|
9
|
+
* Provenance of a forwarded ask: who is really asking, one hop below.
|
|
10
|
+
*
|
|
11
|
+
* Present on {@link PromptPermissionDetails} only when the ask was forwarded
|
|
12
|
+
* from a subagent. Structurally identical to the event's `ForwardedPromptContext`
|
|
13
|
+
* so the details flow straight into `buildUiPrompt`, but declared here to keep
|
|
14
|
+
* the prompter layer free of an events-module import.
|
|
15
|
+
*/
|
|
16
|
+
export interface ForwardedAskProvenance {
|
|
17
|
+
requesterAgentName: string | null;
|
|
18
|
+
requesterSessionId: string | null;
|
|
19
|
+
}
|
|
20
|
+
|
|
7
21
|
/** Details passed when prompting the user for a permission decision. */
|
|
8
22
|
export interface PromptPermissionDetails {
|
|
9
23
|
requestId: string;
|
|
@@ -19,6 +33,19 @@ export interface PromptPermissionDetails {
|
|
|
19
33
|
toolInputPreview?: string;
|
|
20
34
|
/** Override label for the "for this session" dialog option. */
|
|
21
35
|
sessionLabel?: string;
|
|
36
|
+
/** Explicit display-surface override (a forwarded ask carries the child's original). */
|
|
37
|
+
surface?: string | null;
|
|
38
|
+
/** Explicit display-value override (a forwarded ask carries the child's original). */
|
|
39
|
+
value?: string | null;
|
|
40
|
+
/** Present iff this ask was forwarded from a subagent; drives the non-degraded broadcast + "(Subagent)" title. */
|
|
41
|
+
forwarding?: ForwardedAskProvenance;
|
|
42
|
+
/**
|
|
43
|
+
* The session-approval suggestion for this ask. On the child's escalation it
|
|
44
|
+
* rides into the forwarded request; on the serving node it lets the dialog
|
|
45
|
+
* offer a whole-session grant scope. Absent when the gate computed no
|
|
46
|
+
* suggestion.
|
|
47
|
+
*/
|
|
48
|
+
sessionApproval?: ForwardedSessionApproval;
|
|
22
49
|
}
|
|
23
50
|
|
|
24
51
|
/**
|
|
@@ -75,7 +102,9 @@ export class PermissionPrompter implements PermissionPrompterApi {
|
|
|
75
102
|
: "permission_request.denied",
|
|
76
103
|
{
|
|
77
104
|
...details,
|
|
78
|
-
resolution: decision.
|
|
105
|
+
resolution: decision.confirmationUnavailable
|
|
106
|
+
? "confirmation_unavailable"
|
|
107
|
+
: decision.state,
|
|
79
108
|
denialReason: decision.denialReason,
|
|
80
109
|
},
|
|
81
110
|
);
|
|
@@ -53,13 +53,14 @@ export function buildDecisionEvent(
|
|
|
53
53
|
* @param action - The gate's resulting action ("allow" | "block").
|
|
54
54
|
* @param hasSession - True when the gate result carries a sessionApproval
|
|
55
55
|
* (indicates the user chose "for this session").
|
|
56
|
-
* @param
|
|
56
|
+
* @param confirmationUnavailable - True when the denial came from the
|
|
57
|
+
* DenyingAuthorizer (no live authority was reachable).
|
|
57
58
|
*/
|
|
58
59
|
export function deriveResolution(
|
|
59
60
|
state: "allow" | "deny" | "ask",
|
|
60
61
|
action: "allow" | "block",
|
|
61
62
|
hasSession: boolean,
|
|
62
|
-
|
|
63
|
+
confirmationUnavailable: boolean,
|
|
63
64
|
autoApproved = false,
|
|
64
65
|
): PermissionDecisionResolution {
|
|
65
66
|
if (state === "allow") return autoApproved ? "auto_approved" : "policy_allow";
|
|
@@ -69,5 +70,5 @@ export function deriveResolution(
|
|
|
69
70
|
if (autoApproved) return "auto_approved";
|
|
70
71
|
return hasSession ? "user_approved_for_session" : "user_approved";
|
|
71
72
|
}
|
|
72
|
-
return
|
|
73
|
+
return confirmationUnavailable ? "confirmation_unavailable" : "user_denied";
|
|
73
74
|
}
|
|
@@ -1,10 +1,10 @@
|
|
|
1
|
+
import type { AskEscalator } from "#src/authority/authorizer-selection";
|
|
1
2
|
import type { DecisionReporter } from "#src/decision-reporter";
|
|
2
3
|
import {
|
|
3
4
|
formatDenyReason,
|
|
4
5
|
formatUnavailableReason,
|
|
5
6
|
formatUserDeniedReason,
|
|
6
7
|
} from "#src/denial-messages";
|
|
7
|
-
import type { GatePrompter } from "#src/gate-prompter";
|
|
8
8
|
import type { PermissionPromptDecision } from "#src/permission-dialog";
|
|
9
9
|
import { applyPermissionGate } from "#src/permission-gate";
|
|
10
10
|
import type { ScopedPermissionResolver } from "#src/permission-resolver";
|
|
@@ -30,7 +30,7 @@ export class GateRunner {
|
|
|
30
30
|
constructor(
|
|
31
31
|
private readonly resolver: ScopedPermissionResolver,
|
|
32
32
|
private readonly recorder: SessionApprovalRecorder,
|
|
33
|
-
private readonly prompter:
|
|
33
|
+
private readonly prompter: AskEscalator,
|
|
34
34
|
private readonly reporter: DecisionReporter,
|
|
35
35
|
) {}
|
|
36
36
|
|
|
@@ -127,8 +127,8 @@ export class GateRunner {
|
|
|
127
127
|
return { action: "allow" };
|
|
128
128
|
}
|
|
129
129
|
|
|
130
|
-
// 3. Apply the deny/ask/allow gate
|
|
131
|
-
|
|
130
|
+
// 3. Apply the deny/ask/allow gate — always escalate on ask; the selected
|
|
131
|
+
// Authorizer answers (the DenyingAuthorizer by denying with a marker).
|
|
132
132
|
|
|
133
133
|
// Construct messages from the centralized formatter.
|
|
134
134
|
const messages = {
|
|
@@ -139,16 +139,20 @@ export class GateRunner {
|
|
|
139
139
|
};
|
|
140
140
|
|
|
141
141
|
let autoApproved = false;
|
|
142
|
+
let confirmationUnavailable = false;
|
|
142
143
|
const gateResult = await applyPermissionGate({
|
|
143
144
|
state: check.state,
|
|
144
|
-
canConfirm,
|
|
145
145
|
sessionApproval: descriptor.sessionApproval?.toGateApproval(),
|
|
146
146
|
promptForApproval: async () => {
|
|
147
|
-
const decision = await this.prompter.
|
|
147
|
+
const decision = await this.prompter.escalate({
|
|
148
148
|
requestId: toolCallId,
|
|
149
149
|
...descriptor.promptDetails,
|
|
150
|
+
...(descriptor.sessionApproval
|
|
151
|
+
? { sessionApproval: descriptor.sessionApproval.toForwardedData() }
|
|
152
|
+
: {}),
|
|
150
153
|
});
|
|
151
154
|
autoApproved = decision.autoApproved === true;
|
|
155
|
+
confirmationUnavailable = decision.confirmationUnavailable === true;
|
|
152
156
|
return decision;
|
|
153
157
|
},
|
|
154
158
|
writeLog: (event, details) =>
|
|
@@ -172,7 +176,7 @@ export class GateRunner {
|
|
|
172
176
|
check.state,
|
|
173
177
|
gateResult.action,
|
|
174
178
|
hasSessionApproval,
|
|
175
|
-
|
|
179
|
+
confirmationUnavailable,
|
|
176
180
|
autoApproved,
|
|
177
181
|
),
|
|
178
182
|
),
|
package/src/index.ts
CHANGED
|
@@ -3,7 +3,7 @@ import { getAgentDir, getPackageDir } from "@earendil-works/pi-coding-agent";
|
|
|
3
3
|
import { AuthorizerSelection } from "./authority/authorizer-selection";
|
|
4
4
|
import {
|
|
5
5
|
ForwardedRequestServer,
|
|
6
|
-
type
|
|
6
|
+
type ServingPolicy,
|
|
7
7
|
} from "./authority/forwarded-request-server";
|
|
8
8
|
import { PermissionPrompter } from "./authority/permission-prompter";
|
|
9
9
|
import { SubagentDetection } from "./authority/subagent-detection";
|
|
@@ -25,6 +25,7 @@ import { GateRunner } from "./handlers/gates/runner";
|
|
|
25
25
|
import { SkillInputGatePipeline } from "./handlers/gates/skill-input-gate-pipeline";
|
|
26
26
|
import { ToolCallGatePipeline } from "./handlers/gates/tool-call-gate-pipeline";
|
|
27
27
|
import { createFailClosedToolCall } from "./handlers/tool-call-boundary";
|
|
28
|
+
import { buildAccessIntentForSurface } from "./input-normalizer";
|
|
28
29
|
import { requestPermissionDecisionFromUi } from "./permission-dialog";
|
|
29
30
|
import { PermissionManager } from "./permission-manager";
|
|
30
31
|
import { PermissionResolver } from "./permission-resolver";
|
|
@@ -90,15 +91,6 @@ export default function piPermissionSystemExtension(pi: ExtensionAPI): void {
|
|
|
90
91
|
logger,
|
|
91
92
|
});
|
|
92
93
|
|
|
93
|
-
const requestServerDeps: ForwardedRequestServerDeps = {
|
|
94
|
-
forwardingDir: paths.forwardingDir,
|
|
95
|
-
logger,
|
|
96
|
-
events: pi.events,
|
|
97
|
-
requestPermissionDecisionFromUi,
|
|
98
|
-
config: configStore,
|
|
99
|
-
};
|
|
100
|
-
const requestServer = new ForwardedRequestServer(requestServerDeps);
|
|
101
|
-
|
|
102
94
|
const prompter = new PermissionPrompter({ logger });
|
|
103
95
|
|
|
104
96
|
const authorizerSelection = new AuthorizerSelection({
|
|
@@ -111,6 +103,42 @@ export default function piPermissionSystemExtension(pi: ExtensionAPI): void {
|
|
|
111
103
|
prompter,
|
|
112
104
|
});
|
|
113
105
|
|
|
106
|
+
// Resolver composes the manager + session ruleset and owns the
|
|
107
|
+
// access-path → path-values unwrap. Constructed here (before `session`) so
|
|
108
|
+
// the forwarded-request server's ServingPolicy can resolve against it; the
|
|
109
|
+
// service and gates below share this one instance.
|
|
110
|
+
const resolver = new PermissionResolver(permissionManager, sessionRules);
|
|
111
|
+
|
|
112
|
+
// Serving a forwarded request is resolution: evaluate (surface, value)
|
|
113
|
+
// against the serving node's composed base ruleset (agentName undefined —
|
|
114
|
+
// the child already applied its own per-agent overrides before forwarding).
|
|
115
|
+
// The session.getPathNormalizer() read is deferred behind the closure: inbox
|
|
116
|
+
// polling starts at session_start, after `session` is assigned — the same
|
|
117
|
+
// deferred-binding precedent as the logger notify sink below.
|
|
118
|
+
const servingPolicy: ServingPolicy = {
|
|
119
|
+
check: (surface, value) =>
|
|
120
|
+
resolver.resolve(
|
|
121
|
+
buildAccessIntentForSurface(
|
|
122
|
+
surface,
|
|
123
|
+
value ?? undefined,
|
|
124
|
+
session.getPathNormalizer(),
|
|
125
|
+
undefined,
|
|
126
|
+
),
|
|
127
|
+
),
|
|
128
|
+
};
|
|
129
|
+
|
|
130
|
+
const requestServer = new ForwardedRequestServer({
|
|
131
|
+
forwardingDir: paths.forwardingDir,
|
|
132
|
+
logger,
|
|
133
|
+
policy: servingPolicy,
|
|
134
|
+
escalator: authorizerSelection,
|
|
135
|
+
// Records a whole-session grant into the same SessionRules the resolver and
|
|
136
|
+
// gate runner read, so a serving-scope grant governs the parent and future
|
|
137
|
+
// forwarded resolutions.
|
|
138
|
+
recorder: sessionRules,
|
|
139
|
+
registry: subagentRegistry,
|
|
140
|
+
});
|
|
141
|
+
|
|
114
142
|
session = new PermissionSession(
|
|
115
143
|
paths,
|
|
116
144
|
new ForwardingManager(subagentDetection, requestServer),
|
|
@@ -136,11 +164,6 @@ export default function piPermissionSystemExtension(pi: ExtensionAPI): void {
|
|
|
136
164
|
),
|
|
137
165
|
});
|
|
138
166
|
|
|
139
|
-
// Resolver composes the manager + session ruleset and owns the
|
|
140
|
-
// access-path → path-values unwrap; the service routes its policy
|
|
141
|
-
// queries through it, so it is constructed before it.
|
|
142
|
-
const resolver = new PermissionResolver(permissionManager, sessionRules);
|
|
143
|
-
|
|
144
167
|
const permissionsService = new LocalPermissionsService(
|
|
145
168
|
resolver,
|
|
146
169
|
session,
|
package/src/pattern-suggest.ts
CHANGED
|
@@ -62,6 +62,35 @@ export function suggestMcpPattern(target: string): string {
|
|
|
62
62
|
return "*";
|
|
63
63
|
}
|
|
64
64
|
|
|
65
|
+
/** Scope labels for the forwarded-approval two-step scope select. */
|
|
66
|
+
export interface ForwardedScopeLabels {
|
|
67
|
+
/** Least-privilege default: record on the requesting subagent only. */
|
|
68
|
+
subagentLabel: string;
|
|
69
|
+
/** Record on the serving node — covers the parent and all subagents. */
|
|
70
|
+
servingSessionLabel: string;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Build the two scope labels shown when a human grants a forwarded request
|
|
75
|
+
* "for this session."
|
|
76
|
+
*
|
|
77
|
+
* The subagent option names the requester (least privilege); the whole-session
|
|
78
|
+
* option restates the surface + pattern being granted session-wide.
|
|
79
|
+
*/
|
|
80
|
+
export function buildForwardedScopeLabels(
|
|
81
|
+
agentName: string | null,
|
|
82
|
+
surface: string,
|
|
83
|
+
pattern: string,
|
|
84
|
+
): ForwardedScopeLabels {
|
|
85
|
+
const subagentLabel = agentName
|
|
86
|
+
? `This subagent ('${agentName}') only`
|
|
87
|
+
: "This subagent only";
|
|
88
|
+
return {
|
|
89
|
+
subagentLabel,
|
|
90
|
+
servingSessionLabel: `The whole session — allow ${surface} "${pattern}" for parent and all subagents`,
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
|
|
65
94
|
/** Surface-aware human-readable labels for the session-approval option. */
|
|
66
95
|
function buildLabel(pattern: string, surface: string): string {
|
|
67
96
|
switch (surface) {
|
package/src/permission-dialog.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
export type PermissionDecisionState =
|
|
2
2
|
| "approved"
|
|
3
3
|
| "approved_for_session"
|
|
4
|
+
| "approved_for_serving_session"
|
|
4
5
|
| "denied"
|
|
5
6
|
| "denied_with_reason";
|
|
6
7
|
|
|
@@ -14,6 +15,14 @@ export type PermissionPromptDecision = {
|
|
|
14
15
|
* rather than "user_approved" in the permissions:decision broadcast.
|
|
15
16
|
*/
|
|
16
17
|
autoApproved?: true;
|
|
18
|
+
/**
|
|
19
|
+
* True when no live authority was reachable and the DenyingAuthorizer denied
|
|
20
|
+
* this ask (a no-UI, non-subagent session). Consumed by deriveResolution (the
|
|
21
|
+
* decision-event resolution), the gate (block reason), and PermissionPrompter
|
|
22
|
+
* (review-entry resolution) to emit "confirmation_unavailable" rather than a
|
|
23
|
+
* plain user denial.
|
|
24
|
+
*/
|
|
25
|
+
confirmationUnavailable?: true;
|
|
17
26
|
};
|
|
18
27
|
|
|
19
28
|
export interface PermissionDecisionUi {
|
|
@@ -59,6 +68,7 @@ export function isPermissionDecisionState(
|
|
|
59
68
|
return (
|
|
60
69
|
value === "approved" ||
|
|
61
70
|
value === "approved_for_session" ||
|
|
71
|
+
value === "approved_for_serving_session" ||
|
|
62
72
|
value === "denied" ||
|
|
63
73
|
value === "denied_with_reason"
|
|
64
74
|
);
|
|
@@ -67,6 +77,15 @@ export function isPermissionDecisionState(
|
|
|
67
77
|
export interface RequestPermissionOptions {
|
|
68
78
|
/** Override the "for this session" option label (e.g. to show the suggested pattern). */
|
|
69
79
|
sessionLabel?: string;
|
|
80
|
+
/**
|
|
81
|
+
* Forwarded asks only: when set, choosing the "for this session" option opens
|
|
82
|
+
* a second select asking whether the grant applies to the requesting subagent
|
|
83
|
+
* only (the least-privilege default) or the whole serving session.
|
|
84
|
+
*/
|
|
85
|
+
sessionScope?: {
|
|
86
|
+
subagentLabel: string;
|
|
87
|
+
servingSessionLabel: string;
|
|
88
|
+
};
|
|
70
89
|
}
|
|
71
90
|
|
|
72
91
|
export async function requestPermissionDecisionFromUi(
|
|
@@ -95,6 +114,21 @@ export async function requestPermissionDecisionFromUi(
|
|
|
95
114
|
}
|
|
96
115
|
|
|
97
116
|
if (selected === sessionOption) {
|
|
117
|
+
if (options?.sessionScope) {
|
|
118
|
+
const scope = await ui.select(`${title}\nApply this session grant to:`, [
|
|
119
|
+
options.sessionScope.subagentLabel,
|
|
120
|
+
options.sessionScope.servingSessionLabel,
|
|
121
|
+
]);
|
|
122
|
+
return {
|
|
123
|
+
approved: true,
|
|
124
|
+
// A cancelled scope select (undefined) falls back to the
|
|
125
|
+
// least-privilege subagent scope.
|
|
126
|
+
state:
|
|
127
|
+
scope === options.sessionScope.servingSessionLabel
|
|
128
|
+
? "approved_for_serving_session"
|
|
129
|
+
: "approved_for_session",
|
|
130
|
+
};
|
|
131
|
+
}
|
|
98
132
|
return {
|
|
99
133
|
approved: true,
|
|
100
134
|
state: "approved_for_session",
|
|
@@ -44,7 +44,8 @@ const SESSION_FORWARDING_RESPONSES_DIRECTORY_NAME = "responses";
|
|
|
44
44
|
* can emit a non-degraded `permissions:ui_prompt` event.
|
|
45
45
|
*
|
|
46
46
|
* Carried separately from the prompt message because the parent reconstructs
|
|
47
|
-
* the original event
|
|
47
|
+
* the original event from the escalated ask's details (`buildUiPrompt`), not
|
|
48
|
+
* from the message text.
|
|
48
49
|
*/
|
|
49
50
|
export interface ForwardedPromptDisplay {
|
|
50
51
|
source: PermissionUiPromptSource;
|
|
@@ -52,6 +53,20 @@ export interface ForwardedPromptDisplay {
|
|
|
52
53
|
value: string | null;
|
|
53
54
|
}
|
|
54
55
|
|
|
56
|
+
/**
|
|
57
|
+
* The child's session-approval suggestion, relayed to the serving node so a
|
|
58
|
+
* human who grants "the whole session" records the same pattern the child
|
|
59
|
+
* would have recorded locally.
|
|
60
|
+
*
|
|
61
|
+
* A plain data shape (not the `SessionApproval` value object) so it serializes
|
|
62
|
+
* onto the forwarded request; the serving node rebuilds a `SessionApproval`
|
|
63
|
+
* from it via `SessionApproval.multiple`.
|
|
64
|
+
*/
|
|
65
|
+
export interface ForwardedSessionApproval {
|
|
66
|
+
surface: string;
|
|
67
|
+
patterns: readonly string[];
|
|
68
|
+
}
|
|
69
|
+
|
|
55
70
|
export type ForwardedPermissionRequest = {
|
|
56
71
|
id: string;
|
|
57
72
|
createdAt: number;
|
|
@@ -68,6 +83,13 @@ export type ForwardedPermissionRequest = {
|
|
|
68
83
|
source?: PermissionUiPromptSource;
|
|
69
84
|
surface?: string | null;
|
|
70
85
|
value?: string | null;
|
|
86
|
+
/**
|
|
87
|
+
* The child's session-approval suggestion. Present when the child computed a
|
|
88
|
+
* "for this session" pattern for the ask; lets the serving node record a
|
|
89
|
+
* whole-session grant. Optional for version-skew tolerance (an older child
|
|
90
|
+
* omits it, and the serving dialog then offers no scope choice).
|
|
91
|
+
*/
|
|
92
|
+
sessionApproval?: ForwardedSessionApproval;
|
|
71
93
|
};
|
|
72
94
|
|
|
73
95
|
export type ForwardedPermissionResponse = {
|
package/src/permission-gate.ts
CHANGED
|
@@ -10,10 +10,11 @@ export interface PermissionGateParams {
|
|
|
10
10
|
/** The resolved permission state from checkPermission(). */
|
|
11
11
|
state: "allow" | "deny" | "ask";
|
|
12
12
|
|
|
13
|
-
/**
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
13
|
+
/**
|
|
14
|
+
* Escalate the ask to the session's Authorizer for a decision. Called for
|
|
15
|
+
* every `ask`; the DenyingAuthorizer answers by denying with the
|
|
16
|
+
* `confirmationUnavailable` marker when no live authority is reachable.
|
|
17
|
+
*/
|
|
17
18
|
promptForApproval: () => Promise<PermissionPromptDecision>;
|
|
18
19
|
|
|
19
20
|
/**
|
|
@@ -45,14 +46,7 @@ export interface PermissionGateParams {
|
|
|
45
46
|
export async function applyPermissionGate(
|
|
46
47
|
params: PermissionGateParams,
|
|
47
48
|
): Promise<PermissionGateResult> {
|
|
48
|
-
const {
|
|
49
|
-
state,
|
|
50
|
-
canConfirm,
|
|
51
|
-
promptForApproval,
|
|
52
|
-
writeLog,
|
|
53
|
-
logContext,
|
|
54
|
-
messages,
|
|
55
|
-
} = params;
|
|
49
|
+
const { state, promptForApproval, writeLog, logContext, messages } = params;
|
|
56
50
|
|
|
57
51
|
if (state === "deny") {
|
|
58
52
|
writeLog("permission_request.blocked", {
|
|
@@ -63,17 +57,17 @@ export async function applyPermissionGate(
|
|
|
63
57
|
}
|
|
64
58
|
|
|
65
59
|
if (state === "ask") {
|
|
66
|
-
if (!canConfirm) {
|
|
67
|
-
writeLog("permission_request.blocked", {
|
|
68
|
-
...logContext,
|
|
69
|
-
resolution: "confirmation_unavailable",
|
|
70
|
-
});
|
|
71
|
-
return { action: "block", reason: messages.unavailableReason };
|
|
72
|
-
}
|
|
73
|
-
|
|
74
60
|
const decision = await promptForApproval();
|
|
75
61
|
if (!decision.approved) {
|
|
76
|
-
|
|
62
|
+
// The gate writes no review entry for an ask denial — the prompter
|
|
63
|
+
// brackets it (waiting/denied). The block reason distinguishes an
|
|
64
|
+
// absent-authority denial (confirmationUnavailable) from a user denial.
|
|
65
|
+
return {
|
|
66
|
+
action: "block",
|
|
67
|
+
reason: decision.confirmationUnavailable
|
|
68
|
+
? messages.unavailableReason
|
|
69
|
+
: messages.userDeniedReason(decision),
|
|
70
|
+
};
|
|
77
71
|
}
|
|
78
72
|
if (decision.state === "approved_for_session" && params.sessionApproval) {
|
|
79
73
|
return { action: "allow", sessionApproval: params.sessionApproval };
|
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Centralized construction for `permissions:ui_prompt` payloads.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
* public contract's shape — including the normalized `surface`/`value`
|
|
6
|
-
* projection — lives in exactly one place and
|
|
4
|
+
* The single builder `buildUiPrompt` handles both direct and forwarded asks, so
|
|
5
|
+
* the public contract's shape — including the normalized `surface`/`value`
|
|
6
|
+
* projection and the `forwarding` context — lives in exactly one place and
|
|
7
|
+
* cannot drift by source.
|
|
7
8
|
*
|
|
8
9
|
* This module is a leaf: it owns narrow input types that each call site's
|
|
9
10
|
* domain object satisfies structurally, so it imports nothing from the
|
|
@@ -11,8 +12,8 @@
|
|
|
11
12
|
*/
|
|
12
13
|
|
|
13
14
|
import type {
|
|
15
|
+
ForwardedPromptContext,
|
|
14
16
|
PermissionUiPromptEvent,
|
|
15
|
-
PermissionUiPromptSource,
|
|
16
17
|
} from "./permission-events";
|
|
17
18
|
|
|
18
19
|
/** Input for a direct (non-forwarded) tool or skill prompt. */
|
|
@@ -28,18 +29,42 @@ export interface DirectPromptInput {
|
|
|
28
29
|
target?: string;
|
|
29
30
|
}
|
|
30
31
|
|
|
31
|
-
/**
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
32
|
+
/**
|
|
33
|
+
* Input for any UI prompt — direct or forwarded.
|
|
34
|
+
*
|
|
35
|
+
* A direct prompt supplies only the `DirectPromptInput` fields and lets
|
|
36
|
+
* `surface`/`value` derive and `forwarding` default to `null`; a forwarded ask
|
|
37
|
+
* supplies the child's original `surface`/`value` projection explicitly and a
|
|
38
|
+
* populated `forwarding` context, so the parent's broadcast stays non-degraded
|
|
39
|
+
* (the #292 contract hardening).
|
|
40
|
+
*/
|
|
41
|
+
export interface UiPromptInput extends DirectPromptInput {
|
|
42
|
+
/** Explicit display surface; falls back to the derived projection when omitted. */
|
|
40
43
|
surface?: string | null;
|
|
41
|
-
/**
|
|
44
|
+
/** Explicit display value; falls back to the derived projection when omitted. */
|
|
42
45
|
value?: string | null;
|
|
46
|
+
/** Forwarding context for a forwarded subagent ask; `null`/omitted for a direct prompt. */
|
|
47
|
+
forwarding?: ForwardedPromptContext | null;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Build a `permissions:ui_prompt` event from either a direct or a forwarded ask.
|
|
52
|
+
*
|
|
53
|
+
* `surface`/`value` use the explicit override when the caller sets them (an
|
|
54
|
+
* explicit `null` is honored, not treated as "derive"); otherwise they fall
|
|
55
|
+
* back to the direct-prompt projection. `forwarding` passes through, defaulting
|
|
56
|
+
* to `null`.
|
|
57
|
+
*/
|
|
58
|
+
export function buildUiPrompt(input: UiPromptInput): PermissionUiPromptEvent {
|
|
59
|
+
return {
|
|
60
|
+
requestId: input.requestId,
|
|
61
|
+
source: input.source,
|
|
62
|
+
surface: input.surface !== undefined ? input.surface : directSurface(input),
|
|
63
|
+
value: input.value !== undefined ? input.value : directValue(input),
|
|
64
|
+
agentName: input.agentName,
|
|
65
|
+
message: input.message,
|
|
66
|
+
forwarding: input.forwarding ?? null,
|
|
67
|
+
};
|
|
43
68
|
}
|
|
44
69
|
|
|
45
70
|
/** Normalized display surface for a direct prompt. */
|
|
@@ -61,43 +86,3 @@ function directValue(input: DirectPromptInput): string | null {
|
|
|
61
86
|
null
|
|
62
87
|
);
|
|
63
88
|
}
|
|
64
|
-
|
|
65
|
-
/** Build the UI prompt event for a direct tool/skill prompt. */
|
|
66
|
-
export function buildDirectUiPrompt(
|
|
67
|
-
input: DirectPromptInput,
|
|
68
|
-
): PermissionUiPromptEvent {
|
|
69
|
-
return {
|
|
70
|
-
requestId: input.requestId,
|
|
71
|
-
source: input.source,
|
|
72
|
-
surface: directSurface(input),
|
|
73
|
-
value: directValue(input),
|
|
74
|
-
agentName: input.agentName,
|
|
75
|
-
message: input.message,
|
|
76
|
-
forwarding: null,
|
|
77
|
-
};
|
|
78
|
-
}
|
|
79
|
-
|
|
80
|
-
/**
|
|
81
|
-
* Build the UI prompt event for a file-forwarded subagent prompt.
|
|
82
|
-
*
|
|
83
|
-
* `source` defaults to `"tool_call"` (the dominant forwarded origin) when the
|
|
84
|
-
* persisted request predates carrying it — a parent on a newer version may read
|
|
85
|
-
* a request written by an older child during an upgrade. The consumer still
|
|
86
|
-
* receives the notify-now signal, message, and forwarding context.
|
|
87
|
-
*/
|
|
88
|
-
export function buildForwardedUiPrompt(
|
|
89
|
-
input: ForwardedPromptInput,
|
|
90
|
-
): PermissionUiPromptEvent {
|
|
91
|
-
return {
|
|
92
|
-
requestId: input.requestId,
|
|
93
|
-
source: input.source ?? "tool_call",
|
|
94
|
-
surface: input.surface ?? null,
|
|
95
|
-
value: input.value ?? null,
|
|
96
|
-
agentName: input.requesterAgentName,
|
|
97
|
-
message: input.message,
|
|
98
|
-
forwarding: {
|
|
99
|
-
requesterAgentName: input.requesterAgentName,
|
|
100
|
-
requesterSessionId: input.requesterSessionId,
|
|
101
|
-
},
|
|
102
|
-
};
|
|
103
|
-
}
|