@gotgenes/pi-permission-system 30.0.0 → 30.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +31 -0
- package/README.md +1 -0
- package/dist/public.d.ts +24 -2
- package/docs/configuration.md +14 -1
- package/docs/cross-extension-api.md +21 -5
- package/docs/session-approvals.md +36 -1
- package/package.json +1 -1
- package/src/access-intent/path-surfaces.ts +35 -1
- package/src/approval-grant.ts +64 -0
- package/src/authority/forwarded-request-server.ts +8 -2
- package/src/authority/forwarding-io.ts +6 -1
- package/src/authority/local-user-authorizer.ts +37 -17
- package/src/authority/permission-dialog.ts +49 -16
- package/src/authority/permission-forwarding.ts +11 -1
- package/src/authority/permission-prompt-component.ts +18 -5
- package/src/authority/permission-prompt-decision.ts +95 -15
- package/src/authority/permission-prompter.ts +25 -0
- package/src/handlers/before-agent-start.ts +8 -8
- package/src/handlers/gates/runner.ts +9 -7
- package/src/pattern-suggest.ts +73 -5
- package/src/permission-gate.ts +16 -3
- package/src/permission-manager.ts +20 -0
- package/src/permission-resolver.ts +9 -0
- package/src/permissions-service.ts +8 -0
- package/src/rule.ts +49 -0
- package/src/service.ts +24 -2
- package/src/session-approval.ts +19 -1
|
@@ -22,6 +22,7 @@ import {
|
|
|
22
22
|
type PromptModelConfig,
|
|
23
23
|
type PromptViewState,
|
|
24
24
|
reducePrompt,
|
|
25
|
+
visibleOptionKeys,
|
|
25
26
|
} from "#src/authority/permission-prompt-decision";
|
|
26
27
|
import {
|
|
27
28
|
completeViewBudget,
|
|
@@ -127,12 +128,11 @@ const DEFAULT_SESSION_LABEL = "Yes, for this session";
|
|
|
127
128
|
const OPTION_LABELS: Record<PromptKey, string> = {
|
|
128
129
|
y: "Yes",
|
|
129
130
|
s: DEFAULT_SESSION_LABEL,
|
|
131
|
+
b: "Yes, for this session in both directions",
|
|
130
132
|
n: "No",
|
|
131
133
|
r: "No, provide reason",
|
|
132
134
|
};
|
|
133
135
|
|
|
134
|
-
const OPTION_ORDER: readonly PromptKey[] = ["y", "s", "n", "r"];
|
|
135
|
-
|
|
136
136
|
export function presentInlinePermissionPrompt(
|
|
137
137
|
view: PermissionPromptView,
|
|
138
138
|
title: string,
|
|
@@ -142,6 +142,7 @@ export function presentInlinePermissionPrompt(
|
|
|
142
142
|
const config: PromptModelConfig = {
|
|
143
143
|
doublePressToConfirm: view.doublePressToConfirm,
|
|
144
144
|
sessionLabel: options?.sessionLabel ?? DEFAULT_SESSION_LABEL,
|
|
145
|
+
widthLabel: options?.sessionWidth?.label,
|
|
145
146
|
sessionScope: options?.sessionScope,
|
|
146
147
|
};
|
|
147
148
|
return view.ui.custom<UnattributedDecision>(
|
|
@@ -330,7 +331,9 @@ class PermissionPromptComponent implements Component {
|
|
|
330
331
|
return { type: "cancel" };
|
|
331
332
|
}
|
|
332
333
|
if (this.state.step === "decision") {
|
|
333
|
-
const key =
|
|
334
|
+
const key = visibleOptionKeys(this.config).find((option) =>
|
|
335
|
+
matchesKey(data, option),
|
|
336
|
+
);
|
|
334
337
|
if (key) {
|
|
335
338
|
return { type: "hotkey", key };
|
|
336
339
|
}
|
|
@@ -354,8 +357,8 @@ class PermissionPromptComponent implements Component {
|
|
|
354
357
|
private renderDecision(width: number): string[] {
|
|
355
358
|
const ask = this.renderAsk(width);
|
|
356
359
|
const lines = [this.theme.fg("accent", this.title), ...ask.lines, ""];
|
|
357
|
-
for (const key of
|
|
358
|
-
const label =
|
|
360
|
+
for (const key of visibleOptionKeys(this.config)) {
|
|
361
|
+
const label = this.labelFor(key);
|
|
359
362
|
const selected = this.state.highlightedKey === key;
|
|
360
363
|
const marker = selected ? "▶" : " ";
|
|
361
364
|
const row = `${marker} (${key}) ${label}`;
|
|
@@ -366,6 +369,16 @@ class PermissionPromptComponent implements Component {
|
|
|
366
369
|
return lines;
|
|
367
370
|
}
|
|
368
371
|
|
|
372
|
+
/**
|
|
373
|
+
* The row label for a key: the two session options carry ask-supplied text
|
|
374
|
+
* naming what they grant, and the rest are fixed.
|
|
375
|
+
*/
|
|
376
|
+
private labelFor(key: PromptKey): string {
|
|
377
|
+
if (key === "s") return this.config.sessionLabel;
|
|
378
|
+
if (key === "b") return this.config.widthLabel ?? OPTION_LABELS.b;
|
|
379
|
+
return OPTION_LABELS[key];
|
|
380
|
+
}
|
|
381
|
+
|
|
369
382
|
private renderReason(width: number): string[] {
|
|
370
383
|
const lines = [
|
|
371
384
|
this.theme.fg("accent", this.title),
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { SessionGrantWidth } from "#src/approval-grant";
|
|
1
2
|
import {
|
|
2
3
|
createDeniedPermissionDecision,
|
|
3
4
|
normalizePermissionDenialReason,
|
|
@@ -15,17 +16,45 @@ import {
|
|
|
15
16
|
* forwards keystrokes to {@link reducePrompt} and renders the returned state.
|
|
16
17
|
*/
|
|
17
18
|
|
|
18
|
-
/**
|
|
19
|
-
|
|
19
|
+
/**
|
|
20
|
+
* The decision hotkeys, in display order.
|
|
21
|
+
*
|
|
22
|
+
* `b` is conditional: it appears only for an ask whose session grant can be
|
|
23
|
+
* widened to both directions (#813), so the roster an ask actually offers
|
|
24
|
+
* comes from {@link visibleOptionKeys} rather than from this type.
|
|
25
|
+
*/
|
|
26
|
+
export type PromptKey = "y" | "s" | "b" | "n" | "r";
|
|
20
27
|
|
|
21
28
|
/** Which sub-view the dialog is showing. */
|
|
22
29
|
export type PromptStep = "decision" | "reason" | "scope";
|
|
23
30
|
|
|
24
|
-
const OPTION_ORDER: readonly PromptKey[] = ["y", "s", "n", "r"];
|
|
31
|
+
const OPTION_ORDER: readonly PromptKey[] = ["y", "s", "b", "n", "r"];
|
|
32
|
+
|
|
33
|
+
const NARROW_OPTION_ORDER: readonly PromptKey[] = OPTION_ORDER.filter(
|
|
34
|
+
(key) => key !== "b",
|
|
35
|
+
);
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* The decision step's option keys, in display order.
|
|
39
|
+
*
|
|
40
|
+
* A function of the config rather than an exported constant, so which options
|
|
41
|
+
* an ask offers is decided in the model and the component renders whatever it
|
|
42
|
+
* is handed — two copies of the roster would be two places to teach about a
|
|
43
|
+
* conditional option.
|
|
44
|
+
*
|
|
45
|
+
* The width option is offered iff the ask supplied a label for it, so an ask
|
|
46
|
+
* that proves no single direction is rendered and navigated exactly as before.
|
|
47
|
+
*/
|
|
48
|
+
export function visibleOptionKeys(
|
|
49
|
+
config: PromptModelConfig,
|
|
50
|
+
): readonly PromptKey[] {
|
|
51
|
+
return config.widthLabel ? OPTION_ORDER : NARROW_OPTION_ORDER;
|
|
52
|
+
}
|
|
25
53
|
|
|
26
54
|
const OPTION_VERBS: Record<PromptKey, string> = {
|
|
27
55
|
y: "approve",
|
|
28
56
|
s: "approve for this session",
|
|
57
|
+
b: "approve both directions for this session",
|
|
29
58
|
n: "deny",
|
|
30
59
|
r: "deny with a reason",
|
|
31
60
|
};
|
|
@@ -36,6 +65,13 @@ export interface PromptModelConfig {
|
|
|
36
65
|
doublePressToConfirm: boolean;
|
|
37
66
|
/** Label shown beside the approve-for-session option. */
|
|
38
67
|
sessionLabel: string;
|
|
68
|
+
/**
|
|
69
|
+
* Label for the both-directions session option (#813).
|
|
70
|
+
*
|
|
71
|
+
* Its presence is what offers the option: an ask whose grants prove no
|
|
72
|
+
* single direction supplies none, and the roster stays four keys.
|
|
73
|
+
*/
|
|
74
|
+
widthLabel?: string;
|
|
39
75
|
/**
|
|
40
76
|
* Forwarded asks only: when set, confirming `s` opens a second step choosing
|
|
41
77
|
* whether the grant applies to the requesting subagent only (least-privilege
|
|
@@ -56,6 +92,15 @@ export interface PromptViewState {
|
|
|
56
92
|
reasonError?: string;
|
|
57
93
|
/** Scope step: false = subagent-only (default), true = whole serving session. */
|
|
58
94
|
scopeServing: boolean;
|
|
95
|
+
/**
|
|
96
|
+
* The width the session option chosen so far would grant.
|
|
97
|
+
*
|
|
98
|
+
* Held on the state rather than passed to the scope step, because a
|
|
99
|
+
* forwarded ask commits the two choices in different steps. Reset to
|
|
100
|
+
* `"proven"` on every return to the decision step, so a width the user
|
|
101
|
+
* backed out of cannot ride along with a later narrow choice.
|
|
102
|
+
*/
|
|
103
|
+
grantWidth: SessionGrantWidth;
|
|
59
104
|
}
|
|
60
105
|
|
|
61
106
|
/** An input event the reducer understands. */
|
|
@@ -81,6 +126,7 @@ export function initialPromptState(
|
|
|
81
126
|
hint: "",
|
|
82
127
|
reasonError: undefined,
|
|
83
128
|
scopeServing: false,
|
|
129
|
+
grantWidth: "proven",
|
|
84
130
|
};
|
|
85
131
|
}
|
|
86
132
|
|
|
@@ -116,12 +162,14 @@ function reduceDecisionStep(
|
|
|
116
162
|
case "nav":
|
|
117
163
|
return render({
|
|
118
164
|
...state,
|
|
119
|
-
highlightedKey: shiftKey(state.highlightedKey, event.direction),
|
|
165
|
+
highlightedKey: shiftKey(config, state.highlightedKey, event.direction),
|
|
120
166
|
armedKey: undefined,
|
|
121
167
|
hint: "",
|
|
122
168
|
});
|
|
123
169
|
case "hotkey":
|
|
124
|
-
return
|
|
170
|
+
return visibleOptionKeys(config).includes(event.key)
|
|
171
|
+
? pressHotkey(config, state, event.key)
|
|
172
|
+
: render(state);
|
|
125
173
|
case "confirm":
|
|
126
174
|
return commit(config, state, state.highlightedKey);
|
|
127
175
|
case "cancel":
|
|
@@ -170,23 +218,48 @@ function commit(
|
|
|
170
218
|
reasonError: undefined,
|
|
171
219
|
});
|
|
172
220
|
case "s":
|
|
221
|
+
case "b": {
|
|
222
|
+
// The two session options differ only in the width they grant; which
|
|
223
|
+
// scope they land on is the forwarded scope step's separate question.
|
|
224
|
+
const grantWidth: SessionGrantWidth = key === "b" ? "family" : "proven";
|
|
173
225
|
if (config.sessionScope) {
|
|
174
226
|
return render({
|
|
175
227
|
...state,
|
|
176
228
|
step: "scope",
|
|
177
|
-
highlightedKey:
|
|
229
|
+
highlightedKey: key,
|
|
178
230
|
armedKey: undefined,
|
|
179
231
|
hint: "",
|
|
180
232
|
scopeServing: false,
|
|
233
|
+
grantWidth,
|
|
181
234
|
});
|
|
182
235
|
}
|
|
183
236
|
return {
|
|
184
237
|
kind: "decision",
|
|
185
|
-
decision:
|
|
238
|
+
decision: sessionDecision("approved_for_session", grantWidth),
|
|
186
239
|
};
|
|
240
|
+
}
|
|
187
241
|
}
|
|
188
242
|
}
|
|
189
243
|
|
|
244
|
+
/**
|
|
245
|
+
* A session-granting decision, naming its width only when it is not the
|
|
246
|
+
* default.
|
|
247
|
+
*
|
|
248
|
+
* Absent means `"proven"` everywhere this value travels — the decision, the
|
|
249
|
+
* gate result, and the forwarded wire — so the narrow grant serializes
|
|
250
|
+
* exactly as it did before the option existed.
|
|
251
|
+
*/
|
|
252
|
+
function sessionDecision(
|
|
253
|
+
state: "approved_for_session" | "approved_for_serving_session",
|
|
254
|
+
width: SessionGrantWidth,
|
|
255
|
+
): UnattributedDecision {
|
|
256
|
+
return {
|
|
257
|
+
approved: true,
|
|
258
|
+
state,
|
|
259
|
+
...(width === "family" ? { sessionGrantWidth: width } : {}),
|
|
260
|
+
};
|
|
261
|
+
}
|
|
262
|
+
|
|
190
263
|
function reduceReasonStep(
|
|
191
264
|
state: PromptViewState,
|
|
192
265
|
event: PromptEvent,
|
|
@@ -198,6 +271,7 @@ function reduceReasonStep(
|
|
|
198
271
|
armedKey: undefined,
|
|
199
272
|
hint: "",
|
|
200
273
|
reasonError: undefined,
|
|
274
|
+
grantWidth: "proven",
|
|
201
275
|
});
|
|
202
276
|
}
|
|
203
277
|
if (event.type === "submitReason") {
|
|
@@ -226,12 +300,12 @@ function reduceScopeStep(
|
|
|
226
300
|
case "confirm":
|
|
227
301
|
return {
|
|
228
302
|
kind: "decision",
|
|
229
|
-
decision:
|
|
230
|
-
|
|
231
|
-
state: state.scopeServing
|
|
303
|
+
decision: sessionDecision(
|
|
304
|
+
state.scopeServing
|
|
232
305
|
? "approved_for_serving_session"
|
|
233
306
|
: "approved_for_session",
|
|
234
|
-
|
|
307
|
+
state.grantWidth,
|
|
308
|
+
),
|
|
235
309
|
};
|
|
236
310
|
case "cancel":
|
|
237
311
|
return render({
|
|
@@ -239,17 +313,23 @@ function reduceScopeStep(
|
|
|
239
313
|
step: "decision",
|
|
240
314
|
armedKey: undefined,
|
|
241
315
|
hint: "",
|
|
316
|
+
grantWidth: "proven",
|
|
242
317
|
});
|
|
243
318
|
default:
|
|
244
319
|
return render(state);
|
|
245
320
|
}
|
|
246
321
|
}
|
|
247
322
|
|
|
248
|
-
function shiftKey(
|
|
249
|
-
|
|
323
|
+
function shiftKey(
|
|
324
|
+
config: PromptModelConfig,
|
|
325
|
+
current: PromptKey,
|
|
326
|
+
direction: "up" | "down",
|
|
327
|
+
): PromptKey {
|
|
328
|
+
const keys = visibleOptionKeys(config);
|
|
329
|
+
const index = keys.indexOf(current);
|
|
250
330
|
const delta = direction === "down" ? 1 : -1;
|
|
251
|
-
const next = (index + delta +
|
|
252
|
-
return
|
|
331
|
+
const next = (index + delta + keys.length) % keys.length;
|
|
332
|
+
return keys[next] ?? current;
|
|
253
333
|
}
|
|
254
334
|
|
|
255
335
|
function render(state: PromptViewState): PromptOutcome {
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { SessionGrantWidth } from "#src/approval-grant";
|
|
1
2
|
import type { DecisionSource } from "#src/authority/decision-source";
|
|
2
3
|
import type { PermissionPromptDecision } from "#src/authority/permission-dialog";
|
|
3
4
|
import type {
|
|
@@ -11,6 +12,25 @@ import type { TerminalAuthorizer } from "./authorizer";
|
|
|
11
12
|
|
|
12
13
|
export type PermissionReviewSource = "tool_call" | "skill_input" | "skill_read";
|
|
13
14
|
|
|
15
|
+
/**
|
|
16
|
+
* The width a decision's session grant was recorded at, or `undefined` when it
|
|
17
|
+
* granted nothing for the session.
|
|
18
|
+
*
|
|
19
|
+
* Absent means "proven" everywhere else this value travels, but the review log
|
|
20
|
+
* is read rather than consumed, so a session-granting entry states its width
|
|
21
|
+
* explicitly instead of leaving the reader to know the default (#813).
|
|
22
|
+
*/
|
|
23
|
+
function recordedGrantWidth(
|
|
24
|
+
decision: PermissionPromptDecision,
|
|
25
|
+
): SessionGrantWidth | undefined {
|
|
26
|
+
const grantsForSession =
|
|
27
|
+
decision.state === "approved_for_session" ||
|
|
28
|
+
decision.state === "approved_for_serving_session";
|
|
29
|
+
return grantsForSession
|
|
30
|
+
? (decision.sessionGrantWidth ?? "proven")
|
|
31
|
+
: undefined;
|
|
32
|
+
}
|
|
33
|
+
|
|
14
34
|
/**
|
|
15
35
|
* Provenance of a forwarded ask: who is really asking, one hop below.
|
|
16
36
|
*
|
|
@@ -133,6 +153,7 @@ export class PermissionPrompter implements PermissionPrompterApi {
|
|
|
133
153
|
: decision.state,
|
|
134
154
|
denialReason: decision.denialReason,
|
|
135
155
|
decidedBy: decision.decidedBy,
|
|
156
|
+
sessionGrantWidth: recordedGrantWidth(decision),
|
|
136
157
|
},
|
|
137
158
|
);
|
|
138
159
|
|
|
@@ -151,10 +172,14 @@ export class PermissionPrompter implements PermissionPrompterApi {
|
|
|
151
172
|
resolution?: string;
|
|
152
173
|
denialReason?: string;
|
|
153
174
|
decidedBy?: DecisionSource;
|
|
175
|
+
sessionGrantWidth?: SessionGrantWidth;
|
|
154
176
|
},
|
|
155
177
|
): void {
|
|
156
178
|
this.deps.logger.review(event, {
|
|
157
179
|
...(details.decidedBy ? { decidedBy: details.decidedBy } : {}),
|
|
180
|
+
...(details.sessionGrantWidth
|
|
181
|
+
? { sessionGrantWidth: details.sessionGrantWidth }
|
|
182
|
+
: {}),
|
|
158
183
|
requestId: details.requestId,
|
|
159
184
|
source: details.source,
|
|
160
185
|
agentName: details.agentName,
|
|
@@ -8,7 +8,6 @@ import type { PermissionSession } from "#src/permission-session";
|
|
|
8
8
|
import { resolveSkillPromptEntries } from "#src/skill-prompt-sanitizer";
|
|
9
9
|
import { sanitizeAvailableToolsSection } from "#src/system-prompt-sanitizer";
|
|
10
10
|
import { getToolNameFromValue, type ToolRegistry } from "#src/tool-registry";
|
|
11
|
-
import type { PermissionState } from "#src/types";
|
|
12
11
|
|
|
13
12
|
/** Minimal subset of BeforeAgentStartEvent used by this handler. */
|
|
14
13
|
interface BeforeAgentStartPayload {
|
|
@@ -17,16 +16,17 @@ interface BeforeAgentStartPayload {
|
|
|
17
16
|
|
|
18
17
|
/**
|
|
19
18
|
* Pure helper: returns true when the tool should be exposed to the agent.
|
|
20
|
-
*
|
|
21
|
-
*
|
|
19
|
+
*
|
|
20
|
+
* A tool is withheld only when *every* value under its surface resolves to
|
|
21
|
+
* `deny`, so a blanket `bash: deny` hides the tool entirely while a partially
|
|
22
|
+
* permissive `bash: {"*": "deny", "git *": "ask"}` keeps it reachable (#815).
|
|
22
23
|
*/
|
|
23
24
|
export function shouldExposeTool(
|
|
24
25
|
toolName: string,
|
|
25
26
|
agentName: string | null,
|
|
26
|
-
|
|
27
|
+
isToolFullyDenied: (toolName: string, agentName?: string) => boolean,
|
|
27
28
|
): boolean {
|
|
28
|
-
|
|
29
|
-
return toolPermission !== "deny";
|
|
29
|
+
return !isToolFullyDenied(toolName, agentName ?? undefined);
|
|
30
30
|
}
|
|
31
31
|
|
|
32
32
|
/**
|
|
@@ -41,7 +41,7 @@ export function shouldExposeTool(
|
|
|
41
41
|
* - `turnPrep` — brings the node up to date for the turn before anything reads
|
|
42
42
|
* session state
|
|
43
43
|
* - `session` — encapsulates all mutable session state and lifecycle operations
|
|
44
|
-
* - `resolver` — owns permission-query surface: `
|
|
44
|
+
* - `resolver` — owns permission-query surface: `isToolFullyDenied`, skill check
|
|
45
45
|
* - `toolRegistry` — Pi tool API subset (getActive + setActive)
|
|
46
46
|
*/
|
|
47
47
|
export class AgentPrepHandler {
|
|
@@ -70,7 +70,7 @@ export class AgentPrepHandler {
|
|
|
70
70
|
}
|
|
71
71
|
if (
|
|
72
72
|
shouldExposeTool(toolName, agentName, (t, a) =>
|
|
73
|
-
this.resolver.
|
|
73
|
+
this.resolver.isToolFullyDenied(t, a),
|
|
74
74
|
)
|
|
75
75
|
) {
|
|
76
76
|
allowedTools.push(toolName);
|
|
@@ -228,9 +228,9 @@ export class GateRunner {
|
|
|
228
228
|
messages,
|
|
229
229
|
});
|
|
230
230
|
|
|
231
|
-
// 4. Determine whether session approval was granted
|
|
232
|
-
const
|
|
233
|
-
gateResult.action === "allow"
|
|
231
|
+
// 4. Determine whether session approval was granted, and at what width
|
|
232
|
+
const sessionGrant =
|
|
233
|
+
gateResult.action === "allow" ? gateResult.sessionGrant : undefined;
|
|
234
234
|
|
|
235
235
|
// 5. Emit decision event
|
|
236
236
|
this.emitDecision(
|
|
@@ -242,15 +242,17 @@ export class GateRunner {
|
|
|
242
242
|
gateResult.action === "allow" ? "allow" : "deny",
|
|
243
243
|
resolutionFor(gateResult.decidedBy, {
|
|
244
244
|
approved: gateResult.action === "allow",
|
|
245
|
-
forSession:
|
|
245
|
+
forSession: sessionGrant !== undefined,
|
|
246
246
|
}),
|
|
247
247
|
),
|
|
248
248
|
);
|
|
249
249
|
|
|
250
250
|
// 6. Record session approval — tell the store; it owns the per-pattern loop
|
|
251
|
-
//
|
|
252
|
-
if (
|
|
253
|
-
this.recorder.recordSessionApproval(
|
|
251
|
+
// A present grant already implies gateResult.action === "allow".
|
|
252
|
+
if (sessionGrant && descriptor.sessionApproval) {
|
|
253
|
+
this.recorder.recordSessionApproval(
|
|
254
|
+
descriptor.sessionApproval.atWidth(sessionGrant.width),
|
|
255
|
+
);
|
|
254
256
|
}
|
|
255
257
|
|
|
256
258
|
if (gateResult.action === "block") {
|
package/src/pattern-suggest.ts
CHANGED
|
@@ -1,4 +1,9 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import {
|
|
2
|
+
type CapabilityDirection,
|
|
3
|
+
PATH_BEARING_TOOLS,
|
|
4
|
+
surfaceFamilyOf,
|
|
5
|
+
} from "./access-intent/path-surfaces";
|
|
6
|
+
import type { ApprovalGrant } from "./approval-grant";
|
|
2
7
|
import { prefix, stripBashCommentLines } from "./bash-arity";
|
|
3
8
|
|
|
4
9
|
/** The suggestion returned for a "Yes, for this session" dialog option. */
|
|
@@ -69,27 +74,90 @@ export interface ForwardedScopeLabels {
|
|
|
69
74
|
servingSessionLabel: string;
|
|
70
75
|
}
|
|
71
76
|
|
|
77
|
+
/**
|
|
78
|
+
* What an approval's grants cover, as one phrase.
|
|
79
|
+
*
|
|
80
|
+
* A single grant names its pattern; several name their count, because only the
|
|
81
|
+
* external-directory gate aggregates an ask over many paths and there is no
|
|
82
|
+
* pattern that describes them all. Requires at least one grant — an approval
|
|
83
|
+
* with none is never offered as a session option.
|
|
84
|
+
*/
|
|
85
|
+
export function describeGrantTarget(grants: readonly ApprovalGrant[]): string {
|
|
86
|
+
return grants.length === 1
|
|
87
|
+
? `"${grants[0].pattern}"`
|
|
88
|
+
: `${grants.length} paths`;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** The two session-option labels for an ask whose grants prove one direction. */
|
|
92
|
+
export interface DirectionalSessionLabels {
|
|
93
|
+
/** The proven-direction grant — the least-privilege default. */
|
|
94
|
+
sessionLabel: string;
|
|
95
|
+
/** The both-directions grant, offered beside it (#813). */
|
|
96
|
+
widenedLabel: string;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const DIRECTION_NOUNS: Record<CapabilityDirection, string> = {
|
|
100
|
+
read: "reads",
|
|
101
|
+
write: "writes",
|
|
102
|
+
};
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Label the two widths a directional ask's session grant can take.
|
|
106
|
+
*
|
|
107
|
+
* Both rows name the direction and the target, so the choice between them
|
|
108
|
+
* contrasts on a stated axis rather than on "wider" (#813).
|
|
109
|
+
*/
|
|
110
|
+
export function buildDirectionalSessionLabels(
|
|
111
|
+
direction: CapabilityDirection,
|
|
112
|
+
target: string,
|
|
113
|
+
): DirectionalSessionLabels {
|
|
114
|
+
return {
|
|
115
|
+
sessionLabel: `Yes, allow ${DIRECTION_NOUNS[direction]} to ${target} for this session`,
|
|
116
|
+
widenedLabel: `Yes, allow ${DIRECTION_NOUNS.read} and ${DIRECTION_NOUNS.write} to ${target} for this session`,
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
|
|
72
120
|
/**
|
|
73
121
|
* Build the two scope labels shown when a human grants a forwarded request
|
|
74
122
|
* "for this session."
|
|
75
123
|
*
|
|
76
124
|
* The subagent option names the requester (least privilege); the whole-session
|
|
77
|
-
* option restates
|
|
125
|
+
* option restates what is being granted session-wide — every grant, not the
|
|
126
|
+
* first of them (the residual #810 deferred here).
|
|
127
|
+
*
|
|
128
|
+
* The surface is named as the grants' shared **family**, never a directional
|
|
129
|
+
* member: these labels are built before the dialog runs, so a direction here
|
|
130
|
+
* could contradict a width the human chooses inside it. Grants that share no
|
|
131
|
+
* family name no surface at all.
|
|
78
132
|
*/
|
|
79
133
|
export function buildForwardedScopeLabels(
|
|
80
134
|
agentName: string | null,
|
|
81
|
-
|
|
82
|
-
pattern: string,
|
|
135
|
+
grants: readonly ApprovalGrant[],
|
|
83
136
|
): ForwardedScopeLabels {
|
|
84
137
|
const subagentLabel = agentName
|
|
85
138
|
? `This subagent ('${agentName}') only`
|
|
86
139
|
: "This subagent only";
|
|
140
|
+
const family = sharedSurfaceFamilyOf(grants);
|
|
141
|
+
const granted = family
|
|
142
|
+
? `${family} ${describeGrantTarget(grants)}`
|
|
143
|
+
: describeGrantTarget(grants);
|
|
87
144
|
return {
|
|
88
145
|
subagentLabel,
|
|
89
|
-
servingSessionLabel: `The whole session — allow ${
|
|
146
|
+
servingSessionLabel: `The whole session — allow ${granted} for parent and all subagents`,
|
|
90
147
|
};
|
|
91
148
|
}
|
|
92
149
|
|
|
150
|
+
/** The family every grant belongs to, or `null` when they disagree. */
|
|
151
|
+
function sharedSurfaceFamilyOf(
|
|
152
|
+
grants: readonly ApprovalGrant[],
|
|
153
|
+
): string | null {
|
|
154
|
+
if (grants.length === 0) return null;
|
|
155
|
+
const family = surfaceFamilyOf(grants[0].surface);
|
|
156
|
+
return grants.every((grant) => surfaceFamilyOf(grant.surface) === family)
|
|
157
|
+
? family
|
|
158
|
+
: null;
|
|
159
|
+
}
|
|
160
|
+
|
|
93
161
|
/** Surface-aware human-readable labels for the session-approval option. */
|
|
94
162
|
function buildLabel(pattern: string, surface: string): string {
|
|
95
163
|
switch (surface) {
|
package/src/permission-gate.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { SessionGrantWidth } from "#src/approval-grant";
|
|
1
2
|
import type { DecisionSource } from "#src/authority/decision-source";
|
|
2
3
|
import type { PermissionPromptDecision } from "#src/authority/permission-dialog";
|
|
3
4
|
|
|
@@ -13,8 +14,15 @@ export type PermissionGateResult =
|
|
|
13
14
|
| {
|
|
14
15
|
action: "allow";
|
|
15
16
|
decidedBy: DecisionSource;
|
|
16
|
-
/**
|
|
17
|
-
|
|
17
|
+
/**
|
|
18
|
+
* Set when the human granted the ask for the whole session, carrying the
|
|
19
|
+
* width to record it at.
|
|
20
|
+
*
|
|
21
|
+
* One field rather than a `forSession` flag beside a width: the width is
|
|
22
|
+
* meaningless without the grant, and two optional fields could represent
|
|
23
|
+
* a width for a grant that never happened.
|
|
24
|
+
*/
|
|
25
|
+
sessionGrant?: { width: SessionGrantWidth };
|
|
18
26
|
}
|
|
19
27
|
| { action: "block"; decidedBy: DecisionSource; reason: string };
|
|
20
28
|
|
|
@@ -112,7 +120,12 @@ export async function applyPermissionGate(
|
|
|
112
120
|
decision.state === "approved_for_session" &&
|
|
113
121
|
params.canGrantForSession
|
|
114
122
|
) {
|
|
115
|
-
return {
|
|
123
|
+
return {
|
|
124
|
+
action: "allow",
|
|
125
|
+
decidedBy,
|
|
126
|
+
// Absent means the width every producer chose before #813.
|
|
127
|
+
sessionGrant: { width: decision.sessionGrantWidth ?? "proven" },
|
|
128
|
+
};
|
|
116
129
|
}
|
|
117
130
|
return { action: "allow", decidedBy };
|
|
118
131
|
}
|
|
@@ -22,6 +22,7 @@ import {
|
|
|
22
22
|
evaluateAnyValue,
|
|
23
23
|
evaluateFirst,
|
|
24
24
|
floorAllowsToAsk,
|
|
25
|
+
isSurfaceFullyDenied,
|
|
25
26
|
rewriteAsksToYolo,
|
|
26
27
|
} from "./rule";
|
|
27
28
|
import { mergeScopesWithOrigins } from "./scope-merge";
|
|
@@ -83,6 +84,7 @@ export interface ScopedPermissionManager {
|
|
|
83
84
|
sessionRules?: Ruleset,
|
|
84
85
|
): PermissionCheckResult;
|
|
85
86
|
getToolPermission(toolName: string, agentName?: string): PermissionState;
|
|
87
|
+
isToolFullyDenied(toolName: string, agentName?: string): boolean;
|
|
86
88
|
getConfigIssues(agentName?: string): string[];
|
|
87
89
|
}
|
|
88
90
|
|
|
@@ -268,6 +270,24 @@ export class PermissionManager implements ScopedPermissionManager {
|
|
|
268
270
|
return evaluate(toolName.trim(), "*", composedRules, this.flavor).action;
|
|
269
271
|
}
|
|
270
272
|
|
|
273
|
+
/**
|
|
274
|
+
* Whether every value under a tool's surface resolves to `deny`.
|
|
275
|
+
*
|
|
276
|
+
* This is the question tool exposure asks, and it is not
|
|
277
|
+
* {@link PermissionManager.getToolPermission} — that reports the surface's
|
|
278
|
+
* catch-all, so `bash: {"*": "deny", "git *": "ask"}` reads as `deny` even
|
|
279
|
+
* though `git status` would be asked about (#815).
|
|
280
|
+
*
|
|
281
|
+
* Reads the same composed rules the catch-all query does, so it inherits the
|
|
282
|
+
* fail-closed floor and not the yolo rewrite. Neither matters: one touches
|
|
283
|
+
* only `allow` and the other only `ask`, so neither can create or remove the
|
|
284
|
+
* `deny` this answer turns on.
|
|
285
|
+
*/
|
|
286
|
+
isToolFullyDenied(toolName: string, agentName?: string): boolean {
|
|
287
|
+
const { composedRules } = this.resolvePermissions(agentName);
|
|
288
|
+
return isSurfaceFullyDenied(toolName.trim(), composedRules, this.flavor);
|
|
289
|
+
}
|
|
290
|
+
|
|
271
291
|
/**
|
|
272
292
|
* Unified resolution entry point — dispatches on intent kind.
|
|
273
293
|
*
|
|
@@ -126,10 +126,19 @@ export class PermissionResolver
|
|
|
126
126
|
);
|
|
127
127
|
}
|
|
128
128
|
|
|
129
|
+
// Reached only through `LocalPermissionsService`'s structural resolver view,
|
|
130
|
+
// which fallow cannot trace; `tsc` enforces it at that constructor. The
|
|
131
|
+
// handler's exposure check moved to `isToolFullyDenied` in #815, leaving this
|
|
132
|
+
// the published cross-extension catch-all query and nothing else.
|
|
133
|
+
// fallow-ignore-next-line unused-class-member
|
|
129
134
|
getToolPermission(toolName: string, agentName?: string): PermissionState {
|
|
130
135
|
return this.permissionManager.getToolPermission(toolName, agentName);
|
|
131
136
|
}
|
|
132
137
|
|
|
138
|
+
isToolFullyDenied(toolName: string, agentName?: string): boolean {
|
|
139
|
+
return this.permissionManager.isToolFullyDenied(toolName, agentName);
|
|
140
|
+
}
|
|
141
|
+
|
|
133
142
|
getConfigIssues(agentName?: string): string[] {
|
|
134
143
|
return this.permissionManager.getConfigIssues(agentName);
|
|
135
144
|
}
|
|
@@ -25,6 +25,7 @@ import type { PermissionCheckResult, PermissionState } from "./types";
|
|
|
25
25
|
interface ResolverForService {
|
|
26
26
|
resolve(intent: AccessIntent): PermissionCheckResult;
|
|
27
27
|
getToolPermission(toolName: string, agentName?: string): PermissionState;
|
|
28
|
+
isToolFullyDenied(toolName: string, agentName?: string): boolean;
|
|
28
29
|
}
|
|
29
30
|
|
|
30
31
|
/** Narrow session view: hands out the cwd-bound path normalizer. */
|
|
@@ -81,6 +82,13 @@ export class LocalPermissionsService implements PermissionsService {
|
|
|
81
82
|
return this.resolver.getToolPermission(toolName, agentName);
|
|
82
83
|
}
|
|
83
84
|
|
|
85
|
+
isToolFullyDenied(
|
|
86
|
+
toolName: string,
|
|
87
|
+
agentName?: string,
|
|
88
|
+
): ReturnType<PermissionsService["isToolFullyDenied"]> {
|
|
89
|
+
return this.resolver.isToolFullyDenied(toolName, agentName);
|
|
90
|
+
}
|
|
91
|
+
|
|
84
92
|
registerToolInputFormatter(
|
|
85
93
|
toolName: string,
|
|
86
94
|
formatter: ToolInputFormatter,
|