@gotgenes/pi-permission-system 25.2.2 → 25.4.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 +30 -0
- package/README.md +1 -1
- package/config/config.example.json +3 -0
- package/dist/public.d.ts +162 -41
- package/docs/configuration.md +17 -2
- package/docs/cross-extension-api.md +14 -9
- package/package.json +1 -1
- package/schemas/permissions.schema.json +16 -0
- package/src/access-intent/bash/command-enumeration.ts +45 -117
- package/src/access-intent/bash/wrapper-analysis.ts +335 -0
- package/src/authority/approval-escalator.ts +26 -1
- package/src/authority/forwarded-request-server.ts +5 -14
- package/src/authority/local-user-authorizer.ts +2 -3
- package/src/authority/permission-prompt-component.ts +87 -47
- package/src/authority/permission-prompter.ts +9 -0
- package/src/config-loader.ts +2 -0
- package/src/config-schema.ts +14 -0
- package/src/extension-config.ts +10 -0
- package/src/handlers/gates/bash-command.ts +7 -2
- package/src/handlers/gates/bash-external-directory.ts +11 -6
- package/src/handlers/gates/bash-path.ts +10 -6
- package/src/handlers/gates/descriptor.ts +10 -1
- package/src/handlers/gates/external-directory.ts +13 -8
- package/src/handlers/gates/helpers.ts +6 -7
- package/src/handlers/gates/path.ts +11 -14
- package/src/handlers/gates/runner.ts +40 -19
- package/src/handlers/gates/skill-input-gate-pipeline.ts +1 -12
- package/src/handlers/gates/skill-input.ts +5 -2
- package/src/handlers/gates/skill-read.ts +6 -6
- package/src/handlers/gates/tool-call-gate-pipeline.ts +1 -5
- package/src/handlers/gates/tool.ts +10 -5
- package/src/handlers/tool-call-boundary.ts +30 -7
- package/src/index.ts +2 -0
- package/src/permission-events.ts +6 -0
- package/src/permission-prompts.ts +4 -72
- package/src/permission-request-id.ts +17 -0
- package/src/presentation/dialog-renderer.ts +404 -0
- package/src/presentation/forwarded-ask-payload.ts +45 -0
- package/src/presentation/legacy-message.ts +117 -0
- package/src/presentation/line-fitting.ts +27 -0
- package/src/presentation/path-ask-payload.ts +128 -0
- package/src/presentation/prompt-payload.ts +137 -0
- package/src/presentation/skill-ask-payload.ts +50 -0
- package/src/presentation/tool-ask-payload.ts +104 -0
- package/src/tool-preview-formatter.ts +1 -1
- package/src/types.ts +6 -0
- package/src/handlers/gates/external-directory-messages.ts +0 -28
|
@@ -0,0 +1,335 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Wrapper interpretation for a bash command unit: what kind of wrapper it is,
|
|
3
|
+
* and — where it can be established — what it actually runs.
|
|
4
|
+
*
|
|
5
|
+
* Pure and word-based; the AST walk that produces the words lives in
|
|
6
|
+
* `command-enumeration.ts`. Both questions live here together deliberately: the
|
|
7
|
+
* shape that floors a unit to `ask` and the shape that names its inner command
|
|
8
|
+
* must agree, and two classifiers over the same vocabulary would drift.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
/** One word of a command unit: its text, and its offset into the unit's text. */
|
|
12
|
+
export interface CommandWord {
|
|
13
|
+
readonly text: string;
|
|
14
|
+
readonly offset: number;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Why a command unit's decision is floored to at least `ask`.
|
|
19
|
+
* `"opaque-payload"` — an inline-shell payload (`bash -c`/`eval`) whose inner
|
|
20
|
+
* program is not re-parsed (#481).
|
|
21
|
+
* `"indirection"` — a prefix/exec wrapper (`sudo`/`env`/`xargs`/`find -exec`/…)
|
|
22
|
+
* whose inner command is a visible argument but is not gated on its own (#490).
|
|
23
|
+
* The kind selects the audit sentinel; both floor identically.
|
|
24
|
+
*/
|
|
25
|
+
export type WrapperKind = "opaque-payload" | "indirection";
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Classify a command unit's words as a floored wrapper, or `undefined` for an
|
|
29
|
+
* ordinary command. `words[0]` is the command name; a leading
|
|
30
|
+
* `variable_assignment` prefix is already stripped by the caller. The command
|
|
31
|
+
* name is matched on its basename, so `/bin/bash -c …` counts.
|
|
32
|
+
*
|
|
33
|
+
* `"opaque-payload"`: `eval`, or a shell (`bash`/`sh`/`dash`/`zsh`/`ksh`) with a
|
|
34
|
+
* `-c` short-flag cluster (`-c`, `-ec`, `-xc`) — the inner program is a quoted
|
|
35
|
+
* argument the enumerator does not re-parse (#481).
|
|
36
|
+
*
|
|
37
|
+
* `"indirection"`: an always-invoking prefix/exec wrapper
|
|
38
|
+
* ({@link INDIRECTION_WRAPPER_NAMES}), or a search tool
|
|
39
|
+
* ({@link EXEC_CONDITIONAL_WRAPPERS}, `find`/`fd`) carrying a per-result exec
|
|
40
|
+
* flag — the inner command is a visible argument that a `<cmd> *` rule would
|
|
41
|
+
* otherwise never match (#490). A bare `find`/`fd` search runs no subcommand and
|
|
42
|
+
* is not flagged.
|
|
43
|
+
*/
|
|
44
|
+
export function classifyWrapperWords(
|
|
45
|
+
words: readonly CommandWord[],
|
|
46
|
+
): WrapperKind | undefined {
|
|
47
|
+
const commandName = wrapperName(words);
|
|
48
|
+
if (commandName === undefined) return undefined;
|
|
49
|
+
const args = words.slice(1).map((word) => word.text);
|
|
50
|
+
if (commandName === "eval") return "opaque-payload";
|
|
51
|
+
if (SHELL_WRAPPER_NAMES.has(commandName) && hasShortFlagC(args)) {
|
|
52
|
+
return "opaque-payload";
|
|
53
|
+
}
|
|
54
|
+
if (INDIRECTION_WRAPPER_NAMES.has(commandName)) return "indirection";
|
|
55
|
+
if (execFlagIndex(commandName, args) !== -1) return "indirection";
|
|
56
|
+
return undefined;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// ── Wrapper vocabulary ───────────────────────────────────────────────────────
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* The command a wrapper unit actually runs, or `null` when it cannot be
|
|
63
|
+
* established or adds nothing over the unit itself.
|
|
64
|
+
*
|
|
65
|
+
* Display-only (ADR 0011 §3.5, #713): the result is never gated and never
|
|
66
|
+
* becomes a `BashCommand`, so the wrapper floor is untouched. Because it is
|
|
67
|
+
* shown on a decision surface, the rule is to fail to `null` rather than to a
|
|
68
|
+
* guess — an unrecognized option shape yields nothing rather than a remainder
|
|
69
|
+
* that might name the wrong command.
|
|
70
|
+
*
|
|
71
|
+
* Nested wrappers unwrap to the innermost command (`sudo timeout 5 xargs grep
|
|
72
|
+
* foo` → `grep foo`), bounded by {@link MAX_UNWRAP_DEPTH}.
|
|
73
|
+
*/
|
|
74
|
+
export function executedUnitOf(
|
|
75
|
+
unitText: string,
|
|
76
|
+
words: readonly CommandWord[],
|
|
77
|
+
): string | null {
|
|
78
|
+
let text = unitText;
|
|
79
|
+
let current = words;
|
|
80
|
+
|
|
81
|
+
for (let depth = 0; depth < MAX_UNWRAP_DEPTH; depth++) {
|
|
82
|
+
const kind = classifyWrapperWords(current);
|
|
83
|
+
if (kind === undefined) break;
|
|
84
|
+
|
|
85
|
+
if (kind === "opaque-payload") {
|
|
86
|
+
// The payload is an inner *program*, not a slice of this command line, so
|
|
87
|
+
// it is unquoted and terminal — unwrapping it further would need a parse.
|
|
88
|
+
return nothingNew(opaquePayload(current), unitText);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const start = innerCommandIndex(current);
|
|
92
|
+
if (start === -1 || start >= current.length) break;
|
|
93
|
+
const end = execTerminatorIndex(current, start);
|
|
94
|
+
text = sliceWords(text, current, start, end).trimEnd();
|
|
95
|
+
current = rebase(current, start, end);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
return nothingNew(text, unitText);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** How many wrapper layers to unwrap before giving up. */
|
|
102
|
+
const MAX_UNWRAP_DEPTH = 4;
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* The extracted text, or `null` when it establishes nothing new — it is absent
|
|
106
|
+
* or empty, it still begins with an option (so the inner command was never
|
|
107
|
+
* reached), or it simply repeats the unit.
|
|
108
|
+
*/
|
|
109
|
+
function nothingNew(text: string | null, unitText: string): string | null {
|
|
110
|
+
if (text === null || text === "" || text === unitText) return null;
|
|
111
|
+
return text.startsWith("-") ? null : text;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** The inline-shell payload argument, unquoted; `null` when absent. */
|
|
115
|
+
function opaquePayload(words: readonly CommandWord[]): string | null {
|
|
116
|
+
const args = words.slice(1);
|
|
117
|
+
// `eval` takes its program as the first argument (no `-c`, so the index is
|
|
118
|
+
// -1); a shell takes it after the `-c` cluster.
|
|
119
|
+
const flagIndex = shortFlagCIndex(args.map((word) => word.text));
|
|
120
|
+
const payload = args[flagIndex + 1] as CommandWord | undefined;
|
|
121
|
+
return payload === undefined ? null : unquote(payload.text);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** Strip one matching pair of surrounding quotes. */
|
|
125
|
+
function unquote(text: string): string {
|
|
126
|
+
const first = text.at(0);
|
|
127
|
+
const quoted =
|
|
128
|
+
(first === "'" || first === '"') &&
|
|
129
|
+
text.length >= 2 &&
|
|
130
|
+
text.endsWith(first);
|
|
131
|
+
return quoted ? text.slice(1, -1) : text;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Index of the word beginning the inner command, or `-1` when the wrapper's own
|
|
136
|
+
* options run out first.
|
|
137
|
+
*
|
|
138
|
+
* Skips the wrapper name, environment assignments, options (consuming a
|
|
139
|
+
* following value for the options in {@link VALUE_TAKING_FLAGS}), and a leading
|
|
140
|
+
* operand for the wrappers that take one. An exec-conditional wrapper instead
|
|
141
|
+
* starts immediately after its exec flag.
|
|
142
|
+
*/
|
|
143
|
+
function innerCommandIndex(words: readonly CommandWord[]): number {
|
|
144
|
+
const name = wrapperName(words);
|
|
145
|
+
if (name === undefined) return -1;
|
|
146
|
+
|
|
147
|
+
const argTexts = words.slice(1).map((word) => word.text);
|
|
148
|
+
const execFlag = execFlagIndex(name, argTexts);
|
|
149
|
+
if (execFlag !== -1) return execFlag + 2;
|
|
150
|
+
|
|
151
|
+
const valueTaking = VALUE_TAKING_FLAGS.get(name) ?? EMPTY_FLAGS;
|
|
152
|
+
let operandPending = LEADING_OPERAND_WRAPPERS.has(name);
|
|
153
|
+
let index = 1;
|
|
154
|
+
|
|
155
|
+
while (index < words.length) {
|
|
156
|
+
const word = words[index].text;
|
|
157
|
+
if (word === "--") return index + 1;
|
|
158
|
+
if (isEnvironmentAssignment(word)) {
|
|
159
|
+
index++;
|
|
160
|
+
continue;
|
|
161
|
+
}
|
|
162
|
+
if (word.startsWith("-")) {
|
|
163
|
+
index += valueTaking.has(word) ? 2 : 1;
|
|
164
|
+
continue;
|
|
165
|
+
}
|
|
166
|
+
if (operandPending) {
|
|
167
|
+
operandPending = false;
|
|
168
|
+
index++;
|
|
169
|
+
continue;
|
|
170
|
+
}
|
|
171
|
+
return index;
|
|
172
|
+
}
|
|
173
|
+
return -1;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* Index of an exec wrapper's `;`/`+` terminator, or `words.length` — the
|
|
178
|
+
* terminator belongs to `find`, not to the command it runs.
|
|
179
|
+
*/
|
|
180
|
+
function execTerminatorIndex(
|
|
181
|
+
words: readonly CommandWord[],
|
|
182
|
+
start: number,
|
|
183
|
+
): number {
|
|
184
|
+
const terminator = words.findIndex(
|
|
185
|
+
(word, index) =>
|
|
186
|
+
index >= start && EXEC_TERMINATORS.has(word.text.replace(/^\\/, "")),
|
|
187
|
+
);
|
|
188
|
+
return terminator === -1 ? words.length : terminator;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/** The unit text spanned by `words[start..end)`. */
|
|
192
|
+
function sliceWords(
|
|
193
|
+
unitText: string,
|
|
194
|
+
words: readonly CommandWord[],
|
|
195
|
+
start: number,
|
|
196
|
+
end: number,
|
|
197
|
+
): string {
|
|
198
|
+
const from = words[start].offset;
|
|
199
|
+
return end < words.length
|
|
200
|
+
? unitText.slice(from, words[end].offset)
|
|
201
|
+
: unitText.slice(from);
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/** `words[start..end)` with offsets rebased onto the sliced text. */
|
|
205
|
+
function rebase(
|
|
206
|
+
words: readonly CommandWord[],
|
|
207
|
+
start: number,
|
|
208
|
+
end: number,
|
|
209
|
+
): CommandWord[] {
|
|
210
|
+
const origin = words[start].offset;
|
|
211
|
+
return words
|
|
212
|
+
.slice(start, end)
|
|
213
|
+
.map((word) => ({ text: word.text, offset: word.offset - origin }));
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/** True for a `NAME=value` environment prefix. */
|
|
217
|
+
function isEnvironmentAssignment(word: string): boolean {
|
|
218
|
+
return /^[A-Za-z_][A-Za-z0-9_]*=/.test(word);
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/**
|
|
222
|
+
* Shell command names whose `-c` flag introduces an opaque inline program.
|
|
223
|
+
*/
|
|
224
|
+
const SHELL_WRAPPER_NAMES = new Set(["bash", "sh", "dash", "zsh", "ksh"]);
|
|
225
|
+
|
|
226
|
+
/**
|
|
227
|
+
* Indirection wrappers that always invoke a following command, so the wrapper
|
|
228
|
+
* (not the inner command) is what a bash rule matches. Floored by command-name
|
|
229
|
+
* basename alone. Extend this set to cover another always-invoking wrapper.
|
|
230
|
+
*/
|
|
231
|
+
const INDIRECTION_WRAPPER_NAMES = new Set([
|
|
232
|
+
"sudo",
|
|
233
|
+
"env",
|
|
234
|
+
"xargs",
|
|
235
|
+
"time",
|
|
236
|
+
"nohup",
|
|
237
|
+
"timeout",
|
|
238
|
+
"nice",
|
|
239
|
+
// Exec-capable rewrites and prefix wrappers surveyed in #575: parallelizers
|
|
240
|
+
// (parallel/rust-parallel/rush), a sudo rewrite (doas), and prefix wrappers
|
|
241
|
+
// (setsid/stdbuf/watch/flock) that all always invoke a following command.
|
|
242
|
+
"parallel",
|
|
243
|
+
"rust-parallel",
|
|
244
|
+
"rush",
|
|
245
|
+
"doas",
|
|
246
|
+
"setsid",
|
|
247
|
+
"stdbuf",
|
|
248
|
+
"watch",
|
|
249
|
+
"flock",
|
|
250
|
+
]);
|
|
251
|
+
|
|
252
|
+
/**
|
|
253
|
+
* Search tools that invoke a command per result only when an exec flag is
|
|
254
|
+
* present; a bare search runs no subcommand. Floored only when an argument
|
|
255
|
+
* exactly matches one of the tool's exec flags. Extend by adding a tool with
|
|
256
|
+
* its exec-flag set.
|
|
257
|
+
*/
|
|
258
|
+
const EXEC_CONDITIONAL_WRAPPERS = new Map<string, ReadonlySet<string>>([
|
|
259
|
+
["find", new Set(["-exec", "-execdir", "-ok", "-okdir"])],
|
|
260
|
+
["fd", new Set(["-x", "--exec", "-X", "--exec-batch"])],
|
|
261
|
+
]);
|
|
262
|
+
|
|
263
|
+
/**
|
|
264
|
+
* Curated per-wrapper options that consume the following word, so skipping a
|
|
265
|
+
* wrapper's own arguments does not mistake an option's value for the inner
|
|
266
|
+
* command. Attached forms (`-I{}`, `--user=root`) need no entry — they are one
|
|
267
|
+
* word. Only the display-side extraction reads this, and a missing or wrong
|
|
268
|
+
* entry yields `null` (see {@link executedUnitOf}), never a weaker gate.
|
|
269
|
+
*/
|
|
270
|
+
const VALUE_TAKING_FLAGS = new Map<string, ReadonlySet<string>>([
|
|
271
|
+
["sudo", new Set(["-u", "-g", "-p", "-C", "-h", "-U", "-r", "-t"])],
|
|
272
|
+
["doas", new Set(["-u", "-C"])],
|
|
273
|
+
["env", new Set(["-u", "-C", "--unset", "--chdir"])],
|
|
274
|
+
[
|
|
275
|
+
"xargs",
|
|
276
|
+
new Set(["-n", "-P", "-I", "-i", "-d", "-E", "-L", "-l", "-s", "-a"]),
|
|
277
|
+
],
|
|
278
|
+
["timeout", new Set(["-s", "-k", "--signal", "--kill-after"])],
|
|
279
|
+
["nice", new Set(["-n", "--adjustment"])],
|
|
280
|
+
["time", new Set(["-o", "-f", "--output", "--format"])],
|
|
281
|
+
["stdbuf", new Set(["-i", "-o", "-e", "--input", "--output", "--error"])],
|
|
282
|
+
["watch", new Set(["-n", "--interval"])],
|
|
283
|
+
["flock", new Set(["-w", "-E", "--timeout", "--conflict-exit-code"])],
|
|
284
|
+
]);
|
|
285
|
+
|
|
286
|
+
const EMPTY_FLAGS: ReadonlySet<string> = new Set<string>();
|
|
287
|
+
|
|
288
|
+
/**
|
|
289
|
+
* Wrappers whose first bare word is an operand (a duration, a lock file) rather
|
|
290
|
+
* than the start of the inner command.
|
|
291
|
+
*/
|
|
292
|
+
const LEADING_OPERAND_WRAPPERS = new Set(["timeout", "flock"]);
|
|
293
|
+
|
|
294
|
+
/** Words ending a `find -exec` clause; they belong to `find`, not its command. */
|
|
295
|
+
const EXEC_TERMINATORS = new Set([";", "+"]);
|
|
296
|
+
|
|
297
|
+
// ── Shared helpers ───────────────────────────────────────────────────────────
|
|
298
|
+
|
|
299
|
+
/** The wrapper's command-name basename, or `undefined` for an empty unit. */
|
|
300
|
+
function wrapperName(words: readonly CommandWord[]): string | undefined {
|
|
301
|
+
return words.length === 0 ? undefined : basename(words[0].text);
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
/**
|
|
305
|
+
* True when an argument list has a short-flag cluster containing `c` before any
|
|
306
|
+
* `--` end-of-options marker (`-c`, `-ec`, `-xc`) — the inline-shell payload
|
|
307
|
+
* flag for `bash`/`sh`/`dash`/`zsh`/`ksh`.
|
|
308
|
+
*/
|
|
309
|
+
function hasShortFlagC(args: readonly string[]): boolean {
|
|
310
|
+
return shortFlagCIndex(args) !== -1;
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
/** Index within `args` of the `-c` short-flag cluster, or `-1`. */
|
|
314
|
+
function shortFlagCIndex(args: readonly string[]): number {
|
|
315
|
+
for (const [index, arg] of args.entries()) {
|
|
316
|
+
if (arg === "--") return -1;
|
|
317
|
+
if (arg.startsWith("-") && !arg.startsWith("--") && arg.includes("c")) {
|
|
318
|
+
return index;
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
return -1;
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
/** Index within `args` of a matched per-result exec flag, or `-1`. */
|
|
325
|
+
function execFlagIndex(commandName: string, args: readonly string[]): number {
|
|
326
|
+
const execFlags = EXEC_CONDITIONAL_WRAPPERS.get(commandName);
|
|
327
|
+
if (!execFlags) return -1;
|
|
328
|
+
return args.findIndex((arg) => execFlags.has(arg));
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
/** The final path segment of a command name (`/bin/bash` → `bash`). */
|
|
332
|
+
function basename(name: string): string {
|
|
333
|
+
const slash = name.lastIndexOf("/");
|
|
334
|
+
return slash === -1 ? name : name.slice(slash + 1);
|
|
335
|
+
}
|
|
@@ -34,6 +34,7 @@ import {
|
|
|
34
34
|
} from "#src/authority/permission-forwarding";
|
|
35
35
|
import type { ServingLookup } from "#src/authority/serving-registry";
|
|
36
36
|
import type { SubagentSessionRegistry } from "#src/authority/subagent-registry";
|
|
37
|
+
import { createPermissionRequestId } from "#src/permission-request-id";
|
|
37
38
|
import { buildUiPrompt } from "#src/permission-ui-prompt";
|
|
38
39
|
import type { DebugReviewLogger } from "#src/session-logger";
|
|
39
40
|
import { toRecord } from "#src/value-guards";
|
|
@@ -75,6 +76,12 @@ function getContextSystemPrompt(ctx: ForwarderContext): string | undefined {
|
|
|
75
76
|
* relayed value instead of three positional optionals.
|
|
76
77
|
*/
|
|
77
78
|
interface ForwardedRequestFacts {
|
|
79
|
+
/**
|
|
80
|
+
* The requester's own permission request id, adopted as the forwarded
|
|
81
|
+
* request's id so one id runs from the child's gate to the serving node's
|
|
82
|
+
* decision instead of a third being minted here.
|
|
83
|
+
*/
|
|
84
|
+
requestId: string;
|
|
78
85
|
message: string;
|
|
79
86
|
display?: ForwardedPromptDisplay;
|
|
80
87
|
sessionApproval?: ForwardedSessionApproval;
|
|
@@ -111,6 +118,23 @@ function abandon(denialReason: string): PermissionPromptDecision {
|
|
|
111
118
|
};
|
|
112
119
|
}
|
|
113
120
|
|
|
121
|
+
/** Ids this node is willing to use as a request/response filename. */
|
|
122
|
+
const FILENAME_SAFE_REQUEST_ID = /^[A-Za-z0-9._-]+$/;
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* The id to write on the forwarded request: the requester's own, or a fresh
|
|
126
|
+
* mint when that id could not safely name a file.
|
|
127
|
+
*
|
|
128
|
+
* At a relay hop the adopted id came from a request file on disk, which the
|
|
129
|
+
* tolerant reader validates only as a string — so this is the boundary that
|
|
130
|
+
* keeps an inbound id from choosing an outbound path.
|
|
131
|
+
*/
|
|
132
|
+
function forwardableRequestId(requesterRequestId: string): string {
|
|
133
|
+
return FILENAME_SAFE_REQUEST_ID.test(requesterRequestId)
|
|
134
|
+
? requesterRequestId
|
|
135
|
+
: createPermissionRequestId();
|
|
136
|
+
}
|
|
137
|
+
|
|
114
138
|
/**
|
|
115
139
|
* Authorizer for a subagent session: escalate the ask up the tree to the
|
|
116
140
|
* parent's authority.
|
|
@@ -146,6 +170,7 @@ export class ParentAuthorizer implements TerminalAuthorizer {
|
|
|
146
170
|
): Promise<PermissionPromptDecision> {
|
|
147
171
|
const uiPrompt = buildUiPrompt(details);
|
|
148
172
|
return this.waitForForwardedApproval(this.ctx, {
|
|
173
|
+
requestId: details.requestId,
|
|
149
174
|
message: details.message,
|
|
150
175
|
display: {
|
|
151
176
|
source: uiPrompt.source,
|
|
@@ -250,7 +275,7 @@ export class ParentAuthorizer implements TerminalAuthorizer {
|
|
|
250
275
|
requesterSessionId: string,
|
|
251
276
|
targetSessionId: string,
|
|
252
277
|
): ForwardedPermissionRequest {
|
|
253
|
-
const requestId =
|
|
278
|
+
const requestId = forwardableRequestId(facts.requestId);
|
|
254
279
|
const requesterAgentName =
|
|
255
280
|
getActiveAgentName(ctx) ??
|
|
256
281
|
getActiveAgentNameFromSystemPrompt(getContextSystemPrompt(ctx)) ??
|
|
@@ -13,6 +13,8 @@ import {
|
|
|
13
13
|
type PermissionForwardingLocation,
|
|
14
14
|
} from "#src/authority/permission-forwarding";
|
|
15
15
|
import type { SubagentSessionRegistry } from "#src/authority/subagent-registry";
|
|
16
|
+
import { buildForwardedAskPayload } from "#src/presentation/forwarded-ask-payload";
|
|
17
|
+
import { renderLegacyMessage } from "#src/presentation/legacy-message";
|
|
16
18
|
import { SessionApproval } from "#src/session-approval";
|
|
17
19
|
import type { SessionApprovalRecorder } from "#src/session-approval-recorder";
|
|
18
20
|
import type { DebugReviewLogger } from "#src/session-logger";
|
|
@@ -78,19 +80,6 @@ export interface ForwardedRequestServerDeps {
|
|
|
78
80
|
|
|
79
81
|
// ── Module-private helpers ────────────────────────────────────────────────
|
|
80
82
|
|
|
81
|
-
function formatForwardedPermissionPrompt(
|
|
82
|
-
request: ForwardedPermissionRequest,
|
|
83
|
-
): string {
|
|
84
|
-
const agentName = request.requesterAgentName || "unknown";
|
|
85
|
-
const sessionId = request.requesterSessionId || "unknown";
|
|
86
|
-
return [
|
|
87
|
-
`Subagent '${agentName}' requested permission.`,
|
|
88
|
-
`Session ID: ${sessionId}`,
|
|
89
|
-
"",
|
|
90
|
-
request.message,
|
|
91
|
-
].join("\n");
|
|
92
|
-
}
|
|
93
|
-
|
|
94
83
|
/**
|
|
95
84
|
* Map a forwarded request onto the escalated ask's details, carrying the
|
|
96
85
|
* forwarded provenance (requester agent/session + the child's original display
|
|
@@ -106,11 +95,13 @@ function formatForwardedPermissionPrompt(
|
|
|
106
95
|
function buildForwardedAskDetails(
|
|
107
96
|
request: ForwardedPermissionRequest,
|
|
108
97
|
): PromptPermissionDetails {
|
|
98
|
+
const payload = buildForwardedAskPayload(request);
|
|
109
99
|
return {
|
|
110
100
|
requestId: request.id,
|
|
111
101
|
source: request.source ?? "tool_call",
|
|
112
102
|
agentName: request.requesterAgentName || null,
|
|
113
|
-
message:
|
|
103
|
+
message: renderLegacyMessage(payload),
|
|
104
|
+
payload,
|
|
114
105
|
surface: request.surface ?? null,
|
|
115
106
|
value: request.value ?? null,
|
|
116
107
|
forwarding: {
|
|
@@ -53,13 +53,12 @@ export class LocalUserAuthorizer implements TerminalAuthorizer {
|
|
|
53
53
|
{
|
|
54
54
|
mode: this.deps.mode,
|
|
55
55
|
ui: this.deps.ui,
|
|
56
|
-
|
|
57
|
-
this.deps.getPromptPreferences().doublePressToConfirm,
|
|
56
|
+
...this.deps.getPromptPreferences(),
|
|
58
57
|
},
|
|
59
58
|
details.forwarding
|
|
60
59
|
? "Permission Required (Subagent)"
|
|
61
60
|
: "Permission Required",
|
|
62
|
-
details.
|
|
61
|
+
details.payload,
|
|
63
62
|
buildRequestOptions(details),
|
|
64
63
|
);
|
|
65
64
|
}
|