@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.
Files changed (47) hide show
  1. package/CHANGELOG.md +30 -0
  2. package/README.md +1 -1
  3. package/config/config.example.json +3 -0
  4. package/dist/public.d.ts +162 -41
  5. package/docs/configuration.md +17 -2
  6. package/docs/cross-extension-api.md +14 -9
  7. package/package.json +1 -1
  8. package/schemas/permissions.schema.json +16 -0
  9. package/src/access-intent/bash/command-enumeration.ts +45 -117
  10. package/src/access-intent/bash/wrapper-analysis.ts +335 -0
  11. package/src/authority/approval-escalator.ts +26 -1
  12. package/src/authority/forwarded-request-server.ts +5 -14
  13. package/src/authority/local-user-authorizer.ts +2 -3
  14. package/src/authority/permission-prompt-component.ts +87 -47
  15. package/src/authority/permission-prompter.ts +9 -0
  16. package/src/config-loader.ts +2 -0
  17. package/src/config-schema.ts +14 -0
  18. package/src/extension-config.ts +10 -0
  19. package/src/handlers/gates/bash-command.ts +7 -2
  20. package/src/handlers/gates/bash-external-directory.ts +11 -6
  21. package/src/handlers/gates/bash-path.ts +10 -6
  22. package/src/handlers/gates/descriptor.ts +10 -1
  23. package/src/handlers/gates/external-directory.ts +13 -8
  24. package/src/handlers/gates/helpers.ts +6 -7
  25. package/src/handlers/gates/path.ts +11 -14
  26. package/src/handlers/gates/runner.ts +40 -19
  27. package/src/handlers/gates/skill-input-gate-pipeline.ts +1 -12
  28. package/src/handlers/gates/skill-input.ts +5 -2
  29. package/src/handlers/gates/skill-read.ts +6 -6
  30. package/src/handlers/gates/tool-call-gate-pipeline.ts +1 -5
  31. package/src/handlers/gates/tool.ts +10 -5
  32. package/src/handlers/tool-call-boundary.ts +30 -7
  33. package/src/index.ts +2 -0
  34. package/src/permission-events.ts +6 -0
  35. package/src/permission-prompts.ts +4 -72
  36. package/src/permission-request-id.ts +17 -0
  37. package/src/presentation/dialog-renderer.ts +404 -0
  38. package/src/presentation/forwarded-ask-payload.ts +45 -0
  39. package/src/presentation/legacy-message.ts +117 -0
  40. package/src/presentation/line-fitting.ts +27 -0
  41. package/src/presentation/path-ask-payload.ts +128 -0
  42. package/src/presentation/prompt-payload.ts +137 -0
  43. package/src/presentation/skill-ask-payload.ts +50 -0
  44. package/src/presentation/tool-ask-payload.ts +104 -0
  45. package/src/tool-preview-formatter.ts +1 -1
  46. package/src/types.ts +6 -0
  47. 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 = `${Date.now()}-${Math.random().toString(36).slice(2, 10)}-${process.pid}`;
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: formatForwardedPermissionPrompt(request),
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
- doublePressToConfirm:
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.message,
61
+ details.payload,
63
62
  buildRequestOptions(details),
64
63
  );
65
64
  }