@gotgenes/pi-permission-system 25.3.0 → 26.0.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 +48 -0
- package/README.md +14 -12
- package/config/config.example.json +1 -2
- package/dist/public.d.ts +37 -10
- package/docs/configuration.md +23 -19
- package/docs/cross-extension-api.md +43 -12
- package/docs/migration/0745-prompt-payload-contracts.md +68 -0
- package/docs/migration/0746-review-log-fields.md +69 -0
- package/docs/troubleshooting.md +2 -1
- package/package.json +1 -1
- package/schemas/permissions.schema.json +14 -4
- package/src/access-intent/tool-kind.ts +1 -1
- package/src/authority/approval-escalator.ts +32 -5
- package/src/authority/authorizer.ts +3 -3
- package/src/authority/forwarded-request-server.ts +0 -2
- package/src/authority/forwarding-io.ts +7 -5
- package/src/authority/permission-forwarding.ts +12 -3
- package/src/authority/permission-prompter.ts +5 -4
- package/src/config-loader.ts +31 -0
- package/src/config-schema.ts +13 -4
- package/src/extension-config.ts +8 -9
- package/src/handlers/gates/bash-external-directory.ts +1 -12
- package/src/handlers/gates/bash-path.ts +1 -10
- package/src/handlers/gates/descriptor.ts +26 -6
- package/src/handlers/gates/external-directory.ts +1 -13
- package/src/handlers/gates/helpers.ts +6 -7
- package/src/handlers/gates/path.ts +1 -9
- package/src/handlers/gates/runner.ts +62 -31
- package/src/handlers/gates/skill-input-gate-pipeline.ts +3 -14
- package/src/handlers/gates/skill-input.ts +1 -10
- package/src/handlers/gates/skill-read.ts +2 -11
- package/src/handlers/gates/tool-call-gate-pipeline.ts +1 -5
- package/src/handlers/gates/tool.ts +1 -11
- package/src/handlers/tool-call-boundary.ts +30 -7
- package/src/log-field-cap.ts +82 -0
- package/src/logging.ts +24 -3
- package/src/permission-events.ts +21 -2
- package/src/permission-prompts.ts +4 -3
- package/src/permission-request-id.ts +17 -0
- package/src/permission-session.ts +1 -1
- package/src/permission-ui-prompt.ts +4 -2
- package/src/presentation/agent-renderer.ts +215 -0
- package/src/presentation/dialog-renderer.ts +8 -64
- package/src/presentation/fact-vocabulary.ts +103 -0
- package/src/presentation/forwarded-ask-payload.ts +42 -17
- package/src/presentation/path-ask-payload.ts +8 -1
- package/src/presentation/prompt-payload.ts +165 -4
- package/src/presentation/review-log-renderer.ts +51 -0
- package/src/service.ts +11 -0
- package/src/tool-input-preview.ts +0 -1
- package/src/tool-preview-formatter.ts +18 -33
- package/src/denial-messages.ts +0 -269
- package/src/presentation/legacy-message.ts +0 -117
|
@@ -22,10 +22,10 @@ export interface PromptPayload {
|
|
|
22
22
|
*
|
|
23
23
|
* Present because the ask shapes are not separable by surface alone: a tool
|
|
24
24
|
* external-directory ask and a bash one share the `external_directory` surface,
|
|
25
|
-
* and the `path` gate and the per-tool gate differ only in wording. It
|
|
26
|
-
*
|
|
27
|
-
*
|
|
28
|
-
*
|
|
25
|
+
* and the `path` gate and the per-tool gate differ only in wording. It gives
|
|
26
|
+
* every renderer an exhaustive switch rather than a set of string comparisons a
|
|
27
|
+
* new variant sails past — which is what let the parallel denial-context union
|
|
28
|
+
* ADR 0011 §7 described dissolve into this one (#746).
|
|
29
29
|
*/
|
|
30
30
|
export type PromptPayloadKind =
|
|
31
31
|
| "bash"
|
|
@@ -120,6 +120,167 @@ export function localRequester(agentName: string | null): PromptRequester {
|
|
|
120
120
|
return { agentName, forwarded: false, sessionId: null };
|
|
121
121
|
}
|
|
122
122
|
|
|
123
|
+
/** Every {@link PromptPayloadKind}, for tolerant reads of a serialized payload. */
|
|
124
|
+
const PROMPT_PAYLOAD_KINDS = [
|
|
125
|
+
"bash",
|
|
126
|
+
"mcp",
|
|
127
|
+
"tool",
|
|
128
|
+
"path",
|
|
129
|
+
"external_directory",
|
|
130
|
+
"bash_external_directory",
|
|
131
|
+
"skill",
|
|
132
|
+
"skill_read",
|
|
133
|
+
"forwarded",
|
|
134
|
+
] as const satisfies readonly PromptPayloadKind[];
|
|
135
|
+
|
|
136
|
+
const BASH_COMMAND_CONTEXTS = [
|
|
137
|
+
"command_substitution",
|
|
138
|
+
"process_substitution",
|
|
139
|
+
"subshell",
|
|
140
|
+
] as const satisfies readonly BashCommandContext[];
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Narrow an unknown value to a {@link PromptPayload}, or `undefined`.
|
|
144
|
+
*
|
|
145
|
+
* Lives beside its type so a new request fact updates the guard next door
|
|
146
|
+
* rather than in a distant reader, following `isPermissionDecisionState`'s
|
|
147
|
+
* precedent.
|
|
148
|
+
*
|
|
149
|
+
* All-or-nothing: any malformed field yields `undefined` rather than a
|
|
150
|
+
* half-payload, so a consumer renders its own degraded view instead of
|
|
151
|
+
* presenting corrupt facts (ADR 0011 §9).
|
|
152
|
+
*/
|
|
153
|
+
export function asPromptPayload(value: unknown): PromptPayload | undefined {
|
|
154
|
+
const candidate = asObject(value);
|
|
155
|
+
if (!candidate) return undefined;
|
|
156
|
+
|
|
157
|
+
const kind = PROMPT_PAYLOAD_KINDS.find((entry) => entry === candidate.kind);
|
|
158
|
+
const request = asPromptRequestFacts(candidate.request);
|
|
159
|
+
const evidence = asArrayOf(candidate.evidence, asPromptEvidence);
|
|
160
|
+
const annotations = asArrayOf(candidate.annotations, asPromptAnnotation);
|
|
161
|
+
if (!kind || !request || !evidence || !annotations) return undefined;
|
|
162
|
+
|
|
163
|
+
return { kind, request, evidence, annotations };
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
function asPromptRequestFacts(value: unknown): PromptRequestFacts | undefined {
|
|
167
|
+
const candidate = asObject(value);
|
|
168
|
+
if (!candidate) return undefined;
|
|
169
|
+
|
|
170
|
+
const requester = asPromptRequester(candidate.requester);
|
|
171
|
+
const commandContext = asNullableMember(
|
|
172
|
+
candidate.commandContext,
|
|
173
|
+
BASH_COMMAND_CONTEXTS,
|
|
174
|
+
);
|
|
175
|
+
if (
|
|
176
|
+
!requester ||
|
|
177
|
+
commandContext === undefined ||
|
|
178
|
+
typeof candidate.surface !== "string" ||
|
|
179
|
+
typeof candidate.value !== "string" ||
|
|
180
|
+
!isNullableString(candidate.toolName) ||
|
|
181
|
+
!isNullableString(candidate.invokedToolName) ||
|
|
182
|
+
!isNullableString(candidate.matchedPattern) ||
|
|
183
|
+
!isNullableString(candidate.executedUnit)
|
|
184
|
+
) {
|
|
185
|
+
return undefined;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
return {
|
|
189
|
+
requester,
|
|
190
|
+
surface: candidate.surface,
|
|
191
|
+
toolName: candidate.toolName,
|
|
192
|
+
invokedToolName: candidate.invokedToolName,
|
|
193
|
+
value: candidate.value,
|
|
194
|
+
matchedPattern: candidate.matchedPattern,
|
|
195
|
+
commandContext: commandContext.value,
|
|
196
|
+
executedUnit: candidate.executedUnit,
|
|
197
|
+
};
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
function asPromptRequester(value: unknown): PromptRequester | undefined {
|
|
201
|
+
const candidate = asObject(value);
|
|
202
|
+
if (
|
|
203
|
+
!candidate ||
|
|
204
|
+
typeof candidate.forwarded !== "boolean" ||
|
|
205
|
+
!isNullableString(candidate.agentName) ||
|
|
206
|
+
!isNullableString(candidate.sessionId)
|
|
207
|
+
) {
|
|
208
|
+
return undefined;
|
|
209
|
+
}
|
|
210
|
+
return {
|
|
211
|
+
agentName: candidate.agentName,
|
|
212
|
+
forwarded: candidate.forwarded,
|
|
213
|
+
sessionId: candidate.sessionId,
|
|
214
|
+
};
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
function asPromptEvidence(value: unknown): PromptEvidence | undefined {
|
|
218
|
+
const candidate = asObject(value);
|
|
219
|
+
if (
|
|
220
|
+
!candidate ||
|
|
221
|
+
typeof candidate.label !== "string" ||
|
|
222
|
+
typeof candidate.text !== "string" ||
|
|
223
|
+
!isNullableString(candidate.detail)
|
|
224
|
+
) {
|
|
225
|
+
return undefined;
|
|
226
|
+
}
|
|
227
|
+
return {
|
|
228
|
+
label: candidate.label,
|
|
229
|
+
text: candidate.text,
|
|
230
|
+
detail: candidate.detail,
|
|
231
|
+
};
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
function asPromptAnnotation(value: unknown): PromptAnnotation | undefined {
|
|
235
|
+
const candidate = asObject(value);
|
|
236
|
+
if (
|
|
237
|
+
!candidate ||
|
|
238
|
+
typeof candidate.source !== "string" ||
|
|
239
|
+
typeof candidate.text !== "string"
|
|
240
|
+
) {
|
|
241
|
+
return undefined;
|
|
242
|
+
}
|
|
243
|
+
return { source: candidate.source, text: candidate.text };
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
function asObject(value: unknown): Record<string, unknown> | undefined {
|
|
247
|
+
return typeof value === "object" && value !== null
|
|
248
|
+
? (value as Record<string, unknown>)
|
|
249
|
+
: undefined;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/** Narrow every entry, or `undefined` when the array or any entry is malformed. */
|
|
253
|
+
function asArrayOf<T>(
|
|
254
|
+
value: unknown,
|
|
255
|
+
narrow: (entry: unknown) => T | undefined,
|
|
256
|
+
): T[] | undefined {
|
|
257
|
+
if (!Array.isArray(value)) return undefined;
|
|
258
|
+
const narrowed: T[] = [];
|
|
259
|
+
for (const entry of value) {
|
|
260
|
+
const result = narrow(entry);
|
|
261
|
+
if (!result) return undefined;
|
|
262
|
+
narrowed.push(result);
|
|
263
|
+
}
|
|
264
|
+
return narrowed;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
function isNullableString(value: unknown): value is string | null {
|
|
268
|
+
return value === null || typeof value === "string";
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
/**
|
|
272
|
+
* Narrow to `null` or a member of `members`, boxed so a valid `null` is
|
|
273
|
+
* distinguishable from the malformed `undefined`.
|
|
274
|
+
*/
|
|
275
|
+
function asNullableMember<T extends string>(
|
|
276
|
+
value: unknown,
|
|
277
|
+
members: readonly T[],
|
|
278
|
+
): { value: T | null } | undefined {
|
|
279
|
+
if (value === null) return { value: null };
|
|
280
|
+
const member = members.find((entry) => entry === value);
|
|
281
|
+
return member ? { value: member } : undefined;
|
|
282
|
+
}
|
|
283
|
+
|
|
123
284
|
/** Find the evidence entry a renderer knows by label. */
|
|
124
285
|
export function findEvidence(
|
|
125
286
|
payload: PromptPayload,
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import type { PromptPayload } from "#src/presentation/prompt-payload";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The payload facts the permission review log persists (ADR 0011 §6).
|
|
5
|
+
*
|
|
6
|
+
* The log is a renderer over the payload like any other, and this is its
|
|
7
|
+
* content decision: the request facts, and only those the log's own structured
|
|
8
|
+
* columns do not already carry. `toolName`, `command`, `path`, `target`, and
|
|
9
|
+
* `toolInputPreview` are written by the gates; restating them under a second
|
|
10
|
+
* name would grow the log rather than sharpen it.
|
|
11
|
+
*
|
|
12
|
+
* Evidence and annotations are deliberately absent.
|
|
13
|
+
* `docs/decisions/0010-permission-log-secret-exposure.md` bounds what the logs
|
|
14
|
+
* accumulate, and evidence is exactly the unbounded part — the point of this
|
|
15
|
+
* render is that the log's growth is a decision, not a side effect of how a
|
|
16
|
+
* prompt happened to be worded.
|
|
17
|
+
*
|
|
18
|
+
* A fact the ask does not carry is omitted rather than written as `null`, so a
|
|
19
|
+
* line states what was true rather than enumerating what was not.
|
|
20
|
+
*/
|
|
21
|
+
export function renderReviewLogFacts(
|
|
22
|
+
payload: PromptPayload,
|
|
23
|
+
): Record<string, unknown> {
|
|
24
|
+
const { request } = payload;
|
|
25
|
+
return {
|
|
26
|
+
surface: request.surface,
|
|
27
|
+
...present("matchedPattern", request.matchedPattern),
|
|
28
|
+
...present("executedUnit", request.executedUnit),
|
|
29
|
+
...present("commandContext", request.commandContext),
|
|
30
|
+
...present("invokedToolName", request.invokedToolName),
|
|
31
|
+
...forwardingFacts(payload),
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Where the ask came from, when it came from somewhere else.
|
|
37
|
+
*
|
|
38
|
+
* A local ask is the default and states nothing; a forwarded one names the
|
|
39
|
+
* session that raised it, so a decision can be correlated back to the child
|
|
40
|
+
* that asked.
|
|
41
|
+
*/
|
|
42
|
+
function forwardingFacts(payload: PromptPayload): Record<string, unknown> {
|
|
43
|
+
const { forwarded, sessionId } = payload.request.requester;
|
|
44
|
+
return forwarded
|
|
45
|
+
? { forwarded: true, ...present("requesterSessionId", sessionId) }
|
|
46
|
+
: {};
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function present<T>(key: string, value: T | null): Record<string, T> {
|
|
50
|
+
return value === null ? {} : { [key]: value };
|
|
51
|
+
}
|
package/src/service.ts
CHANGED
|
@@ -49,6 +49,17 @@ export {
|
|
|
49
49
|
PERMISSIONS_READY_CHANNEL,
|
|
50
50
|
PERMISSIONS_UI_PROMPT_CHANNEL,
|
|
51
51
|
} from "./permission-events";
|
|
52
|
+
// The declaration bundle already inlines these through `PromptPermissionDetails`
|
|
53
|
+
// and `PermissionUiPromptEvent`; the named exports are what a consumer needs to
|
|
54
|
+
// annotate a variable of their own.
|
|
55
|
+
export type {
|
|
56
|
+
PromptAnnotation,
|
|
57
|
+
PromptEvidence,
|
|
58
|
+
PromptPayload,
|
|
59
|
+
PromptPayloadKind,
|
|
60
|
+
PromptRequester,
|
|
61
|
+
PromptRequestFacts,
|
|
62
|
+
} from "./presentation/prompt-payload";
|
|
52
63
|
export type { PermissionCheckResult, PermissionState, ToolInputFormatter };
|
|
53
64
|
|
|
54
65
|
/** Process-global key for the service slot. */
|
|
@@ -2,7 +2,6 @@ import { safeJsonStringify } from "./json-safe-stringify";
|
|
|
2
2
|
import { redactedJsonStringify } from "./log-redaction";
|
|
3
3
|
|
|
4
4
|
export const TOOL_INPUT_PREVIEW_MAX_LENGTH = 200;
|
|
5
|
-
export const TOOL_INPUT_LOG_PREVIEW_MAX_LENGTH = 1000;
|
|
6
5
|
export const TOOL_TEXT_SUMMARY_MAX_LENGTH = 80;
|
|
7
6
|
|
|
8
7
|
export function truncateInlineText(value: string, maxLength: number): string {
|
|
@@ -1,10 +1,8 @@
|
|
|
1
1
|
import { classifyToolKind, isMcpCheck } from "./access-intent/tool-kind";
|
|
2
|
-
import type { PermissionSystemExtensionConfig } from "./extension-config";
|
|
3
2
|
import type { ToolInputFormatterLookup } from "./tool-input-formatter-registry";
|
|
4
3
|
import {
|
|
5
4
|
serializeRedactedToolInputPreview,
|
|
6
5
|
serializeToolInputPreview,
|
|
7
|
-
TOOL_INPUT_LOG_PREVIEW_MAX_LENGTH,
|
|
8
6
|
TOOL_INPUT_PREVIEW_MAX_LENGTH,
|
|
9
7
|
TOOL_TEXT_SUMMARY_MAX_LENGTH,
|
|
10
8
|
truncateInlineText,
|
|
@@ -21,27 +19,21 @@ import { getNonEmptyString, toRecord } from "./value-guards";
|
|
|
21
19
|
export interface ToolPreviewFormatterOptions {
|
|
22
20
|
toolInputPreviewMaxLength: number;
|
|
23
21
|
toolTextSummaryMaxLength: number;
|
|
24
|
-
toolInputLogPreviewMaxLength: number;
|
|
25
22
|
}
|
|
26
23
|
|
|
27
|
-
type ConfigurablePreviewLimits = Pick<
|
|
28
|
-
PermissionSystemExtensionConfig,
|
|
29
|
-
"toolInputPreviewMaxLength" | "toolTextSummaryMaxLength"
|
|
30
|
-
>;
|
|
31
|
-
|
|
32
24
|
/**
|
|
33
|
-
*
|
|
34
|
-
*
|
|
25
|
+
* The built-in `ToolPreviewFormatterOptions`.
|
|
26
|
+
*
|
|
27
|
+
* Takes no config: `toolInputPreviewMaxLength` and `toolTextSummaryMaxLength`
|
|
28
|
+
* are subsumed by the renderer budgets (`promptMaxRows` / `promptFieldMaxWidth`,
|
|
29
|
+
* ADR 0011 §5), so an operator's values no longer take effect. The constants
|
|
30
|
+
* remain because they still shape a *prompt* preview; what the review log
|
|
31
|
+
* persists is bounded by `reviewLogFieldMaxWidth` at the writer instead.
|
|
35
32
|
*/
|
|
36
|
-
export function resolveToolPreviewLimits(
|
|
37
|
-
config: ConfigurablePreviewLimits,
|
|
38
|
-
): ToolPreviewFormatterOptions {
|
|
33
|
+
export function resolveToolPreviewLimits(): ToolPreviewFormatterOptions {
|
|
39
34
|
return {
|
|
40
|
-
toolInputPreviewMaxLength:
|
|
41
|
-
|
|
42
|
-
toolTextSummaryMaxLength:
|
|
43
|
-
config.toolTextSummaryMaxLength ?? TOOL_TEXT_SUMMARY_MAX_LENGTH,
|
|
44
|
-
toolInputLogPreviewMaxLength: TOOL_INPUT_LOG_PREVIEW_MAX_LENGTH,
|
|
35
|
+
toolInputPreviewMaxLength: TOOL_INPUT_PREVIEW_MAX_LENGTH,
|
|
36
|
+
toolTextSummaryMaxLength: TOOL_TEXT_SUMMARY_MAX_LENGTH,
|
|
45
37
|
};
|
|
46
38
|
}
|
|
47
39
|
|
|
@@ -147,14 +139,16 @@ export class ToolPreviewFormatter {
|
|
|
147
139
|
// ── Log formatting ──────────────────────────────────────────────────────
|
|
148
140
|
|
|
149
141
|
/**
|
|
150
|
-
* Serialize `input` to inline JSON
|
|
151
|
-
*
|
|
142
|
+
* Serialize `input` to inline JSON for the review log, masking
|
|
143
|
+
* sensitive-keyed values.
|
|
144
|
+
*
|
|
145
|
+
* Unbounded here: the writer narrows every field it persists to
|
|
146
|
+
* `reviewLogFieldMaxWidth`, so a second bound at the producer would be a
|
|
147
|
+
* limit the operator cannot see or change.
|
|
152
148
|
*/
|
|
153
149
|
formatGenericToolInputForLog(input: unknown): string | undefined {
|
|
154
150
|
const inline = serializeRedactedToolInputPreview(input);
|
|
155
|
-
return inline
|
|
156
|
-
? `input ${truncateInlineText(inline, this.options.toolInputLogPreviewMaxLength)}`
|
|
157
|
-
: undefined;
|
|
151
|
+
return inline ? `input ${inline}` : undefined;
|
|
158
152
|
}
|
|
159
153
|
|
|
160
154
|
/** Derive a loggable input preview string for the review log. */
|
|
@@ -168,16 +162,7 @@ export class ToolPreviewFormatter {
|
|
|
168
162
|
}
|
|
169
163
|
|
|
170
164
|
if (pathBearingTools.has(result.toolName)) {
|
|
171
|
-
|
|
172
|
-
result.toolName,
|
|
173
|
-
input,
|
|
174
|
-
);
|
|
175
|
-
return inputPreview
|
|
176
|
-
? truncateInlineText(
|
|
177
|
-
inputPreview,
|
|
178
|
-
this.options.toolInputLogPreviewMaxLength,
|
|
179
|
-
)
|
|
180
|
-
: undefined;
|
|
165
|
+
return this.formatToolInputForPrompt(result.toolName, input) || undefined;
|
|
181
166
|
}
|
|
182
167
|
|
|
183
168
|
return this.formatGenericToolInputForLog(input);
|
package/src/denial-messages.ts
DELETED
|
@@ -1,269 +0,0 @@
|
|
|
1
|
-
import { classifyToolKind, isMcpCheck } from "./access-intent/tool-kind";
|
|
2
|
-
import { EXTENSION_ID } from "./extension-config";
|
|
3
|
-
import type { BashCommandContext, PermissionCheckResult } from "./types";
|
|
4
|
-
|
|
5
|
-
// ── Extension attribution tag ──────────────────────────────────────────────
|
|
6
|
-
|
|
7
|
-
export const EXTENSION_TAG = `[${EXTENSION_ID}]`;
|
|
8
|
-
|
|
9
|
-
// ── External-path resolved-target disclosure ────────────────────────────────
|
|
10
|
-
|
|
11
|
-
/** A displayed external path paired with its resolved target, when distinct. */
|
|
12
|
-
export interface ExternalPathDisclosure {
|
|
13
|
-
/** The path as displayed (typed for tools, lexical-absolute for bash). */
|
|
14
|
-
path: string;
|
|
15
|
-
/** The canonical symlink-resolved target; present only when it differs. */
|
|
16
|
-
resolvedPath?: string;
|
|
17
|
-
}
|
|
18
|
-
|
|
19
|
-
/** ` (resolves to '<canonical>')` when a distinct target exists, else `""`. */
|
|
20
|
-
export function resolvesToSuffix(resolvedPath?: string): string {
|
|
21
|
-
return resolvedPath ? ` (resolves to '${resolvedPath}')` : "";
|
|
22
|
-
}
|
|
23
|
-
|
|
24
|
-
// ── Denial context discriminated union ─────────────────────────────────────
|
|
25
|
-
|
|
26
|
-
export type DenialContext =
|
|
27
|
-
| {
|
|
28
|
-
kind: "tool";
|
|
29
|
-
check: PermissionCheckResult;
|
|
30
|
-
agentName?: string;
|
|
31
|
-
input?: unknown;
|
|
32
|
-
}
|
|
33
|
-
| {
|
|
34
|
-
kind: "path";
|
|
35
|
-
toolName: string;
|
|
36
|
-
pathValue: string;
|
|
37
|
-
agentName?: string;
|
|
38
|
-
}
|
|
39
|
-
| {
|
|
40
|
-
kind: "external_directory";
|
|
41
|
-
toolName: string;
|
|
42
|
-
pathValue: string;
|
|
43
|
-
resolvedPath?: string;
|
|
44
|
-
cwd: string;
|
|
45
|
-
agentName?: string;
|
|
46
|
-
}
|
|
47
|
-
| {
|
|
48
|
-
kind: "bash_external_directory";
|
|
49
|
-
command: string;
|
|
50
|
-
externalPaths: ExternalPathDisclosure[];
|
|
51
|
-
cwd: string;
|
|
52
|
-
agentName?: string;
|
|
53
|
-
}
|
|
54
|
-
| {
|
|
55
|
-
kind: "bash_path";
|
|
56
|
-
command: string;
|
|
57
|
-
pathValue: string;
|
|
58
|
-
agentName?: string;
|
|
59
|
-
}
|
|
60
|
-
| {
|
|
61
|
-
kind: "skill_read";
|
|
62
|
-
skillName: string;
|
|
63
|
-
readPath: string;
|
|
64
|
-
agentName?: string;
|
|
65
|
-
}
|
|
66
|
-
| {
|
|
67
|
-
kind: "skill_input";
|
|
68
|
-
skillName: string;
|
|
69
|
-
agentName?: string;
|
|
70
|
-
};
|
|
71
|
-
|
|
72
|
-
// ── Public formatter API ───────────────────────────────────────────────────
|
|
73
|
-
|
|
74
|
-
/** Format the block reason when permission policy denies an operation. */
|
|
75
|
-
export function formatDenyReason(ctx: DenialContext): string {
|
|
76
|
-
return `${EXTENSION_TAG} ${buildDenyBody(ctx)}`;
|
|
77
|
-
}
|
|
78
|
-
|
|
79
|
-
/**
|
|
80
|
-
* Format the block reason when no live authority could answer the ask.
|
|
81
|
-
*
|
|
82
|
-
* `denialReason` is optional because the plain no-UI case has nothing to add;
|
|
83
|
-
* an authority that abandoned for a specific reason (a forwarding target that
|
|
84
|
-
* is not serving, a request that could not be written) supplies one, and the
|
|
85
|
-
* model sees it (#719).
|
|
86
|
-
*/
|
|
87
|
-
export function formatUnavailableReason(
|
|
88
|
-
ctx: DenialContext,
|
|
89
|
-
denialReason?: string,
|
|
90
|
-
): string {
|
|
91
|
-
return `${EXTENSION_TAG} ${buildUnavailableBody(ctx, denialReason)}`;
|
|
92
|
-
}
|
|
93
|
-
|
|
94
|
-
/** Format the block reason when the user denies at an interactive prompt. */
|
|
95
|
-
export function formatUserDeniedReason(
|
|
96
|
-
ctx: DenialContext,
|
|
97
|
-
denialReason?: string,
|
|
98
|
-
): string {
|
|
99
|
-
return `${EXTENSION_TAG} ${buildUserDeniedBody(ctx, denialReason)}`;
|
|
100
|
-
}
|
|
101
|
-
|
|
102
|
-
// ── Private body builders ──────────────────────────────────────────────────
|
|
103
|
-
|
|
104
|
-
function subject(agentName?: string): string {
|
|
105
|
-
return agentName ? `Agent '${agentName}'` : "Current agent";
|
|
106
|
-
}
|
|
107
|
-
|
|
108
|
-
function reasonSuffix(denialReason?: string): string {
|
|
109
|
-
return denialReason ? ` Reason: ${denialReason}.` : "";
|
|
110
|
-
}
|
|
111
|
-
|
|
112
|
-
function buildDenyBody(ctx: DenialContext): string {
|
|
113
|
-
switch (ctx.kind) {
|
|
114
|
-
case "tool":
|
|
115
|
-
return buildToolDenyBody(ctx);
|
|
116
|
-
case "path":
|
|
117
|
-
return `${subject(ctx.agentName)} is not permitted to access path '${ctx.pathValue}' via tool '${ctx.toolName}'.`;
|
|
118
|
-
case "external_directory":
|
|
119
|
-
return `${subject(ctx.agentName)} is not permitted to run tool '${ctx.toolName}' for path '${ctx.pathValue}'${resolvesToSuffix(ctx.resolvedPath)} outside working directory '${ctx.cwd}'.`;
|
|
120
|
-
case "bash_external_directory":
|
|
121
|
-
return `${subject(ctx.agentName)} is not permitted to run bash command '${ctx.command}' which references path(s) outside working directory '${ctx.cwd}': ${formatExternalPathList(ctx.externalPaths)}.`;
|
|
122
|
-
case "bash_path":
|
|
123
|
-
return `${subject(ctx.agentName)} is not permitted to access path '${ctx.pathValue}' via tool 'bash'.`;
|
|
124
|
-
case "skill_read":
|
|
125
|
-
return `${subject(ctx.agentName)} is not permitted to access skill '${ctx.skillName}' via '${ctx.readPath}'.`;
|
|
126
|
-
case "skill_input":
|
|
127
|
-
return `${subject(ctx.agentName)} is not permitted to access skill '${ctx.skillName}'.`;
|
|
128
|
-
}
|
|
129
|
-
}
|
|
130
|
-
|
|
131
|
-
function buildToolDenyBody(
|
|
132
|
-
ctx: Extract<DenialContext, { kind: "tool" }>,
|
|
133
|
-
): string {
|
|
134
|
-
const parts: string[] = [];
|
|
135
|
-
const { check, agentName } = ctx;
|
|
136
|
-
|
|
137
|
-
if (agentName) {
|
|
138
|
-
parts.push(`Agent '${agentName}'`);
|
|
139
|
-
}
|
|
140
|
-
|
|
141
|
-
if (isMcpCheck(check) && check.target) {
|
|
142
|
-
parts.push(`is not permitted to run MCP target '${check.target}'`);
|
|
143
|
-
} else {
|
|
144
|
-
parts.push(`is not permitted to run '${check.toolName}'`);
|
|
145
|
-
}
|
|
146
|
-
|
|
147
|
-
if (check.command) {
|
|
148
|
-
parts.push(`command '${check.command}'`);
|
|
149
|
-
}
|
|
150
|
-
|
|
151
|
-
const qualifier = matchQualifier(check.matchedPattern, check.commandContext);
|
|
152
|
-
if (qualifier) {
|
|
153
|
-
parts.push(qualifier);
|
|
154
|
-
}
|
|
155
|
-
|
|
156
|
-
// reasonSuffix appends ` Reason: <reason>.` after the sentence-ending period.
|
|
157
|
-
return `${parts.join(" ")}.${reasonSuffix(check.reason)}`;
|
|
158
|
-
}
|
|
159
|
-
|
|
160
|
-
/**
|
|
161
|
-
* Human-readable label for a nested bash execution context, or `undefined` for
|
|
162
|
-
* a current-shell (top-level) command.
|
|
163
|
-
*/
|
|
164
|
-
export function describeBashCommandContext(
|
|
165
|
-
context?: BashCommandContext,
|
|
166
|
-
): string | undefined {
|
|
167
|
-
switch (context) {
|
|
168
|
-
case "command_substitution":
|
|
169
|
-
return "command substitution";
|
|
170
|
-
case "process_substitution":
|
|
171
|
-
return "process substitution";
|
|
172
|
-
case "subshell":
|
|
173
|
-
return "subshell";
|
|
174
|
-
default:
|
|
175
|
-
return undefined;
|
|
176
|
-
}
|
|
177
|
-
}
|
|
178
|
-
|
|
179
|
-
/**
|
|
180
|
-
* Build the parenthetical qualifier for a bash decision, folding the matched
|
|
181
|
-
* rule and (for a nested command) its execution context into one clause, e.g.
|
|
182
|
-
* `(matched 'rm *', inside command substitution)`. Returns `""` when neither
|
|
183
|
-
* applies.
|
|
184
|
-
*/
|
|
185
|
-
export function matchQualifier(
|
|
186
|
-
matchedPattern?: string,
|
|
187
|
-
context?: BashCommandContext,
|
|
188
|
-
): string {
|
|
189
|
-
const parts: string[] = [];
|
|
190
|
-
if (matchedPattern) {
|
|
191
|
-
parts.push(`matched '${matchedPattern}'`);
|
|
192
|
-
}
|
|
193
|
-
const label = describeBashCommandContext(context);
|
|
194
|
-
if (label) {
|
|
195
|
-
parts.push(`inside ${label}`);
|
|
196
|
-
}
|
|
197
|
-
return parts.length > 0 ? `(${parts.join(", ")})` : "";
|
|
198
|
-
}
|
|
199
|
-
|
|
200
|
-
function buildUnavailableBody(
|
|
201
|
-
ctx: DenialContext,
|
|
202
|
-
denialReason?: string,
|
|
203
|
-
): string {
|
|
204
|
-
return `${buildUnavailableSentence(ctx)}${reasonSuffix(denialReason)}`;
|
|
205
|
-
}
|
|
206
|
-
|
|
207
|
-
function buildUnavailableSentence(ctx: DenialContext): string {
|
|
208
|
-
switch (ctx.kind) {
|
|
209
|
-
case "tool": {
|
|
210
|
-
const { check } = ctx;
|
|
211
|
-
if (classifyToolKind(check.toolName) === "bash" && check.command) {
|
|
212
|
-
return `Running bash command '${check.command}' requires approval, but no interactive UI is available.`;
|
|
213
|
-
}
|
|
214
|
-
if (isMcpCheck(check) && check.target) {
|
|
215
|
-
return "Using tool 'mcp' requires approval, but no interactive UI is available.";
|
|
216
|
-
}
|
|
217
|
-
return `Using tool '${check.toolName}' requires approval, but no interactive UI is available.`;
|
|
218
|
-
}
|
|
219
|
-
case "path":
|
|
220
|
-
return `Accessing '${ctx.pathValue}' requires approval, but no interactive UI is available.`;
|
|
221
|
-
case "external_directory":
|
|
222
|
-
return `Accessing '${ctx.pathValue}'${resolvesToSuffix(ctx.resolvedPath)} outside the working directory requires approval, but no interactive UI is available.`;
|
|
223
|
-
case "bash_external_directory":
|
|
224
|
-
return `Bash command '${ctx.command}' references path(s) outside the working directory and requires approval, but no interactive UI is available.`;
|
|
225
|
-
case "bash_path":
|
|
226
|
-
return `Bash command '${ctx.command}' accesses path '${ctx.pathValue}' which requires approval, but no interactive UI is available.`;
|
|
227
|
-
case "skill_read":
|
|
228
|
-
return `Accessing skill '${ctx.skillName}' requires approval, but no interactive UI is available.`;
|
|
229
|
-
case "skill_input":
|
|
230
|
-
return `Accessing skill '${ctx.skillName}' requires approval, but no interactive UI is available.`;
|
|
231
|
-
}
|
|
232
|
-
}
|
|
233
|
-
|
|
234
|
-
function buildUserDeniedBody(
|
|
235
|
-
ctx: DenialContext,
|
|
236
|
-
denialReason?: string,
|
|
237
|
-
): string {
|
|
238
|
-
switch (ctx.kind) {
|
|
239
|
-
case "tool": {
|
|
240
|
-
const { check } = ctx;
|
|
241
|
-
if (isMcpCheck(check) && check.target) {
|
|
242
|
-
return `User denied MCP target '${check.target}'.${reasonSuffix(denialReason)}`;
|
|
243
|
-
}
|
|
244
|
-
if (classifyToolKind(check.toolName) === "bash" && check.command) {
|
|
245
|
-
return `User denied bash command '${check.command}'.${reasonSuffix(denialReason)}`;
|
|
246
|
-
}
|
|
247
|
-
return `User denied tool '${check.toolName}'.${reasonSuffix(denialReason)}`;
|
|
248
|
-
}
|
|
249
|
-
case "path":
|
|
250
|
-
return `User denied access to path '${ctx.pathValue}'.${reasonSuffix(denialReason)}`;
|
|
251
|
-
case "external_directory":
|
|
252
|
-
return `User denied external directory access for tool '${ctx.toolName}' path '${ctx.pathValue}'${resolvesToSuffix(ctx.resolvedPath)}.${reasonSuffix(denialReason)}`;
|
|
253
|
-
case "bash_external_directory":
|
|
254
|
-
return `User denied external directory access for bash command '${ctx.command}'.${reasonSuffix(denialReason)}`;
|
|
255
|
-
case "bash_path":
|
|
256
|
-
return `User denied path access for bash command '${ctx.command}' (path '${ctx.pathValue}').${reasonSuffix(denialReason)}`;
|
|
257
|
-
case "skill_read":
|
|
258
|
-
return `User denied access to skill '${ctx.skillName}'.${reasonSuffix(denialReason)}`;
|
|
259
|
-
case "skill_input":
|
|
260
|
-
return `User denied access to skill '${ctx.skillName}'.${reasonSuffix(denialReason)}`;
|
|
261
|
-
}
|
|
262
|
-
}
|
|
263
|
-
|
|
264
|
-
/** Render an external-path disclosure list for the bash deny body's path clause. */
|
|
265
|
-
function formatExternalPathList(paths: ExternalPathDisclosure[]): string {
|
|
266
|
-
return paths
|
|
267
|
-
.map(({ path, resolvedPath }) => `${path}${resolvesToSuffix(resolvedPath)}`)
|
|
268
|
-
.join(", ");
|
|
269
|
-
}
|