@gotgenes/pi-permission-system 20.7.3 → 20.9.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 +35 -0
- package/README.md +3 -0
- package/config/config.example.json +2 -0
- package/dist/public.d.ts +212 -75
- package/docs/configuration.md +45 -9
- package/package.json +1 -1
- package/schemas/permissions.schema.json +10 -0
- package/src/access-intent/input-normalizer.ts +34 -1
- package/src/authority/approval-escalator.ts +53 -23
- package/src/authority/authorizer-chain.ts +60 -0
- package/src/authority/authorizer-registry.ts +69 -0
- package/src/authority/authorizer-selection.ts +54 -8
- package/src/authority/authorizer.ts +31 -4
- package/src/authority/delegation-envelope.ts +52 -0
- package/src/authority/denying-authorizer.ts +2 -2
- package/src/authority/forwarded-request-server.ts +22 -32
- package/src/authority/forwarder-context.ts +7 -0
- package/src/authority/forwarding-io.ts +61 -0
- package/src/authority/local-user-authorizer.ts +2 -2
- package/src/authority/permission-forwarding.ts +46 -0
- package/src/authority/permission-prompter.ts +16 -5
- package/src/config-loader.ts +5 -4
- package/src/config-schema.ts +7 -0
- package/src/extension-config.ts +5 -0
- package/src/handlers/gates/bash-external-directory.ts +6 -0
- package/src/handlers/gates/bash-path.ts +2 -0
- package/src/handlers/gates/external-directory.ts +2 -0
- package/src/handlers/gates/helpers.ts +33 -0
- package/src/handlers/gates/path.ts +2 -0
- package/src/handlers/gates/skill-input.ts +2 -0
- package/src/handlers/gates/skill-read.ts +2 -0
- package/src/handlers/gates/tool.ts +19 -6
- package/src/index.ts +26 -13
- package/src/permission-resolver.ts +17 -2
- package/src/permissions-service.ts +10 -0
- package/src/service.ts +52 -14
|
@@ -6,6 +6,7 @@ import {
|
|
|
6
6
|
} from "#src/active-agent";
|
|
7
7
|
import {
|
|
8
8
|
type ForwarderContext,
|
|
9
|
+
getCwd,
|
|
9
10
|
getSessionId,
|
|
10
11
|
} from "#src/authority/forwarder-context";
|
|
11
12
|
import {
|
|
@@ -20,6 +21,7 @@ import {
|
|
|
20
21
|
} from "#src/authority/forwarding-io";
|
|
21
22
|
import type { PermissionPromptDecision } from "#src/authority/permission-dialog";
|
|
22
23
|
import {
|
|
24
|
+
type ForwardedAccessFacts,
|
|
23
25
|
type ForwardedPermissionRequest,
|
|
24
26
|
type ForwardedPromptDisplay,
|
|
25
27
|
type ForwardedSessionApproval,
|
|
@@ -33,7 +35,7 @@ import type { SubagentSessionRegistry } from "#src/authority/subagent-registry";
|
|
|
33
35
|
import { buildUiPrompt } from "#src/permission-ui-prompt";
|
|
34
36
|
import type { DebugReviewLogger } from "#src/session-logger";
|
|
35
37
|
import { toRecord } from "#src/value-guards";
|
|
36
|
-
import type {
|
|
38
|
+
import type { TerminalAuthorizer } from "./authorizer";
|
|
37
39
|
import type { PromptPermissionDetails } from "./permission-prompter";
|
|
38
40
|
|
|
39
41
|
// ── Module-private helpers ────────────────────────────────────────────────
|
|
@@ -61,6 +63,23 @@ function getContextSystemPrompt(ctx: ForwarderContext): string | undefined {
|
|
|
61
63
|
|
|
62
64
|
// ── ParentAuthorizer ────────────────────────────────────────────────────
|
|
63
65
|
|
|
66
|
+
/**
|
|
67
|
+
* The facts a forwarded request relays unchanged from the child's ask: the
|
|
68
|
+
* prompt message, the optional display projection, and the optional
|
|
69
|
+
* session-approval suggestion.
|
|
70
|
+
*
|
|
71
|
+
* Bundled into one object so the two-hop private chain
|
|
72
|
+
* (`waitForForwardedApproval` → `buildForwardedRequest`) threads a single
|
|
73
|
+
* relayed value instead of three positional optionals.
|
|
74
|
+
*/
|
|
75
|
+
interface ForwardedRequestFacts {
|
|
76
|
+
message: string;
|
|
77
|
+
display?: ForwardedPromptDisplay;
|
|
78
|
+
sessionApproval?: ForwardedSessionApproval;
|
|
79
|
+
/** The child-fixed access facts; the edge completes them into a `ForwardedAccessIntent`. */
|
|
80
|
+
accessIntent?: ForwardedAccessFacts;
|
|
81
|
+
}
|
|
82
|
+
|
|
64
83
|
/** Constructor config for {@link ParentAuthorizer}. */
|
|
65
84
|
export interface ParentAuthorizerDeps {
|
|
66
85
|
forwardingDir: string;
|
|
@@ -81,7 +100,7 @@ export interface ParentAuthorizerDeps {
|
|
|
81
100
|
* (formerly `ApprovalEscalator.requestApproval`'s `hasUI` / `!isSubagent`
|
|
82
101
|
* arms, both dead once every caller routes through `selectAuthorizer`).
|
|
83
102
|
*/
|
|
84
|
-
export class ParentAuthorizer implements
|
|
103
|
+
export class ParentAuthorizer implements TerminalAuthorizer {
|
|
85
104
|
private readonly forwardingDir: string;
|
|
86
105
|
private readonly registry: SubagentSessionRegistry | undefined;
|
|
87
106
|
private readonly logger: DebugReviewLogger;
|
|
@@ -99,25 +118,23 @@ export class ParentAuthorizer implements Authorizer {
|
|
|
99
118
|
details: PromptPermissionDetails,
|
|
100
119
|
): Promise<PermissionPromptDecision> {
|
|
101
120
|
const uiPrompt = buildUiPrompt(details);
|
|
102
|
-
return this.waitForForwardedApproval(
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
{
|
|
121
|
+
return this.waitForForwardedApproval(this.ctx, {
|
|
122
|
+
message: details.message,
|
|
123
|
+
display: {
|
|
106
124
|
source: uiPrompt.source,
|
|
107
125
|
surface: uiPrompt.surface,
|
|
108
126
|
value: uiPrompt.value,
|
|
109
127
|
},
|
|
110
|
-
details.sessionApproval,
|
|
111
|
-
|
|
128
|
+
sessionApproval: details.sessionApproval,
|
|
129
|
+
accessIntent: details.accessIntent,
|
|
130
|
+
});
|
|
112
131
|
}
|
|
113
132
|
|
|
114
133
|
// ── Private methods ────────────────────────────────────────────────────
|
|
115
134
|
|
|
116
135
|
private async waitForForwardedApproval(
|
|
117
136
|
ctx: ForwarderContext,
|
|
118
|
-
|
|
119
|
-
forwarded?: ForwardedPromptDisplay,
|
|
120
|
-
sessionApproval?: ForwardedSessionApproval,
|
|
137
|
+
facts: ForwardedRequestFacts,
|
|
121
138
|
): Promise<PermissionPromptDecision> {
|
|
122
139
|
const requesterSessionId = getSessionId(ctx);
|
|
123
140
|
const targetSessionId = resolvePermissionForwardingTargetSessionId({
|
|
@@ -159,11 +176,9 @@ export class ParentAuthorizer implements Authorizer {
|
|
|
159
176
|
|
|
160
177
|
const request = this.buildForwardedRequest(
|
|
161
178
|
ctx,
|
|
162
|
-
|
|
179
|
+
facts,
|
|
163
180
|
requesterSessionId,
|
|
164
181
|
targetSessionId,
|
|
165
|
-
forwarded,
|
|
166
|
-
sessionApproval,
|
|
167
182
|
);
|
|
168
183
|
const requestPath = join(location.requestsDir, `${request.id}.json`);
|
|
169
184
|
const responsePath = join(location.responsesDir, `${request.id}.json`);
|
|
@@ -198,32 +213,47 @@ export class ParentAuthorizer implements Authorizer {
|
|
|
198
213
|
|
|
199
214
|
private buildForwardedRequest(
|
|
200
215
|
ctx: ForwarderContext,
|
|
201
|
-
|
|
216
|
+
facts: ForwardedRequestFacts,
|
|
202
217
|
requesterSessionId: string,
|
|
203
218
|
targetSessionId: string,
|
|
204
|
-
forwarded?: ForwardedPromptDisplay,
|
|
205
|
-
sessionApproval?: ForwardedSessionApproval,
|
|
206
219
|
): ForwardedPermissionRequest {
|
|
207
220
|
const requestId = `${Date.now()}-${Math.random().toString(36).slice(2, 10)}-${process.pid}`;
|
|
208
221
|
const requesterAgentName =
|
|
209
222
|
getActiveAgentName(ctx) ??
|
|
210
223
|
getActiveAgentNameFromSystemPrompt(getContextSystemPrompt(ctx)) ??
|
|
211
224
|
"unknown";
|
|
225
|
+
// Complete the child-fixed facts into a full ForwardedAccessIntent: the
|
|
226
|
+
// gate fixed the access facts; the edge stamps the requester identity it
|
|
227
|
+
// alone knows (cwd + principal). The parent resolves against this intent
|
|
228
|
+
// and never re-derives the match set (ADR 0008).
|
|
229
|
+
const accessIntent = facts.accessIntent
|
|
230
|
+
? {
|
|
231
|
+
...facts.accessIntent,
|
|
232
|
+
requesterCwd: getCwd(ctx),
|
|
233
|
+
principal: {
|
|
234
|
+
sessionId: requesterSessionId,
|
|
235
|
+
agentName: requesterAgentName,
|
|
236
|
+
},
|
|
237
|
+
}
|
|
238
|
+
: undefined;
|
|
212
239
|
return {
|
|
213
240
|
id: requestId,
|
|
214
241
|
createdAt: Date.now(),
|
|
215
242
|
requesterSessionId,
|
|
216
243
|
targetSessionId,
|
|
217
244
|
requesterAgentName,
|
|
218
|
-
message,
|
|
219
|
-
...(
|
|
245
|
+
message: facts.message,
|
|
246
|
+
...(facts.display
|
|
220
247
|
? {
|
|
221
|
-
source:
|
|
222
|
-
surface:
|
|
223
|
-
value:
|
|
248
|
+
source: facts.display.source,
|
|
249
|
+
surface: facts.display.surface,
|
|
250
|
+
value: facts.display.value,
|
|
224
251
|
}
|
|
225
252
|
: {}),
|
|
226
|
-
...(sessionApproval
|
|
253
|
+
...(facts.sessionApproval
|
|
254
|
+
? { sessionApproval: facts.sessionApproval }
|
|
255
|
+
: {}),
|
|
256
|
+
...(accessIntent ? { accessIntent } : {}),
|
|
227
257
|
};
|
|
228
258
|
}
|
|
229
259
|
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import type { PermissionQuery } from "#src/service";
|
|
2
|
+
import type {
|
|
3
|
+
Authorizer,
|
|
4
|
+
AuthorizerVerdict,
|
|
5
|
+
TerminalAuthorizer,
|
|
6
|
+
} from "./authorizer";
|
|
7
|
+
import { createDeniedPermissionDecision } from "./permission-dialog";
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Compose the live-authority chain (ADR 0007): try each non-terminal `link`
|
|
11
|
+
* in order, and on `defer` fall through to the next link, ending at the
|
|
12
|
+
* context-selected `terminal` that always decides.
|
|
13
|
+
*
|
|
14
|
+
* The signature is the type-level terminal-cannot-defer invariant: `links` are
|
|
15
|
+
* deferring {@link Authorizer}s while `terminal` is a {@link TerminalAuthorizer}
|
|
16
|
+
* (returns a full decision), so a deferring link cannot occupy the terminal
|
|
17
|
+
* slot.
|
|
18
|
+
*
|
|
19
|
+
* Each link is handed the session-scoped `query` at `authorize` time (ADR 0007
|
|
20
|
+
* §3) so it queries the deterministic engine at gate parity; the terminal never
|
|
21
|
+
* queries. With zero links the composed chain **is** the terminal instance
|
|
22
|
+
* (identity), so behavior is byte-identical to the pre-chain spine — the
|
|
23
|
+
* empty-links case that ships until a link registers.
|
|
24
|
+
*/
|
|
25
|
+
export function composeAuthorizerChain(
|
|
26
|
+
links: readonly Authorizer[],
|
|
27
|
+
terminal: TerminalAuthorizer,
|
|
28
|
+
query: PermissionQuery,
|
|
29
|
+
): TerminalAuthorizer {
|
|
30
|
+
if (links.length === 0) {
|
|
31
|
+
return terminal;
|
|
32
|
+
}
|
|
33
|
+
return {
|
|
34
|
+
async authorize(details) {
|
|
35
|
+
for (const link of links) {
|
|
36
|
+
const verdict = await link.authorize(details, query);
|
|
37
|
+
const decision = decideFromVerdict(verdict);
|
|
38
|
+
if (decision) {
|
|
39
|
+
return decision;
|
|
40
|
+
}
|
|
41
|
+
// `defer` \u2014 try the next link.
|
|
42
|
+
}
|
|
43
|
+
return terminal.authorize(details);
|
|
44
|
+
},
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Map a link's decisive verdict to a decision; `defer` yields `null`. */
|
|
49
|
+
function decideFromVerdict(verdict: AuthorizerVerdict) {
|
|
50
|
+
switch (verdict.kind) {
|
|
51
|
+
case "allow":
|
|
52
|
+
// A link grant is non-persistent (state `approved`, never
|
|
53
|
+
// `approved_for_session`), per ADR 0007's off-by-default envelope.
|
|
54
|
+
return { approved: true, state: "approved" } as const;
|
|
55
|
+
case "deny":
|
|
56
|
+
return createDeniedPermissionDecision(verdict.reason);
|
|
57
|
+
case "defer":
|
|
58
|
+
return null;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Registry for named live-authority chain links (ADR 0007 §4).
|
|
3
|
+
*
|
|
4
|
+
* A downstream extension offers a named `Authorizer` link via
|
|
5
|
+
* `PermissionsService.registerAuthorizer`; this registry stores the link's
|
|
6
|
+
* `authorize` callback so composition can bind names to capabilities. One link
|
|
7
|
+
* per name; duplicate registration throws.
|
|
8
|
+
*
|
|
9
|
+
* Registration alone grants no authority — a link decides nothing until the
|
|
10
|
+
* operator names it in the `authorizerChain` config (the opt-in activation
|
|
11
|
+
* model). `AuthorizerSelection` owns that config-order resolution; this registry
|
|
12
|
+
* is storage only.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import type { Authorizer } from "./authorizer";
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Read-only lookup used by chain composition (ISP — exposes only the read side,
|
|
19
|
+
* not the registration surface).
|
|
20
|
+
*/
|
|
21
|
+
export interface AuthorizerLookup {
|
|
22
|
+
get(name: string): Authorizer["authorize"] | undefined;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Registration side of the registry (ISP — exposes only the write surface,
|
|
27
|
+
* mirroring the read-only {@link AuthorizerLookup}).
|
|
28
|
+
*/
|
|
29
|
+
export interface AuthorizerRegistrar {
|
|
30
|
+
register(name: string, authorize: Authorizer["authorize"]): () => void;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Persistent registry mapping link names to their `authorize` callbacks.
|
|
35
|
+
*
|
|
36
|
+
* Owned by the extension factory (`index.ts`) so it survives across session
|
|
37
|
+
* activations. Exposed to sibling extensions via
|
|
38
|
+
* `PermissionsService.registerAuthorizer` and consulted by
|
|
39
|
+
* `AuthorizerSelection` during chain resolution.
|
|
40
|
+
*/
|
|
41
|
+
export class AuthorizerRegistry
|
|
42
|
+
implements AuthorizerLookup, AuthorizerRegistrar
|
|
43
|
+
{
|
|
44
|
+
private readonly links = new Map<string, Authorizer["authorize"]>();
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Register a link under `name`.
|
|
48
|
+
*
|
|
49
|
+
* Throws if a link is already registered for that name — keeps resolution
|
|
50
|
+
* deterministic (a pi-permission-system package priority). Returns a disposer
|
|
51
|
+
* that removes the link; the disposer is identity-guarded so a stale call
|
|
52
|
+
* cannot evict a later registration.
|
|
53
|
+
*/
|
|
54
|
+
register(name: string, authorize: Authorizer["authorize"]): () => void {
|
|
55
|
+
if (this.links.has(name)) {
|
|
56
|
+
throw new Error(`An authorizer is already registered for '${name}'.`);
|
|
57
|
+
}
|
|
58
|
+
this.links.set(name, authorize);
|
|
59
|
+
return () => {
|
|
60
|
+
if (this.links.get(name) === authorize) {
|
|
61
|
+
this.links.delete(name);
|
|
62
|
+
}
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
get(name: string): Authorizer["authorize"] | undefined {
|
|
67
|
+
return this.links.get(name);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
@@ -1,10 +1,15 @@
|
|
|
1
1
|
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
2
2
|
import type { PermissionPromptDecision } from "#src/authority/permission-dialog";
|
|
3
|
+
import type { PermissionQuery } from "#src/service";
|
|
3
4
|
import {
|
|
4
5
|
type Authorizer,
|
|
5
6
|
type AuthorizerSelectionDeps,
|
|
6
7
|
selectAuthorizer,
|
|
8
|
+
type TerminalAuthorizer,
|
|
7
9
|
} from "./authorizer";
|
|
10
|
+
import { composeAuthorizerChain } from "./authorizer-chain";
|
|
11
|
+
import type { AuthorizerLookup } from "./authorizer-registry";
|
|
12
|
+
import { encloseInDelegationEnvelope } from "./delegation-envelope";
|
|
8
13
|
import type {
|
|
9
14
|
PermissionPrompterApi,
|
|
10
15
|
PromptPermissionDetails,
|
|
@@ -49,38 +54,79 @@ export interface AskEscalator {
|
|
|
49
54
|
export class AuthorizerSelection
|
|
50
55
|
implements AskEscalator, AuthorizerSelectionLifecycle
|
|
51
56
|
{
|
|
52
|
-
private
|
|
57
|
+
private terminal: TerminalAuthorizer | null = null;
|
|
53
58
|
|
|
54
59
|
constructor(
|
|
55
60
|
private readonly deps: AuthorizerSelectionDeps & {
|
|
56
61
|
prompter: PermissionPrompterApi;
|
|
62
|
+
/** The session-scoped query injected into each chain link (ADR 0007 §3). */
|
|
63
|
+
getPermissionQuery: () => PermissionQuery;
|
|
64
|
+
/** Read-only lookup of registered links by name. */
|
|
65
|
+
authorizerRegistry: AuthorizerLookup;
|
|
66
|
+
/** The operator's configured link names, read live per ask. */
|
|
67
|
+
getAuthorizerChain: () => string[];
|
|
57
68
|
},
|
|
58
69
|
) {}
|
|
59
70
|
|
|
60
|
-
/**
|
|
71
|
+
/**
|
|
72
|
+
* Select the terminal Authorizer for `ctx` and store it. The non-terminal
|
|
73
|
+
* chain is composed per ask in {@link escalate}, not here: ADR 0007 §4 lets a
|
|
74
|
+
* link register in a `permissions:ready` handler that may fire after
|
|
75
|
+
* activation, so link resolution is deferred to the session's first ask.
|
|
76
|
+
*/
|
|
61
77
|
activate(ctx: ExtensionContext): void {
|
|
62
|
-
this.
|
|
78
|
+
this.terminal = selectAuthorizer(ctx, this.deps);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Resolve the operator's `authorizerChain` names to registered links, in
|
|
83
|
+
* config order (ADR 0007 invariant 1). An unregistered name is skipped with a
|
|
84
|
+
* warning (invariant 2 — more prompting, never less); each resolved link is
|
|
85
|
+
* wrapped in the bounded-delegation envelope so an `allow` on an excluded
|
|
86
|
+
* surface cannot exceed the operator's policy.
|
|
87
|
+
*/
|
|
88
|
+
private resolveConfiguredLinks(): Authorizer[] {
|
|
89
|
+
const links: Authorizer[] = [];
|
|
90
|
+
for (const name of this.deps.getAuthorizerChain()) {
|
|
91
|
+
const authorize = this.deps.authorizerRegistry.get(name);
|
|
92
|
+
if (authorize === undefined) {
|
|
93
|
+
this.deps.logger.review("authorizer_chain_unregistered_link", { name });
|
|
94
|
+
continue;
|
|
95
|
+
}
|
|
96
|
+
links.push({ authorize: encloseInDelegationEnvelope(authorize) });
|
|
97
|
+
}
|
|
98
|
+
return links;
|
|
63
99
|
}
|
|
64
100
|
|
|
65
101
|
/** Clear the stored selection. */
|
|
66
102
|
deactivate(): void {
|
|
67
|
-
this.
|
|
103
|
+
this.terminal = null;
|
|
68
104
|
}
|
|
69
105
|
|
|
70
106
|
/**
|
|
71
|
-
* Escalate an ask
|
|
107
|
+
* Escalate an ask through the composed chain and return its decision.
|
|
108
|
+
*
|
|
109
|
+
* Resolves the configured links freshly (so a link registered any time before
|
|
110
|
+
* this first ask is honored) and composes them ahead of the selected
|
|
111
|
+
* terminal. With zero links the composed value **is** the terminal instance,
|
|
112
|
+
* so behavior is identical to a bare terminal escalation.
|
|
72
113
|
*
|
|
73
|
-
* Rejects if no
|
|
114
|
+
* Rejects if no terminal has been selected — i.e. before the session was
|
|
74
115
|
* activated. Implements {@link AskEscalator}.
|
|
75
116
|
*/
|
|
76
117
|
escalate(
|
|
77
118
|
details: PromptPermissionDetails,
|
|
78
119
|
): Promise<PermissionPromptDecision> {
|
|
79
|
-
if (this.
|
|
120
|
+
if (this.terminal === null) {
|
|
80
121
|
return Promise.reject(
|
|
81
122
|
new Error("escalate called before the session was activated"),
|
|
82
123
|
);
|
|
83
124
|
}
|
|
84
|
-
|
|
125
|
+
const chain = composeAuthorizerChain(
|
|
126
|
+
this.resolveConfiguredLinks(),
|
|
127
|
+
this.terminal,
|
|
128
|
+
this.deps.getPermissionQuery(),
|
|
129
|
+
);
|
|
130
|
+
return this.deps.prompter.prompt(chain, details);
|
|
85
131
|
}
|
|
86
132
|
}
|
|
@@ -6,6 +6,7 @@ import type {
|
|
|
6
6
|
} from "#src/authority/permission-prompt-component";
|
|
7
7
|
import type { SubagentSessionRegistry } from "#src/authority/subagent-registry";
|
|
8
8
|
import type { PermissionEventBus } from "#src/permission-events";
|
|
9
|
+
import type { PermissionQuery } from "#src/service";
|
|
9
10
|
import type { DebugReviewLogger } from "#src/session-logger";
|
|
10
11
|
import { ParentAuthorizer } from "./approval-escalator";
|
|
11
12
|
import { DenyingAuthorizer } from "./denying-authorizer";
|
|
@@ -14,15 +15,41 @@ import type { PromptPermissionDetails } from "./permission-prompter";
|
|
|
14
15
|
import type { SubagentDetector } from "./subagent-detection";
|
|
15
16
|
|
|
16
17
|
/**
|
|
17
|
-
*
|
|
18
|
-
*
|
|
18
|
+
* A non-terminal chain link's ruling on an `ask`: decide (`allow`/`deny`) or
|
|
19
|
+
* pass the ask on to the next link (`defer`). A `deny` carries an optional
|
|
20
|
+
* teaching `reason` the invoking model sees, so it can self-correct.
|
|
21
|
+
*/
|
|
22
|
+
export type AuthorizerVerdict =
|
|
23
|
+
| { kind: "allow" }
|
|
24
|
+
| { kind: "deny"; reason?: string }
|
|
25
|
+
| { kind: "defer" };
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* A non-terminal link in the live-authority chain: reviews an `ask` and may
|
|
29
|
+
* decide it or defer to the next link (ADR 0007). The chain injects a narrow,
|
|
30
|
+
* session-scoped {@link PermissionQuery} at `authorize` time (§3), so a link
|
|
31
|
+
* queries the deterministic engine at gate parity rather than reaching for the
|
|
32
|
+
* cross-extension service via `Symbol.for()`.
|
|
33
|
+
*/
|
|
34
|
+
export interface Authorizer {
|
|
35
|
+
authorize(
|
|
36
|
+
details: PromptPermissionDetails,
|
|
37
|
+
query: PermissionQuery,
|
|
38
|
+
): Promise<AuthorizerVerdict>;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* The terminal link: on `ask`, rules on a single request and is told the
|
|
43
|
+
* decision. Structurally cannot defer — it always returns a full
|
|
44
|
+
* {@link PermissionPromptDecision}, which is the type-level enforcement of
|
|
45
|
+
* ADR 0007's terminal-cannot-defer invariant.
|
|
19
46
|
*
|
|
20
47
|
* One method, one responsibility. `DenyingAuthorizer` ignores `details`;
|
|
21
48
|
* `LocalUserAuthorizer` reads `message`/`sessionLabel` and derives the UI
|
|
22
49
|
* event from it; `ParentAuthorizer` reads `message` and derives the
|
|
23
50
|
* forwarded display from it.
|
|
24
51
|
*/
|
|
25
|
-
export interface
|
|
52
|
+
export interface TerminalAuthorizer {
|
|
26
53
|
authorize(
|
|
27
54
|
details: PromptPermissionDetails,
|
|
28
55
|
): Promise<PermissionPromptDecision>;
|
|
@@ -56,7 +83,7 @@ export interface AuthorizerSelectionDeps {
|
|
|
56
83
|
export function selectAuthorizer(
|
|
57
84
|
ctx: ExtensionContext,
|
|
58
85
|
deps: AuthorizerSelectionDeps,
|
|
59
|
-
):
|
|
86
|
+
): TerminalAuthorizer {
|
|
60
87
|
if (ctx.hasUI) {
|
|
61
88
|
return new LocalUserAuthorizer({
|
|
62
89
|
ui: ctx.ui,
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The bounded-delegation enforcement checkpoint (ADR 0007 §5).
|
|
3
|
+
*
|
|
4
|
+
* The chain owner caps every registered link's verdict so a buggy or over-eager
|
|
5
|
+
* external judge can never exceed the operator's policy: a link's `allow` on an
|
|
6
|
+
* excluded surface is downgraded to `defer`, letting the `ask` fall through to
|
|
7
|
+
* the terminal (a prompt) instead. The checkpoint only ever *tightens* a
|
|
8
|
+
* verdict — it never turns a `defer`/`deny` into an `allow`.
|
|
9
|
+
*
|
|
10
|
+
* The excluded set is the whole `path` surface plus `external_directory`. A
|
|
11
|
+
* finer secret-shaped-`path` exclusion (letting a link allow a non-secret path)
|
|
12
|
+
* is deferred to the allow-capable slice that needs it (#620); until then the
|
|
13
|
+
* conservative whole-surface exclusion ships. The checkpoint is dormant while
|
|
14
|
+
* the only registered links are deny-first (they never `allow`).
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import type { Authorizer } from "./authorizer";
|
|
18
|
+
import type { PromptPermissionDetails } from "./permission-prompter";
|
|
19
|
+
|
|
20
|
+
/** Surfaces on which a link may never grant an `allow` (ADR 0007 §5). */
|
|
21
|
+
export const DELEGATION_EXCLUDED_SURFACES: ReadonlySet<string> = new Set([
|
|
22
|
+
"external_directory",
|
|
23
|
+
"path",
|
|
24
|
+
]);
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Wrap a link's `authorize` so an `allow` on an excluded surface is capped to
|
|
28
|
+
* `defer`. All other verdicts, and `allow`s on non-excluded surfaces, pass
|
|
29
|
+
* through unchanged. `details` and the injected `query` are forwarded as-is.
|
|
30
|
+
*/
|
|
31
|
+
export function encloseInDelegationEnvelope(
|
|
32
|
+
authorize: Authorizer["authorize"],
|
|
33
|
+
): Authorizer["authorize"] {
|
|
34
|
+
return async (details, query) => {
|
|
35
|
+
const verdict = await authorize(details, query);
|
|
36
|
+
if (verdict.kind === "allow" && isExcludedSurface(details)) {
|
|
37
|
+
return { kind: "defer" };
|
|
38
|
+
}
|
|
39
|
+
return verdict;
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Whether the ask's surface is excluded from link grants. Reads the
|
|
45
|
+
* gate-authoritative `accessIntent.surface`, falling back to the display
|
|
46
|
+
* `surface`. Fail-safe: an ask whose surface cannot be determined is treated as
|
|
47
|
+
* excluded (more prompting, never less — ADR 0007 invariant 2).
|
|
48
|
+
*/
|
|
49
|
+
function isExcludedSurface(details: PromptPermissionDetails): boolean {
|
|
50
|
+
const surface = details.accessIntent?.surface ?? details.surface ?? undefined;
|
|
51
|
+
return surface === undefined || DELEGATION_EXCLUDED_SURFACES.has(surface);
|
|
52
|
+
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { PermissionPromptDecision } from "#src/authority/permission-dialog";
|
|
2
|
-
import type {
|
|
2
|
+
import type { TerminalAuthorizer } from "./authorizer";
|
|
3
3
|
|
|
4
4
|
/**
|
|
5
5
|
* Least-privilege Authorizer: no authority is reachable for this session
|
|
@@ -9,7 +9,7 @@ import type { Authorizer } from "./authorizer";
|
|
|
9
9
|
* distinguish "nobody could answer" from an interactive user denial when it
|
|
10
10
|
* derives the review-entry and decision-event resolution.
|
|
11
11
|
*/
|
|
12
|
-
export class DenyingAuthorizer implements
|
|
12
|
+
export class DenyingAuthorizer implements TerminalAuthorizer {
|
|
13
13
|
authorize(): Promise<PermissionPromptDecision> {
|
|
14
14
|
return Promise.resolve({
|
|
15
15
|
approved: false,
|
|
@@ -5,6 +5,7 @@ import {
|
|
|
5
5
|
} from "#src/authority/forwarder-context";
|
|
6
6
|
import type { PermissionPromptDecision } from "#src/authority/permission-dialog";
|
|
7
7
|
import {
|
|
8
|
+
type ForwardedAccessIntent,
|
|
8
9
|
type ForwardedPermissionRequest,
|
|
9
10
|
type ForwardedPermissionResponse,
|
|
10
11
|
isForwardedPermissionRequestForSession,
|
|
@@ -43,23 +44,25 @@ export interface InboxProcessor {
|
|
|
43
44
|
|
|
44
45
|
/**
|
|
45
46
|
* Recorded-authority view the serving node resolves a forwarded request
|
|
46
|
-
* against: answer one
|
|
47
|
-
* composed
|
|
48
|
-
*
|
|
47
|
+
* against: answer one {@link ForwardedAccessIntent} query on the serving
|
|
48
|
+
* session's composed ruleset, agent-scoped to the requester
|
|
49
|
+
* (`principal.agentName`, ADR 0008 §3) — the child-fixed `matchValues` are
|
|
50
|
+
* used as-is, never re-derived through this session's `PathNormalizer`/cwd.
|
|
49
51
|
*
|
|
50
52
|
* Narrow by design (ISP): the server needs one decision, not the whole
|
|
51
|
-
* resolver. The composition root satisfies it with
|
|
52
|
-
* `resolver.resolve`, the same
|
|
53
|
+
* resolver. The composition root satisfies it with
|
|
54
|
+
* `buildResolvedIntentFromMatchValues` plus `resolver.resolve`, the same
|
|
55
|
+
* `resolve` entry point `LocalPermissionsService` composes.
|
|
53
56
|
*/
|
|
54
57
|
export interface ServingPolicy {
|
|
55
|
-
|
|
58
|
+
resolve(intent: ForwardedAccessIntent): PermissionCheckResult;
|
|
56
59
|
}
|
|
57
60
|
|
|
58
61
|
/** Constructor config for `ForwardedRequestServer`. */
|
|
59
62
|
export interface ForwardedRequestServerDeps {
|
|
60
63
|
forwardingDir: string;
|
|
61
64
|
logger: DebugReviewLogger;
|
|
62
|
-
/** Recorded-authority resolution for
|
|
65
|
+
/** Recorded-authority resolution for a forwarded `ForwardedAccessIntent`. */
|
|
63
66
|
policy: ServingPolicy;
|
|
64
67
|
/** Escalation seam to the serving session's selected `Authorizer` on `ask`. */
|
|
65
68
|
escalator: AskEscalator;
|
|
@@ -87,22 +90,6 @@ function formatForwardedPermissionPrompt(
|
|
|
87
90
|
].join("\n");
|
|
88
91
|
}
|
|
89
92
|
|
|
90
|
-
/**
|
|
91
|
-
* A request is resolvable against the ruleset only when it carries a concrete
|
|
92
|
-
* `(surface, value)` display projection. A legacy/version-skew request without
|
|
93
|
-
* them floors to `ask` (escalate), never a silent grant.
|
|
94
|
-
*/
|
|
95
|
-
function hasDisplayFields(
|
|
96
|
-
request: ForwardedPermissionRequest,
|
|
97
|
-
): request is ForwardedPermissionRequest & { surface: string; value: string } {
|
|
98
|
-
return (
|
|
99
|
-
typeof request.surface === "string" &&
|
|
100
|
-
request.surface.length > 0 &&
|
|
101
|
-
typeof request.value === "string" &&
|
|
102
|
-
request.value.length > 0
|
|
103
|
-
);
|
|
104
|
-
}
|
|
105
|
-
|
|
106
93
|
/**
|
|
107
94
|
* Map a forwarded request onto the escalated ask's details, carrying the
|
|
108
95
|
* forwarded provenance (requester agent/session + the child's original display
|
|
@@ -135,9 +122,10 @@ function buildForwardedAskDetails(
|
|
|
135
122
|
/**
|
|
136
123
|
* Owner of the serving-down role of the forwarded-permission behavior:
|
|
137
124
|
* draining this session's forwarded-permission inbox and answering each
|
|
138
|
-
* request the same way the session resolves a local action —
|
|
139
|
-
* against
|
|
140
|
-
* selected `Authorizer` (`AskEscalator`) on `ask
|
|
125
|
+
* request the same way the session resolves a local action — resolving its
|
|
126
|
+
* `ForwardedAccessIntent` against recorded authority (`ServingPolicy`), then
|
|
127
|
+
* escalation to its selected `Authorizer` (`AskEscalator`) on `ask` (ADR
|
|
128
|
+
* 0008).
|
|
141
129
|
*/
|
|
142
130
|
export class ForwardedRequestServer implements InboxProcessor {
|
|
143
131
|
private readonly forwardingDir: string;
|
|
@@ -343,17 +331,19 @@ export class ForwardedRequestServer implements InboxProcessor {
|
|
|
343
331
|
|
|
344
332
|
/**
|
|
345
333
|
* Resolve the request the same way the session resolves a local action:
|
|
346
|
-
* recorded authority first (a request carrying
|
|
347
|
-
* against the serving node's
|
|
348
|
-
* yolo-rewritten, auto-approves;
|
|
349
|
-
*
|
|
334
|
+
* recorded authority first (a request carrying an `accessIntent` — the
|
|
335
|
+
* child-fixed facts, ADR 0008 §2 — resolves against the serving node's
|
|
336
|
+
* composed ruleset — `allow`, including yolo-rewritten, auto-approves;
|
|
337
|
+
* `deny` auto-denies), then escalate `ask` (or a request missing
|
|
338
|
+
* `accessIntent`, the version-skew floor, ADR 0008 §4) to the selected
|
|
339
|
+
* `Authorizer`.
|
|
350
340
|
*/
|
|
351
341
|
private async resolveDecision(
|
|
352
342
|
request: ForwardedPermissionRequest,
|
|
353
343
|
logDetails: Record<string, unknown>,
|
|
354
344
|
): Promise<PermissionPromptDecision> {
|
|
355
|
-
const state =
|
|
356
|
-
? this.policy.
|
|
345
|
+
const state = request.accessIntent
|
|
346
|
+
? this.policy.resolve(request.accessIntent).state
|
|
357
347
|
: "ask";
|
|
358
348
|
|
|
359
349
|
if (state === "allow") {
|
|
@@ -12,6 +12,8 @@ import type { PermissionDecisionUi } from "#src/authority/permission-dialog";
|
|
|
12
12
|
export interface ForwarderContext {
|
|
13
13
|
hasUI: boolean;
|
|
14
14
|
ui: PermissionDecisionUi;
|
|
15
|
+
/** The session's working directory, stamped onto a forwarded request as the requester cwd. */
|
|
16
|
+
cwd: string;
|
|
15
17
|
sessionManager: {
|
|
16
18
|
getSessionId(): string;
|
|
17
19
|
getSessionDir(): string;
|
|
@@ -19,6 +21,11 @@ export interface ForwarderContext {
|
|
|
19
21
|
};
|
|
20
22
|
}
|
|
21
23
|
|
|
24
|
+
/** Reads the current session cwd off `ctx`. */
|
|
25
|
+
export function getCwd(ctx: ForwarderContext): string {
|
|
26
|
+
return ctx.cwd;
|
|
27
|
+
}
|
|
28
|
+
|
|
22
29
|
/** Reads the current session id off `ctx`, falling back to `"unknown"`. */
|
|
23
30
|
export function getSessionId(ctx: ForwarderContext): string {
|
|
24
31
|
try {
|