@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
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
import { EXTENSION_ID } from "#src/extension-config";
|
|
2
|
+
import { DEFAULT_RENDER_BUDGET } from "#src/presentation/dialog-renderer";
|
|
3
|
+
import {
|
|
4
|
+
describeBashCommandContext,
|
|
5
|
+
flaggedElementLabel,
|
|
6
|
+
flaggedElements,
|
|
7
|
+
} from "#src/presentation/fact-vocabulary";
|
|
8
|
+
import {
|
|
9
|
+
allEvidence,
|
|
10
|
+
findEvidence,
|
|
11
|
+
type PromptPayload,
|
|
12
|
+
} from "#src/presentation/prompt-payload";
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* The agent-facing render of a refused permission ask (ADR 0011 §7).
|
|
16
|
+
*
|
|
17
|
+
* The rule that governs this renderer and no other:
|
|
18
|
+
*
|
|
19
|
+
* > The agent renderer identifies the call; it does not reproduce it.
|
|
20
|
+
*
|
|
21
|
+
* The agent authored the tool call, and the harness returns this text as that
|
|
22
|
+
* call's own tool result with its arguments still in context, so echoing the
|
|
23
|
+
* input back tells it nothing it did not already have. What is new is the
|
|
24
|
+
* verdict: which surface gated the call, which rule matched, which of the
|
|
25
|
+
* call's operands tripped it, and what the human said.
|
|
26
|
+
*
|
|
27
|
+
* The command is the one value never rendered — it is the payload that took
|
|
28
|
+
* over the viewport in #710 and the context window on every denial. The
|
|
29
|
+
* flagged element (a path, an MCP target, a skill) *is* rendered, because
|
|
30
|
+
* which operand a rule fired on is below tool-call granularity and the agent
|
|
31
|
+
* cannot recover it from its own arguments; being agent input, it is capped
|
|
32
|
+
* rather than structurally bounded.
|
|
33
|
+
*/
|
|
34
|
+
|
|
35
|
+
/** Attribution tag on every block reason this extension produces. */
|
|
36
|
+
export const EXTENSION_TAG = `[${EXTENSION_ID}]`;
|
|
37
|
+
|
|
38
|
+
/** How much room the flagged element has, as the operator configured it. */
|
|
39
|
+
export interface AgentRenderBudget {
|
|
40
|
+
/** Maximum characters of the flagged element's text. */
|
|
41
|
+
readonly fieldMaxWidth: number;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** The agent-facing render of a policy deny. */
|
|
45
|
+
export function renderPolicyDenial(
|
|
46
|
+
payload: PromptPayload,
|
|
47
|
+
ruleReason: string | null,
|
|
48
|
+
budget: AgentRenderBudget = DEFAULT_RENDER_BUDGET,
|
|
49
|
+
): string {
|
|
50
|
+
return tagged(
|
|
51
|
+
`Denied by policy: ${identification(payload, budget, "")}${boundaryClause(payload)}${provenanceClause(payload)}.`,
|
|
52
|
+
ruleReason,
|
|
53
|
+
);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** The agent-facing render of a human's denial at an interactive prompt. */
|
|
57
|
+
export function renderUserDenial(
|
|
58
|
+
payload: PromptPayload,
|
|
59
|
+
denialReason: string | null,
|
|
60
|
+
budget: AgentRenderBudget = DEFAULT_RENDER_BUDGET,
|
|
61
|
+
): string {
|
|
62
|
+
return tagged(
|
|
63
|
+
`The user denied this ${identification(payload, budget, "call")}${boundaryClause(payload)}${provenanceClause(payload)}.`,
|
|
64
|
+
denialReason,
|
|
65
|
+
);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** The agent-facing render when no live authority could answer the ask. */
|
|
69
|
+
export function renderUnavailableDenial(
|
|
70
|
+
payload: PromptPayload,
|
|
71
|
+
denialReason: string | null,
|
|
72
|
+
budget: AgentRenderBudget = DEFAULT_RENDER_BUDGET,
|
|
73
|
+
): string {
|
|
74
|
+
return tagged(
|
|
75
|
+
`This ${identification(payload, budget, "call")} requires approval, but no interactive UI is available.`,
|
|
76
|
+
denialReason,
|
|
77
|
+
);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// ── Sentence assembly ──────────────────────────────────────────────────────
|
|
81
|
+
|
|
82
|
+
function tagged(sentence: string, reason: string | null): string {
|
|
83
|
+
return `${EXTENSION_TAG} ${sentence}${reasonClause(reason)}`;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* What was refused, in the order a reader needs it: the gate surface, the tool
|
|
88
|
+
* that reached it, who asked, which of the call's operands was flagged, and the
|
|
89
|
+
* rule that fired.
|
|
90
|
+
*
|
|
91
|
+
* `callWord` is the noun the verdict needs after the surface — a user or
|
|
92
|
+
* unavailable verdict refuses a *call*, while a policy deny refuses the
|
|
93
|
+
* surface itself.
|
|
94
|
+
*/
|
|
95
|
+
function identification(
|
|
96
|
+
payload: PromptPayload,
|
|
97
|
+
budget: AgentRenderBudget,
|
|
98
|
+
callWord: string,
|
|
99
|
+
): string {
|
|
100
|
+
return [
|
|
101
|
+
`'${payload.request.surface}'`,
|
|
102
|
+
callWord,
|
|
103
|
+
invokedAsClause(payload),
|
|
104
|
+
toolClause(payload),
|
|
105
|
+
agentClause(payload),
|
|
106
|
+
flaggedClause(payload, budget),
|
|
107
|
+
ruleClause(payload),
|
|
108
|
+
]
|
|
109
|
+
.filter((clause) => clause !== "")
|
|
110
|
+
.join(" ");
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** The gated tool, named only when the surface has not already named it. */
|
|
114
|
+
function toolClause(payload: PromptPayload): string {
|
|
115
|
+
const { toolName, surface } = payload.request;
|
|
116
|
+
return toolName === null || toolName === surface
|
|
117
|
+
? ""
|
|
118
|
+
: `for tool '${toolName}'`;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** The name the agent actually called, when a shell alias re-exposed bash. */
|
|
122
|
+
function invokedAsClause(payload: PromptPayload): string {
|
|
123
|
+
const { invokedToolName } = payload.request;
|
|
124
|
+
return invokedToolName === null ? "" : `(invoked as '${invokedToolName}')`;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** Which agent asked, when the ask carries a name. */
|
|
128
|
+
function agentClause(payload: PromptPayload): string {
|
|
129
|
+
const { agentName } = payload.request.requester;
|
|
130
|
+
return agentName ? `for agent '${agentName}'` : "";
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Which of the call's operands the rule fired on.
|
|
135
|
+
*
|
|
136
|
+
* Omitted for a bash ask, whose flagged element is the command §7 forbids
|
|
137
|
+
* echoing; for a generic tool ask, whose value is the tool name an earlier
|
|
138
|
+
* clause already stated; and for a payload-less forwarded relay, whose value
|
|
139
|
+
* shape is unknown, so it cannot be shown to not be a command.
|
|
140
|
+
*/
|
|
141
|
+
function flaggedClause(
|
|
142
|
+
payload: PromptPayload,
|
|
143
|
+
budget: AgentRenderBudget,
|
|
144
|
+
): string {
|
|
145
|
+
if (payload.kind === "bash" || payload.kind === "forwarded") {
|
|
146
|
+
return "";
|
|
147
|
+
}
|
|
148
|
+
const label = flaggedElementLabel(payload);
|
|
149
|
+
const elements = flaggedElements(payload).filter(
|
|
150
|
+
(element) => element !== payload.request.toolName,
|
|
151
|
+
);
|
|
152
|
+
if (elements.length === 0) {
|
|
153
|
+
return "";
|
|
154
|
+
}
|
|
155
|
+
const noun = elements.length === 1 ? label : `${label}s`;
|
|
156
|
+
return `for ${noun} ${elements
|
|
157
|
+
.map(
|
|
158
|
+
(element) =>
|
|
159
|
+
`'${cap(element, budget)}'${resolvedAlias(payload, element)}`,
|
|
160
|
+
)
|
|
161
|
+
.join(", ")}`;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/** The canonical target of a flagged path, when it names somewhere else. */
|
|
165
|
+
function resolvedAlias(payload: PromptPayload, element: string): string {
|
|
166
|
+
const resolved =
|
|
167
|
+
findEvidence(payload, "resolves to")?.text ??
|
|
168
|
+
allEvidence(payload, "external path").find(
|
|
169
|
+
(entry) => entry.text === element,
|
|
170
|
+
)?.detail;
|
|
171
|
+
return resolved ? ` (resolves to '${resolved}')` : "";
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/** The rule that fired, with the nested context that makes it intelligible. */
|
|
175
|
+
function ruleClause(payload: PromptPayload): string {
|
|
176
|
+
const { matchedPattern, commandContext } = payload.request;
|
|
177
|
+
const parts: string[] = [];
|
|
178
|
+
if (matchedPattern !== null) {
|
|
179
|
+
parts.push(`rule '${matchedPattern}'`);
|
|
180
|
+
}
|
|
181
|
+
const context = describeBashCommandContext(commandContext);
|
|
182
|
+
if (context !== undefined) {
|
|
183
|
+
parts.push(`inside ${context}`);
|
|
184
|
+
}
|
|
185
|
+
return parts.length > 0 ? `(${parts.join(", ")})` : "";
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/** The working directory the flagged paths escaped. */
|
|
189
|
+
function boundaryClause(payload: PromptPayload): string {
|
|
190
|
+
const cwd = findEvidence(payload, "working directory")?.text;
|
|
191
|
+
return cwd ? `: outside working directory '${cwd}'` : "";
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/** The path a skill read reached its skill through. */
|
|
195
|
+
function provenanceClause(payload: PromptPayload): string {
|
|
196
|
+
const readPath = findEvidence(payload, "read path")?.text;
|
|
197
|
+
return readPath ? `, reached via '${readPath}'` : "";
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
function reasonClause(reason: string | null): string {
|
|
201
|
+
return reason ? ` Reason: ${reason}.` : "";
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/**
|
|
205
|
+
* Narrow the flagged element to the budget.
|
|
206
|
+
*
|
|
207
|
+
* The command is never rendered, so this bounds the only agent-supplied value
|
|
208
|
+
* that reaches the agent. A quantity bound applied uniformly, never a content
|
|
209
|
+
* filter, with the same bare-ellipsis marker the dialog uses (ADR 0011 §4).
|
|
210
|
+
*/
|
|
211
|
+
function cap(text: string, budget: AgentRenderBudget): string {
|
|
212
|
+
return text.length <= budget.fieldMaxWidth
|
|
213
|
+
? text
|
|
214
|
+
: `${text.slice(0, budget.fieldMaxWidth)}\u2026`;
|
|
215
|
+
}
|
|
@@ -1,9 +1,10 @@
|
|
|
1
|
-
import { describeBashCommandContext } from "#src/denial-messages";
|
|
2
|
-
import { fitLinesToWidth } from "#src/presentation/line-fitting";
|
|
3
1
|
import {
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
2
|
+
describeBashCommandContext,
|
|
3
|
+
flaggedElements,
|
|
4
|
+
valueLabel,
|
|
5
|
+
} from "#src/presentation/fact-vocabulary";
|
|
6
|
+
import { fitLinesToWidth } from "#src/presentation/line-fitting";
|
|
7
|
+
import type { PromptPayload } from "#src/presentation/prompt-payload";
|
|
7
8
|
|
|
8
9
|
/**
|
|
9
10
|
* Render a {@link PromptPayload} for a human deciding an ask (ADR 0011 §5).
|
|
@@ -31,7 +32,7 @@ export function renderPromptDialog(
|
|
|
31
32
|
);
|
|
32
33
|
const blocks = layout(
|
|
33
34
|
[...core, ...evidence],
|
|
34
|
-
|
|
35
|
+
flaggedElements(payload),
|
|
35
36
|
paint,
|
|
36
37
|
).map((block) => fitLinesToWidth(block, budget.width));
|
|
37
38
|
const fitted = fitToRows(
|
|
@@ -122,21 +123,6 @@ export function completeViewBudget(width: number): DialogBudget {
|
|
|
122
123
|
|
|
123
124
|
const plainText: HighlightPaint = (text) => text;
|
|
124
125
|
|
|
125
|
-
/**
|
|
126
|
-
* What the ask is flagging.
|
|
127
|
-
*
|
|
128
|
-
* The decision-relevant value for every shape but one: a bash ask that escaped
|
|
129
|
-
* the working directory flags the paths it referenced, not the command that
|
|
130
|
-
* referenced them — the command is the context, and the paths are what the
|
|
131
|
-
* operator is ruling on.
|
|
132
|
-
*/
|
|
133
|
-
function flaggedTexts(payload: PromptPayload): string[] {
|
|
134
|
-
if (payload.kind === "bash_external_directory") {
|
|
135
|
-
return allEvidence(payload, "external path").map((entry) => entry.text);
|
|
136
|
-
}
|
|
137
|
-
return payload.request.value === "" ? [] : [payload.request.value];
|
|
138
|
-
}
|
|
139
|
-
|
|
140
126
|
/** One rendered fact. */
|
|
141
127
|
interface Fact {
|
|
142
128
|
readonly label: string;
|
|
@@ -242,9 +228,7 @@ function coreFacts(payload: PromptPayload): Fact[] {
|
|
|
242
228
|
if (request.executedUnit !== null) {
|
|
243
229
|
facts.push({ label: "runs", text: request.executedUnit });
|
|
244
230
|
}
|
|
245
|
-
const context = describeBashCommandContext(
|
|
246
|
-
request.commandContext ?? undefined,
|
|
247
|
-
);
|
|
231
|
+
const context = describeBashCommandContext(request.commandContext);
|
|
248
232
|
if (context !== undefined) {
|
|
249
233
|
facts.push({ label: "context", text: context });
|
|
250
234
|
}
|
|
@@ -293,46 +277,6 @@ function toolText(payload: PromptPayload): string {
|
|
|
293
277
|
: `${String(toolName)} (invoked as ${invokedToolName})`;
|
|
294
278
|
}
|
|
295
279
|
|
|
296
|
-
/** What the decision-relevant value is called, per ask shape. */
|
|
297
|
-
function valueLabel(payload: PromptPayload): string {
|
|
298
|
-
switch (payload.kind) {
|
|
299
|
-
case "bash":
|
|
300
|
-
case "bash_external_directory":
|
|
301
|
-
return "command";
|
|
302
|
-
case "mcp":
|
|
303
|
-
return "target";
|
|
304
|
-
case "tool":
|
|
305
|
-
return "tool";
|
|
306
|
-
case "path":
|
|
307
|
-
case "external_directory":
|
|
308
|
-
return "path";
|
|
309
|
-
case "skill":
|
|
310
|
-
case "skill_read":
|
|
311
|
-
return "skill";
|
|
312
|
-
case "forwarded":
|
|
313
|
-
return forwardedValueLabel(payload.request.surface);
|
|
314
|
-
}
|
|
315
|
-
}
|
|
316
|
-
|
|
317
|
-
/**
|
|
318
|
-
* A forwarded request carries the child's *display* projection — its tool name
|
|
319
|
-
* as the surface — rather than the child's own payload, so the label is
|
|
320
|
-
* inferred from it and falls back to a neutral one.
|
|
321
|
-
*
|
|
322
|
-
* Dissolves when the payload replaces `message` on the wire (#745): the
|
|
323
|
-
* serving node will then hold the child's real `kind`.
|
|
324
|
-
*/
|
|
325
|
-
function forwardedValueLabel(surface: string): string {
|
|
326
|
-
switch (surface) {
|
|
327
|
-
case "bash":
|
|
328
|
-
return "command";
|
|
329
|
-
case "skill":
|
|
330
|
-
return "skill";
|
|
331
|
-
default:
|
|
332
|
-
return "value";
|
|
333
|
-
}
|
|
334
|
-
}
|
|
335
|
-
|
|
336
280
|
/**
|
|
337
281
|
* Align the labels into a `label : value` column.
|
|
338
282
|
*
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import {
|
|
2
|
+
allEvidence,
|
|
3
|
+
type PromptPayload,
|
|
4
|
+
} from "#src/presentation/prompt-payload";
|
|
5
|
+
import type { BashCommandContext } from "#src/types";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* The render vocabulary shared by every renderer over a {@link PromptPayload}.
|
|
9
|
+
*
|
|
10
|
+
* Which element an ask flags, what that element is called, and how a nested
|
|
11
|
+
* execution context reads are all answers a render needs and none of them is
|
|
12
|
+
* a payload fact — the payload carries `value`, `kind`, and `commandContext`,
|
|
13
|
+
* and this module is where they acquire a name. It lives apart from any one
|
|
14
|
+
* renderer so the dialog, the agent-facing text, and the review log cannot
|
|
15
|
+
* disagree about what a given ask is flagging.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* What the ask is flagging.
|
|
20
|
+
*
|
|
21
|
+
* The decision-relevant value for every shape but one: a bash ask that escaped
|
|
22
|
+
* the working directory flags the paths it referenced, not the command that
|
|
23
|
+
* referenced them — the command is the context, and the paths are what the
|
|
24
|
+
* operator is ruling on.
|
|
25
|
+
*/
|
|
26
|
+
export function flaggedElements(payload: PromptPayload): readonly string[] {
|
|
27
|
+
if (payload.kind === "bash_external_directory") {
|
|
28
|
+
return allEvidence(payload, "external path").map((entry) => entry.text);
|
|
29
|
+
}
|
|
30
|
+
return payload.request.value === "" ? [] : [payload.request.value];
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* What {@link flaggedElements} returns is called.
|
|
35
|
+
*
|
|
36
|
+
* Differs from {@link valueLabel} for exactly one shape: a bash ask that
|
|
37
|
+
* escaped the working directory flags paths while its value is the command,
|
|
38
|
+
* so the two nouns are for two different things.
|
|
39
|
+
*/
|
|
40
|
+
export function flaggedElementLabel(payload: PromptPayload): string {
|
|
41
|
+
return payload.kind === "bash_external_directory"
|
|
42
|
+
? "path"
|
|
43
|
+
: valueLabel(payload);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** What the decision-relevant value is called, per ask shape. */
|
|
47
|
+
export function valueLabel(payload: PromptPayload): string {
|
|
48
|
+
switch (payload.kind) {
|
|
49
|
+
case "bash":
|
|
50
|
+
case "bash_external_directory":
|
|
51
|
+
return "command";
|
|
52
|
+
case "mcp":
|
|
53
|
+
return "target";
|
|
54
|
+
case "tool":
|
|
55
|
+
return "tool";
|
|
56
|
+
case "path":
|
|
57
|
+
case "external_directory":
|
|
58
|
+
return "path";
|
|
59
|
+
case "skill":
|
|
60
|
+
case "skill_read":
|
|
61
|
+
return "skill";
|
|
62
|
+
case "forwarded":
|
|
63
|
+
return forwardedValueLabel(payload.request.surface);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Labels the version-skew render only: a payload-bearing forwarded ask carries
|
|
69
|
+
* the child's real `kind` and never reaches this arm (#745).
|
|
70
|
+
*
|
|
71
|
+
* Without a payload all that survives is the child's *display* projection — its
|
|
72
|
+
* tool name as the surface — so the label is inferred from it and falls back to
|
|
73
|
+
* a neutral one.
|
|
74
|
+
*/
|
|
75
|
+
function forwardedValueLabel(surface: string): string {
|
|
76
|
+
switch (surface) {
|
|
77
|
+
case "bash":
|
|
78
|
+
return "command";
|
|
79
|
+
case "skill":
|
|
80
|
+
return "skill";
|
|
81
|
+
default:
|
|
82
|
+
return "value";
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Human-readable label for a nested bash execution context, or `undefined` for
|
|
88
|
+
* a current-shell (top-level) command.
|
|
89
|
+
*/
|
|
90
|
+
export function describeBashCommandContext(
|
|
91
|
+
context: BashCommandContext | null,
|
|
92
|
+
): string | undefined {
|
|
93
|
+
switch (context) {
|
|
94
|
+
case "command_substitution":
|
|
95
|
+
return "command substitution";
|
|
96
|
+
case "process_substitution":
|
|
97
|
+
return "process substitution";
|
|
98
|
+
case "subshell":
|
|
99
|
+
return "subshell";
|
|
100
|
+
case null:
|
|
101
|
+
return undefined;
|
|
102
|
+
}
|
|
103
|
+
}
|
|
@@ -1,34 +1,56 @@
|
|
|
1
1
|
import type { ForwardedPermissionRequest } from "#src/authority/permission-forwarding";
|
|
2
|
-
import type {
|
|
2
|
+
import type {
|
|
3
|
+
PromptPayload,
|
|
4
|
+
PromptRequester,
|
|
5
|
+
} from "#src/presentation/prompt-payload";
|
|
3
6
|
|
|
4
7
|
/**
|
|
5
8
|
* Build the payload for an ask forwarded up from a subagent.
|
|
6
9
|
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
10
|
+
* A projection, not a synthesizer: the child ships its own complete payload, so
|
|
11
|
+
* the serving node renders the child's facts under the *parent's* budget — which
|
|
12
|
+
* is what makes a forwarded ask and a local one consistent in kind, a forwarded
|
|
13
|
+
* bash ask reading `command : …` exactly as a local one does (ADR 0011 §2).
|
|
11
14
|
*
|
|
12
|
-
*
|
|
13
|
-
* child's own payload instead, and the serving node renders the child's facts
|
|
14
|
-
* under its own budget — which is what makes a forwarded ask and a local one
|
|
15
|
-
* consistent for the first time (ADR 0011 §2).
|
|
16
|
-
*
|
|
17
|
-
* A request missing a field renders from whatever it does carry: fail-closed
|
|
15
|
+
* A request carrying no payload renders from whatever it does hold: fail-closed
|
|
18
16
|
* applies to presentation as it does to policy, so a version-skewed ask still
|
|
19
17
|
* reaches the human rather than resolving without one (ADR 0011 §9).
|
|
20
18
|
*/
|
|
21
19
|
export function buildForwardedAskPayload(
|
|
22
20
|
request: ForwardedPermissionRequest,
|
|
21
|
+
): PromptPayload {
|
|
22
|
+
// The child built its payload with `localRequester` — `forwarded: false`,
|
|
23
|
+
// `sessionId: null`. The serving node is the only party that knows the ask
|
|
24
|
+
// arrived over the wire, and the request's own provenance is authoritative
|
|
25
|
+
// (#292); everything else is the child's fact and passes through untouched.
|
|
26
|
+
const requester: PromptRequester = {
|
|
27
|
+
agentName: request.requesterAgentName,
|
|
28
|
+
forwarded: true,
|
|
29
|
+
sessionId: request.requesterSessionId,
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
return request.payload
|
|
33
|
+
? {
|
|
34
|
+
...request.payload,
|
|
35
|
+
request: { ...request.payload.request, requester },
|
|
36
|
+
}
|
|
37
|
+
: degradedForwardedPayload(request, requester);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* The render for an ask that arrived without a payload.
|
|
42
|
+
*
|
|
43
|
+
* `kind: "forwarded"` narrows to meaning exactly this — not "an ask from a
|
|
44
|
+
* subagent", which every branch above is too.
|
|
45
|
+
*/
|
|
46
|
+
function degradedForwardedPayload(
|
|
47
|
+
request: ForwardedPermissionRequest,
|
|
48
|
+
requester: PromptRequester,
|
|
23
49
|
): PromptPayload {
|
|
24
50
|
return {
|
|
25
51
|
kind: "forwarded",
|
|
26
52
|
request: {
|
|
27
|
-
requester
|
|
28
|
-
agentName: request.requesterAgentName,
|
|
29
|
-
forwarded: true,
|
|
30
|
-
sessionId: request.requesterSessionId,
|
|
31
|
-
},
|
|
53
|
+
requester,
|
|
32
54
|
// The child's display projection: what the ask was about, as the child's
|
|
33
55
|
// own gate named it.
|
|
34
56
|
surface: request.surface ?? "",
|
|
@@ -39,7 +61,10 @@ export function buildForwardedAskPayload(
|
|
|
39
61
|
commandContext: null,
|
|
40
62
|
executedUnit: null,
|
|
41
63
|
},
|
|
42
|
-
|
|
64
|
+
// Nothing to carry: the wire no longer relays a sentence, and inventing
|
|
65
|
+
// evidence the child never sent is exactly the fiction the bounded
|
|
66
|
+
// renderers would then have to trust.
|
|
67
|
+
evidence: [],
|
|
43
68
|
annotations: [],
|
|
44
69
|
};
|
|
45
70
|
}
|
|
@@ -1,10 +1,17 @@
|
|
|
1
|
-
import type { ExternalPathDisclosure } from "#src/denial-messages";
|
|
2
1
|
import type {
|
|
3
2
|
PromptEvidence,
|
|
4
3
|
PromptPayload,
|
|
5
4
|
} from "#src/presentation/prompt-payload";
|
|
6
5
|
import { localRequester } from "#src/presentation/prompt-payload";
|
|
7
6
|
|
|
7
|
+
/** A displayed external path paired with its resolved target, when distinct. */
|
|
8
|
+
export interface ExternalPathDisclosure {
|
|
9
|
+
/** The path as displayed (typed for tools, lexical-absolute for bash). */
|
|
10
|
+
path: string;
|
|
11
|
+
/** The canonical symlink-resolved target; present only when it differs. */
|
|
12
|
+
resolvedPath?: string;
|
|
13
|
+
}
|
|
14
|
+
|
|
8
15
|
/** The facts a path-shaped gate holds when it raises an ask. */
|
|
9
16
|
interface PathAskFacts {
|
|
10
17
|
toolName: string;
|