@gotgenes/pi-permission-system 25.2.2 → 25.3.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 +15 -0
- package/README.md +1 -1
- package/config/config.example.json +3 -0
- package/dist/public.d.ts +156 -41
- package/docs/configuration.md +17 -2
- 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/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/external-directory.ts +13 -8
- package/src/handlers/gates/path.ts +11 -14
- package/src/handlers/gates/skill-input.ts +5 -2
- package/src/handlers/gates/skill-read.ts +5 -6
- package/src/handlers/gates/tool.ts +10 -5
- package/src/index.ts +2 -0
- package/src/permission-prompts.ts +4 -72
- 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
|
+
}
|
|
@@ -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
|
}
|
|
@@ -3,12 +3,7 @@ import type {
|
|
|
3
3
|
ExtensionUIContext,
|
|
4
4
|
KeybindingsManager,
|
|
5
5
|
} from "@earendil-works/pi-coding-agent";
|
|
6
|
-
import {
|
|
7
|
-
type Component,
|
|
8
|
-
matchesKey,
|
|
9
|
-
truncateToWidth,
|
|
10
|
-
wrapTextWithAnsi,
|
|
11
|
-
} from "@earendil-works/pi-tui";
|
|
6
|
+
import { type Component, matchesKey } from "@earendil-works/pi-tui";
|
|
12
7
|
import {
|
|
13
8
|
type PermissionPromptDecision,
|
|
14
9
|
type RequestPermissionOptions,
|
|
@@ -22,6 +17,14 @@ import {
|
|
|
22
17
|
type PromptViewState,
|
|
23
18
|
reducePrompt,
|
|
24
19
|
} from "#src/authority/permission-prompt-decision";
|
|
20
|
+
import {
|
|
21
|
+
completeViewBudget,
|
|
22
|
+
type DialogView,
|
|
23
|
+
type RenderBudget,
|
|
24
|
+
renderPromptDialog,
|
|
25
|
+
} from "#src/presentation/dialog-renderer";
|
|
26
|
+
import { fitLinesToWidth } from "#src/presentation/line-fitting";
|
|
27
|
+
import type { PromptPayload } from "#src/presentation/prompt-payload";
|
|
25
28
|
|
|
26
29
|
/**
|
|
27
30
|
* Inline `ctx.ui.custom` permission dialog for TUI sessions.
|
|
@@ -43,15 +46,16 @@ export type PermissionPromptUi = Pick<
|
|
|
43
46
|
type PromptKeybindings = Pick<KeybindingsManager, "matches">;
|
|
44
47
|
|
|
45
48
|
/** The resolved presentation context selected once per activation. */
|
|
46
|
-
export interface PermissionPromptView {
|
|
49
|
+
export interface PermissionPromptView extends PromptPreferences {
|
|
47
50
|
mode: ExtensionContext["mode"];
|
|
48
51
|
ui: PermissionPromptUi;
|
|
49
|
-
doublePressToConfirm: boolean;
|
|
50
52
|
}
|
|
51
53
|
|
|
52
54
|
/** Live prompt-behavior preferences read at prompt time (see `doublePressToConfirm`). */
|
|
53
55
|
export interface PromptPreferences {
|
|
54
56
|
doublePressToConfirm: boolean;
|
|
57
|
+
/** How much room a render has; the terminal width is added per frame. */
|
|
58
|
+
budget: RenderBudget;
|
|
55
59
|
}
|
|
56
60
|
|
|
57
61
|
/**
|
|
@@ -64,15 +68,30 @@ export interface PromptPreferences {
|
|
|
64
68
|
export function requestPermissionDecision(
|
|
65
69
|
view: PermissionPromptView,
|
|
66
70
|
title: string,
|
|
67
|
-
|
|
71
|
+
payload: PromptPayload,
|
|
68
72
|
options?: RequestPermissionOptions,
|
|
69
73
|
): Promise<PermissionPromptDecision> {
|
|
70
74
|
if (view.mode === "tui") {
|
|
71
|
-
return presentInlinePermissionPrompt(view, title,
|
|
75
|
+
return presentInlinePermissionPrompt(view, title, payload, options);
|
|
72
76
|
}
|
|
73
|
-
|
|
77
|
+
// The fallback renders once and cannot re-render, so it neither paints nor
|
|
78
|
+
// offers an expansion; it substitutes a nominal width for the terminal size
|
|
79
|
+
// it is never told, and the host's own select wraps from there.
|
|
80
|
+
const rendered = renderPromptDialog(payload, {
|
|
81
|
+
...view.budget,
|
|
82
|
+
width: FALLBACK_RENDER_WIDTH,
|
|
83
|
+
});
|
|
84
|
+
return requestPermissionDecisionFromUi(
|
|
85
|
+
view.ui,
|
|
86
|
+
title,
|
|
87
|
+
rendered.lines.join("\n"),
|
|
88
|
+
options,
|
|
89
|
+
);
|
|
74
90
|
}
|
|
75
91
|
|
|
92
|
+
/** The width the `select`/`input` fallback renders against. */
|
|
93
|
+
const FALLBACK_RENDER_WIDTH = 80;
|
|
94
|
+
|
|
76
95
|
/** Minimal theme surface the dialog uses; satisfied by the real SDK theme. */
|
|
77
96
|
interface PromptTheme {
|
|
78
97
|
fg(color: string, text: string): string;
|
|
@@ -92,7 +111,7 @@ const OPTION_ORDER: readonly PromptKey[] = ["y", "s", "n", "r"];
|
|
|
92
111
|
export function presentInlinePermissionPrompt(
|
|
93
112
|
view: PermissionPromptView,
|
|
94
113
|
title: string,
|
|
95
|
-
|
|
114
|
+
payload: PromptPayload,
|
|
96
115
|
options?: RequestPermissionOptions,
|
|
97
116
|
): Promise<PermissionPromptDecision> {
|
|
98
117
|
const config: PromptModelConfig = {
|
|
@@ -106,7 +125,8 @@ export function presentInlinePermissionPrompt(
|
|
|
106
125
|
theme,
|
|
107
126
|
config,
|
|
108
127
|
title,
|
|
109
|
-
|
|
128
|
+
payload,
|
|
129
|
+
view.budget,
|
|
110
130
|
(data) => handleToolsExpandAction(data, keybindings, view.ui),
|
|
111
131
|
() => {
|
|
112
132
|
tui.requestRender();
|
|
@@ -145,12 +165,15 @@ function handleToolsExpandAction(
|
|
|
145
165
|
class PermissionPromptComponent implements Component {
|
|
146
166
|
private state: PromptViewState;
|
|
147
167
|
private reasonBuffer = "";
|
|
168
|
+
/** Whether the operator asked to see the complete request (ADR 0011 §4). */
|
|
169
|
+
private expanded = false;
|
|
148
170
|
|
|
149
171
|
constructor(
|
|
150
172
|
private readonly theme: PromptTheme,
|
|
151
173
|
private readonly config: PromptModelConfig,
|
|
152
174
|
private readonly title: string,
|
|
153
|
-
private readonly
|
|
175
|
+
private readonly payload: PromptPayload,
|
|
176
|
+
private readonly budget: RenderBudget,
|
|
154
177
|
private readonly handleAppAction: (data: string) => boolean,
|
|
155
178
|
private readonly requestRender: () => void,
|
|
156
179
|
private readonly done: (decision: PermissionPromptDecision) => void,
|
|
@@ -163,26 +186,66 @@ class PermissionPromptComponent implements Component {
|
|
|
163
186
|
}
|
|
164
187
|
|
|
165
188
|
render(width: number): string[] {
|
|
166
|
-
return
|
|
189
|
+
return fitLinesToWidth(this.renderStep(width), width);
|
|
167
190
|
}
|
|
168
191
|
|
|
169
|
-
private renderStep(): string[] {
|
|
192
|
+
private renderStep(width: number): string[] {
|
|
170
193
|
switch (this.state.step) {
|
|
171
194
|
case "decision":
|
|
172
|
-
return this.renderDecision();
|
|
195
|
+
return this.renderDecision(width);
|
|
173
196
|
case "reason":
|
|
174
|
-
return this.renderReason();
|
|
197
|
+
return this.renderReason(width);
|
|
175
198
|
case "scope":
|
|
176
199
|
return this.renderScope();
|
|
177
200
|
}
|
|
178
201
|
}
|
|
179
202
|
|
|
203
|
+
/**
|
|
204
|
+
* The ask itself, bounded to the budget at this frame's width.
|
|
205
|
+
*
|
|
206
|
+
* Rendered per frame rather than once, because the row budget is a function
|
|
207
|
+
* of the width the host gives us, which a resize changes.
|
|
208
|
+
*/
|
|
209
|
+
private renderAsk(width: number): DialogView {
|
|
210
|
+
return renderPromptDialog(
|
|
211
|
+
this.payload,
|
|
212
|
+
this.expanded ? completeViewBudget(width) : { ...this.budget, width },
|
|
213
|
+
(text) => this.theme.fg("warning", text),
|
|
214
|
+
);
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/**
|
|
218
|
+
* The key hints, naming the expansion only when it would do something.
|
|
219
|
+
*
|
|
220
|
+
* An affordance advertised when there is nothing to expand is noise; one
|
|
221
|
+
* left unadvertised when the render dropped something is a decision made
|
|
222
|
+
* without the evidence.
|
|
223
|
+
*/
|
|
224
|
+
private hint(view: DialogView): string {
|
|
225
|
+
const keys = [
|
|
226
|
+
"↑/↓ move",
|
|
227
|
+
"enter confirm",
|
|
228
|
+
"esc deny",
|
|
229
|
+
"press a letter, then again to confirm",
|
|
230
|
+
];
|
|
231
|
+
if (this.expanded) {
|
|
232
|
+
keys.push("ctrl+o collapse");
|
|
233
|
+
} else if (view.elided) {
|
|
234
|
+
keys.push("ctrl+o full request");
|
|
235
|
+
}
|
|
236
|
+
return this.theme.fg("muted", keys.join(" · "));
|
|
237
|
+
}
|
|
238
|
+
|
|
180
239
|
handleInput(data: string): void {
|
|
181
240
|
if (this.state.step === "reason") {
|
|
182
241
|
this.handleReasonInput(data);
|
|
183
242
|
return;
|
|
184
243
|
}
|
|
185
244
|
if (this.handleAppAction(data)) {
|
|
245
|
+
// One "expand" for the operator: the host expands its pending tool call
|
|
246
|
+
// and the dialog expands its own render, on the same keystroke.
|
|
247
|
+
this.expanded = !this.expanded;
|
|
248
|
+
this.requestRender();
|
|
186
249
|
return;
|
|
187
250
|
}
|
|
188
251
|
const event = this.toEvent(data);
|
|
@@ -247,8 +310,9 @@ class PermissionPromptComponent implements Component {
|
|
|
247
310
|
this.requestRender();
|
|
248
311
|
}
|
|
249
312
|
|
|
250
|
-
private renderDecision(): string[] {
|
|
251
|
-
const
|
|
313
|
+
private renderDecision(width: number): string[] {
|
|
314
|
+
const ask = this.renderAsk(width);
|
|
315
|
+
const lines = [this.theme.fg("accent", this.title), ...ask.lines, ""];
|
|
252
316
|
for (const key of OPTION_ORDER) {
|
|
253
317
|
const label = key === "s" ? this.config.sessionLabel : OPTION_LABELS[key];
|
|
254
318
|
const selected = this.state.highlightedKey === key;
|
|
@@ -257,20 +321,14 @@ class PermissionPromptComponent implements Component {
|
|
|
257
321
|
lines.push(selected ? this.theme.fg("accent", row) : row);
|
|
258
322
|
}
|
|
259
323
|
lines.push("");
|
|
260
|
-
lines.push(
|
|
261
|
-
this.state.hint ||
|
|
262
|
-
this.theme.fg(
|
|
263
|
-
"muted",
|
|
264
|
-
"↑/↓ move · enter confirm · esc deny · press a letter, then again to confirm",
|
|
265
|
-
),
|
|
266
|
-
);
|
|
324
|
+
lines.push(this.state.hint || this.hint(ask));
|
|
267
325
|
return lines;
|
|
268
326
|
}
|
|
269
327
|
|
|
270
|
-
private renderReason(): string[] {
|
|
328
|
+
private renderReason(width: number): string[] {
|
|
271
329
|
const lines = [
|
|
272
330
|
this.theme.fg("accent", this.title),
|
|
273
|
-
this.
|
|
331
|
+
...this.renderAsk(width).lines,
|
|
274
332
|
"",
|
|
275
333
|
`Reason (required): ${this.reasonBuffer}\u2588`,
|
|
276
334
|
];
|
|
@@ -307,24 +365,6 @@ class PermissionPromptComponent implements Component {
|
|
|
307
365
|
}
|
|
308
366
|
}
|
|
309
367
|
|
|
310
|
-
/**
|
|
311
|
-
* Fit rendered lines to the terminal width, satisfying the `ctx.ui.custom`
|
|
312
|
-
* contract that every returned line be a single visual row no wider than
|
|
313
|
-
* `width`. Long lines (e.g. a wide tool-preview message) are wrapped rather
|
|
314
|
-
* than clipped so no content is lost; the final `truncateToWidth` guards the
|
|
315
|
-
* edge cases `wrapTextWithAnsi` cannot split (a lone wide grapheme).
|
|
316
|
-
*/
|
|
317
|
-
function fitToWidth(lines: string[], width: number): string[] {
|
|
318
|
-
if (width <= 0) {
|
|
319
|
-
return [];
|
|
320
|
-
}
|
|
321
|
-
return lines.flatMap((line) =>
|
|
322
|
-
wrapTextWithAnsi(line, width).map((wrapped) =>
|
|
323
|
-
truncateToWidth(wrapped, width),
|
|
324
|
-
),
|
|
325
|
-
);
|
|
326
|
-
}
|
|
327
|
-
|
|
328
368
|
function isPrintable(data: string): boolean {
|
|
329
369
|
if (data.length !== 1) {
|
|
330
370
|
return false;
|
|
@@ -3,6 +3,7 @@ import type {
|
|
|
3
3
|
ForwardedAccessFacts,
|
|
4
4
|
ForwardedSessionApproval,
|
|
5
5
|
} from "#src/authority/permission-forwarding";
|
|
6
|
+
import type { PromptPayload } from "#src/presentation/prompt-payload";
|
|
6
7
|
import type { ReviewLogger } from "#src/session-logger";
|
|
7
8
|
import type { TerminalAuthorizer } from "./authorizer";
|
|
8
9
|
|
|
@@ -27,6 +28,14 @@ export interface PromptPermissionDetails {
|
|
|
27
28
|
source: PermissionReviewSource;
|
|
28
29
|
agentName: string | null;
|
|
29
30
|
message: string;
|
|
31
|
+
/**
|
|
32
|
+
* The complete structured description of this ask (ADR 0011 §2).
|
|
33
|
+
*
|
|
34
|
+
* Required: every ask carries one, and the type is what guarantees it rather
|
|
35
|
+
* than a convention each gate has to remember. `message` is a render over it
|
|
36
|
+
* for the duration of the transition, so the two cannot disagree.
|
|
37
|
+
*/
|
|
38
|
+
payload: PromptPayload;
|
|
30
39
|
toolCallId?: string;
|
|
31
40
|
toolName?: string;
|
|
32
41
|
skillName?: string;
|
package/src/config-loader.ts
CHANGED
|
@@ -222,6 +222,8 @@ export function mergeUnifiedConfigs(
|
|
|
222
222
|
// Number scalars: override replaces base when defined
|
|
223
223
|
for (const key of [
|
|
224
224
|
"forwardingTimeoutMs",
|
|
225
|
+
"promptMaxRows",
|
|
226
|
+
"promptFieldMaxWidth",
|
|
225
227
|
"toolInputPreviewMaxLength",
|
|
226
228
|
"toolTextSummaryMaxLength",
|
|
227
229
|
] as const) {
|