@gotgenes/pi-permission-system 36.1.0 → 36.2.1

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.
@@ -1,13 +1,11 @@
1
1
  import { inlineShellPayloadNode } from "#src/access-intent/bash/command-enumeration";
2
- import {
3
- ARG_NODE_TYPES,
4
- resolveNodeText,
5
- } from "#src/access-intent/bash/node-text";
2
+ import { ARG_NODE_TYPES, WordReader } from "#src/access-intent/bash/node-text";
6
3
  import {
7
4
  type BashReparser,
8
5
  getWarmBashParser,
9
6
  type TSNode,
10
7
  } from "#src/access-intent/bash/parser";
8
+ import { ShellVariables } from "#src/access-intent/bash/shell-variable-expansion";
11
9
  import { isPlainRecord } from "#src/value-guards";
12
10
  import { isSensitiveName, REDACTED_PLACEHOLDER } from "./log-redaction";
13
11
 
@@ -80,7 +78,7 @@ export function redactCommandSecrets(command: string): string {
80
78
  * carries.
81
79
  *
82
80
  * A payload is re-parsed from its **verbatim inner slice** rather than
83
- * `resolveNodeText`'s shell value: the resolved text concatenates children and
81
+ * `WordReader.text`'s shell value: the resolved text concatenates children and
84
82
  * expands `$HOME`, which destroys the offset correspondence this shift relies
85
83
  * on. Because the slice excludes the payload's quotes, no span recovered from it
86
84
  * can reach one, so the masked payload stays quoted as it was written.
@@ -124,6 +122,14 @@ function collectSpansIn(
124
122
  }
125
123
  }
126
124
 
125
+ /**
126
+ * How the masker reads a word: at the startup values of `HOME` and `PWD`,
127
+ * whatever the command rebinds. What it masks is decided by the *name* a value
128
+ * is bound to, and neither spelling of either variable is a sensitive name, so
129
+ * reading them as rebound would change no span.
130
+ */
131
+ const WORDS = new WordReader(ShellVariables.UNREBOUND);
132
+
127
133
  /**
128
134
  * How many payload layers to descend.
129
135
  *
@@ -175,7 +181,7 @@ function collectInlineShellPayloads(
175
181
  payloads: PayloadSource[],
176
182
  ): void {
177
183
  if (node.type === "command") {
178
- const payload = inlineShellPayloadNode(node);
184
+ const payload = inlineShellPayloadNode(node, WORDS);
179
185
  if (payload) payloads.push(payloadSourceOf(payload));
180
186
  }
181
187
  for (let i = 0; i < node.childCount; i++) {
@@ -191,7 +197,7 @@ function collectInlineShellPayloads(
191
197
  * A `word` payload is the program already. A `string`/`raw_string` wraps it in
192
198
  * one quote pair and an `ansi_c_string` in a `$` plus one quote pair, so each is
193
199
  * a slice at a known offset. Anything else — a `concatenation`, an expansion —
194
- * is stitched: `resolveNodeText` knows how to read its shell value, and that
200
+ * is stitched: `WordReader.text` knows how to read its shell value, and that
195
201
  * value's own offsets describe no span of the command.
196
202
  */
197
203
  function payloadSourceOf(node: TSNode): PayloadSource {
@@ -216,7 +222,7 @@ function payloadSourceOf(node: TSNode): PayloadSource {
216
222
  start: node.startIndex + quoteAt + 1,
217
223
  end: node.endIndex - 1,
218
224
  }
219
- : { kind: "stitched", text: resolveNodeText(node), ...bounds };
225
+ : { kind: "stitched", text: WORDS.text(node), ...bounds };
220
226
  }
221
227
 
222
228
  /**
@@ -318,7 +324,7 @@ const HEADER_FIELD = /^([A-Za-z][A-Za-z0-9_-]*)[ \t]*:[ \t]*\S/;
318
324
 
319
325
  function headerValueSpan(node: TSNode): MaskSpan | null {
320
326
  if (!ARG_NODE_TYPES.has(node.type)) return null;
321
- const match = HEADER_FIELD.exec(resolveNodeText(node));
327
+ const match = HEADER_FIELD.exec(WORDS.text(node));
322
328
  const field = match?.[1];
323
329
  if (!field || !isSensitiveName(field) || isCamelCased(field)) return null;
324
330
  const colon = node.text.indexOf(":");
@@ -29,14 +29,30 @@ const HOME_PREFIXES = ["~", "$HOME", "${HOME}"] as const;
29
29
  * All other patterns are returned unchanged.
30
30
  */
31
31
  export function expandHomePath(pattern: string): string {
32
- for (const prefix of HOME_PREFIXES) {
33
- if (pattern === prefix) return homedir();
34
- if (!pattern.startsWith(prefix)) continue;
32
+ const rest = afterHomePrefix(pattern);
33
+ if (rest === undefined) return pattern;
34
+ return rest === "" ? homedir() : join(homedir(), rest.slice(1));
35
+ }
36
+
37
+ /**
38
+ * Whether {@link expandHomePath} reads `value` as starting at the home
39
+ * directory.
40
+ */
41
+ export function hasHomePrefix(value: string): boolean {
42
+ return afterHomePrefix(value) !== undefined;
43
+ }
35
44
 
36
- const rest = pattern.slice(prefix.length);
37
- if (rest.startsWith("/") || rest.startsWith("\\")) {
38
- return join(homedir(), rest.slice(1));
45
+ /**
46
+ * What follows a recognized home prefix (`""`, or a separator and the rest),
47
+ * or `undefined` when `value` has none.
48
+ */
49
+ function afterHomePrefix(value: string): string | undefined {
50
+ for (const prefix of HOME_PREFIXES) {
51
+ if (!value.startsWith(prefix)) continue;
52
+ const rest = value.slice(prefix.length);
53
+ if (rest === "" || rest.startsWith("/") || rest.startsWith("\\")) {
54
+ return rest;
39
55
  }
40
56
  }
41
- return pattern;
57
+ return undefined;
42
58
  }
@@ -1,41 +1,102 @@
1
+ import { basename } from "node:path";
1
2
  import type { PromptNotificationChannel } from "#src/config/config-schema";
3
+ import type { PromptRequestFacts } from "./prompt-payload";
2
4
 
3
5
  const BEL = "\x07";
4
6
  const OSC = "\x1b]";
5
7
 
6
- /** The OSC 777 title; the body carries the dialog title. */
7
- const OSC_777_TITLE = "pi";
8
+ /** What a prompt notification says: OSC 777 carries both fields. */
9
+ export interface PromptNotice {
10
+ readonly title: string;
11
+ readonly body: string;
12
+ }
13
+
14
+ /** The session facts a notice names, read when the prompt opens. */
15
+ export interface NotificationSession {
16
+ readonly name: string | undefined;
17
+ readonly cwd: string;
18
+ }
19
+
20
+ /** The request facts a notice may name: labels only, never the value. */
21
+ export type NoticeRequestFacts = Pick<
22
+ PromptRequestFacts,
23
+ "toolName" | "surface" | "requester"
24
+ >;
25
+
26
+ /**
27
+ * What a prompt notification says: which session is waiting, and what is
28
+ * being asked and by whom.
29
+ *
30
+ * Labels only. The request's value (the command, path, MCP target, or skill)
31
+ * never appears: the OS notification history is a store this package does not
32
+ * control, outside the owner-only logs and their key-name masking.
33
+ */
34
+ export function describePromptNotice(
35
+ dialogTitle: string,
36
+ request: NoticeRequestFacts,
37
+ session: NotificationSession,
38
+ ): PromptNotice {
39
+ const label = sessionLabel(session);
40
+ const subject = askSubject(request);
41
+ return {
42
+ title: label ? `pi — ${label}` : "pi",
43
+ body: subject ? `${dialogTitle}: ${subject}` : dialogTitle,
44
+ };
45
+ }
46
+
47
+ /** The session's name, or the name of the directory it runs in, as Pi's own terminal title does. */
48
+ function sessionLabel(session: NotificationSession): string {
49
+ // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing -- || intentional: an empty name is unnamed
50
+ return session.name || basename(session.cwd);
51
+ }
52
+
53
+ /** What is asked (the tool, else the gate surface) and by which agent. */
54
+ function askSubject(request: NoticeRequestFacts): string {
55
+ const what = request.toolName ?? request.surface;
56
+ const who = request.requester.agentName;
57
+ if (what && who) return `${what} (${who})`;
58
+ // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing -- || intentional: an empty label names nothing
59
+ return what || who || "";
60
+ }
8
61
 
9
62
  /**
10
63
  * The bytes that ask a terminal for attention as a permission dialog opens.
11
64
  *
12
65
  * One sequence per configured channel, in configured order; an empty list
13
- * renders nothing. The message is stripped of control characters, so it cannot
66
+ * renders nothing. Every field is stripped of control characters, so it cannot
14
67
  * end a sequence early or start another one.
15
68
  */
16
69
  export function renderPromptNotification(
17
70
  channels: readonly PromptNotificationChannel[],
18
- message: string,
71
+ notice: PromptNotice,
19
72
  ): string {
20
- const text = withoutControlCharacters(message);
21
- return channels.map((channel) => renderChannel(channel, text)).join("");
73
+ const clean: PromptNotice = {
74
+ title: withoutControlCharacters(notice.title),
75
+ body: withoutControlCharacters(notice.body),
76
+ };
77
+ return channels.map((channel) => renderChannel(channel, clean)).join("");
22
78
  }
23
79
 
24
80
  function renderChannel(
25
81
  channel: PromptNotificationChannel,
26
- text: string,
82
+ notice: PromptNotice,
27
83
  ): string {
28
84
  switch (channel) {
29
85
  case "bell":
30
86
  return BEL;
31
87
  case "osc9":
32
- return `${OSC}9;${text}${BEL}`;
88
+ // One field, so the title leads it: it is what names the session.
89
+ return `${OSC}9;${notice.title}: ${notice.body}${BEL}`;
33
90
  case "osc777":
34
- // `;` separates OSC 777's fields, so one in the body would split it.
35
- return `${OSC}777;notify;${OSC_777_TITLE};${text.replaceAll(";", ":")}${BEL}`;
91
+ return `${OSC}777;notify;${osc777Field(notice.title)};${osc777Field(notice.body)}${BEL}`;
36
92
  }
37
93
  }
38
94
 
95
+ /** `;` separates OSC 777's fields, so one inside a field would split it. */
96
+ function osc777Field(text: string): string {
97
+ return text.replaceAll(";", ":");
98
+ }
99
+
39
100
  function withoutControlCharacters(value: string): string {
40
101
  // Code points, not grapheme clusters: a control character is always one.
41
102
  return Array.from(value)