@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.
- package/CHANGELOG.md +26 -0
- package/docs/configuration.md +13 -3
- package/package.json +1 -1
- package/schemas/permissions.schema.json +1 -1
- package/src/access-intent/bash/bash-path-resolver.ts +22 -10
- package/src/access-intent/bash/command-enumeration.ts +48 -20
- package/src/access-intent/bash/node-text.ts +87 -31
- package/src/access-intent/bash/program.ts +8 -2
- package/src/access-intent/bash/shell-variable-expansion.ts +207 -10
- package/src/access-intent/bash/sync-commands.ts +11 -4
- package/src/access-intent/bash/token-collection.ts +66 -45
- package/src/authority/authorizer.ts +4 -0
- package/src/authority/local-user-authorizer.ts +15 -3
- package/src/authority/permission-prompt-component.ts +7 -2
- package/src/config/config-schema.ts +1 -1
- package/src/logging/command-redaction.ts +15 -9
- package/src/path/expand-home.ts +23 -7
- package/src/presentation/prompt-notification.ts +71 -10
|
@@ -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
|
-
* `
|
|
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: `
|
|
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:
|
|
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(
|
|
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(":");
|
package/src/path/expand-home.ts
CHANGED
|
@@ -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
|
-
|
|
33
|
-
|
|
34
|
-
|
|
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
|
-
|
|
37
|
-
|
|
38
|
-
|
|
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
|
|
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
|
-
/**
|
|
7
|
-
|
|
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.
|
|
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
|
-
|
|
71
|
+
notice: PromptNotice,
|
|
19
72
|
): string {
|
|
20
|
-
const
|
|
21
|
-
|
|
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
|
-
|
|
82
|
+
notice: PromptNotice,
|
|
27
83
|
): string {
|
|
28
84
|
switch (channel) {
|
|
29
85
|
case "bell":
|
|
30
86
|
return BEL;
|
|
31
87
|
case "osc9":
|
|
32
|
-
|
|
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
|
-
|
|
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)
|