@gotgenes/pi-permission-system 36.2.0 → 37.0.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 +32 -0
- package/README.md +6 -0
- package/docs/configuration.md +17 -15
- package/package.json +5 -5
- 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/exposure/skill-prompt-sanitizer.ts +41 -93
- package/src/exposure/tool-surface-prompt.ts +34 -430
- package/src/handlers/before-agent-start.ts +64 -50
- package/src/logging/command-redaction.ts +15 -9
- package/src/path/expand-home.ts +23 -7
|
@@ -1,16 +1,20 @@
|
|
|
1
1
|
import type {
|
|
2
2
|
BeforeAgentStartEventResult,
|
|
3
3
|
ExtensionContext,
|
|
4
|
+
NormalizedBuildSystemPromptOptions,
|
|
4
5
|
} from "@earendil-works/pi-coding-agent";
|
|
5
6
|
import type { SubagentDetector } from "#src/authority/subagent-detection";
|
|
6
|
-
import {
|
|
7
|
+
import {
|
|
8
|
+
visibleSkillPromptEntries,
|
|
9
|
+
withoutDeniedSkills,
|
|
10
|
+
} from "#src/exposure/skill-prompt-sanitizer";
|
|
7
11
|
import {
|
|
8
12
|
type RegisteredTools,
|
|
9
13
|
readRegisteredTools,
|
|
10
14
|
type ToolRegistry,
|
|
11
15
|
} from "#src/exposure/tool-registry";
|
|
12
16
|
import type { ToolSurfaceObservation } from "#src/exposure/tool-surface-baseline";
|
|
13
|
-
import {
|
|
17
|
+
import { renderToolSurfaceSections } from "#src/exposure/tool-surface-prompt";
|
|
14
18
|
import type { DebugLogger } from "#src/logging/session-logger";
|
|
15
19
|
import type { PermissionResolver } from "#src/policy/permission-resolver";
|
|
16
20
|
import type { PermissionSession } from "#src/session/permission-session";
|
|
@@ -18,22 +22,20 @@ import type { TurnPreparation } from "./session-turn-prep";
|
|
|
18
22
|
|
|
19
23
|
/** Minimal subset of BeforeAgentStartEvent used by this handler. */
|
|
20
24
|
interface BeforeAgentStartPayload {
|
|
21
|
-
|
|
25
|
+
/** The prompt rendered from `systemPromptOptions` as it stands right now. */
|
|
26
|
+
readonly systemPrompt: string;
|
|
22
27
|
/**
|
|
23
|
-
* The parts Pi
|
|
24
|
-
*
|
|
25
|
-
* wrote
|
|
26
|
-
* `
|
|
27
|
-
*
|
|
28
|
-
*
|
|
29
|
-
* `promptGuidelines` carries rules other extensions added, which removing
|
|
30
|
-
* Pi's own rules section would otherwise drop.
|
|
28
|
+
* The mutable parts Pi renders the prompt from; later handlers, and Pi
|
|
29
|
+
* itself, see what this handler writes here. `customPrompt` says whether Pi
|
|
30
|
+
* wrote a preamble at all: under one, it writes no tool surface.
|
|
31
|
+
* `toolSnippets` and `promptGuidelines` are what a child's own tool surface
|
|
32
|
+
* renders from, and `sections` is where it is stated. `skills` is the
|
|
33
|
+
* catalogue Pi renders, which policy narrows.
|
|
31
34
|
*/
|
|
32
|
-
systemPromptOptions
|
|
33
|
-
|
|
34
|
-
toolSnippets
|
|
35
|
-
|
|
36
|
-
};
|
|
35
|
+
systemPromptOptions: Pick<
|
|
36
|
+
NormalizedBuildSystemPromptOptions,
|
|
37
|
+
"customPrompt" | "toolSnippets" | "promptGuidelines" | "sections" | "skills"
|
|
38
|
+
>;
|
|
37
39
|
}
|
|
38
40
|
|
|
39
41
|
/**
|
|
@@ -54,17 +56,16 @@ export function shouldExposeTool(
|
|
|
54
56
|
/**
|
|
55
57
|
* Handles the `before_agent_start` event: tool filtering + prompt sanitization.
|
|
56
58
|
*
|
|
57
|
-
*
|
|
58
|
-
*
|
|
59
|
-
*
|
|
60
|
-
*
|
|
61
|
-
* hit.
|
|
59
|
+
* Every change to the prompt is stated through `event.systemPromptOptions`,
|
|
60
|
+
* never as a returned `systemPrompt`: a returned prompt is frozen for the run,
|
|
61
|
+
* so the sections an extension later in the chain adds (Pi's own
|
|
62
|
+
* `<mcp_servers>` among them) would never reach the provider (#999).
|
|
62
63
|
*
|
|
63
|
-
*
|
|
64
|
-
*
|
|
65
|
-
*
|
|
66
|
-
*
|
|
67
|
-
*
|
|
64
|
+
* Narrowing the active set is enough for a prompt Pi wrote: Pi renders its
|
|
65
|
+
* `<tools>` and `<rules>` from the reconciled active set. A subagent child's
|
|
66
|
+
* prompt is always a custom one, under which Pi writes neither, so a child
|
|
67
|
+
* states its own as `sections`, which Pi places after its `<cwd>` section. A
|
|
68
|
+
* root under a custom prompt states none, matching Pi.
|
|
68
69
|
*
|
|
69
70
|
* Constructor deps:
|
|
70
71
|
* - `turnPrep` — brings the node up to date for the turn before anything reads
|
|
@@ -116,40 +117,53 @@ export class AgentPrepHandler {
|
|
|
116
117
|
});
|
|
117
118
|
}
|
|
118
119
|
|
|
119
|
-
const
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
120
|
+
const options = event.systemPromptOptions;
|
|
121
|
+
if (this.isSubagentUnderCustomPrompt(event, ctx)) {
|
|
122
|
+
const sections = renderToolSurfaceSections({
|
|
123
|
+
allowedTools,
|
|
124
|
+
toolSnippets: options.toolSnippets,
|
|
125
|
+
guidelinesByTool: registered.guidelinesByTool,
|
|
126
|
+
promptGuidelines: options.promptGuidelines,
|
|
127
|
+
});
|
|
128
|
+
// An empty section is one Pi leaves out, so this also clears a stale peer list.
|
|
129
|
+
options.sections.tools = sections.tools ?? "";
|
|
130
|
+
options.sections.rules = sections.rules;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// Path-match entries come from every catalogue the rendered prompt lists,
|
|
134
|
+
// read before the skill list is narrowed.
|
|
135
|
+
this.session.setActiveSkillEntries(
|
|
136
|
+
visibleSkillPromptEntries(
|
|
137
|
+
event.systemPrompt,
|
|
138
|
+
this.resolver,
|
|
139
|
+
agentName,
|
|
140
|
+
this.session.getPathNormalizer(),
|
|
141
|
+
),
|
|
142
|
+
);
|
|
143
|
+
// Denials are judged on the list Pi renders `<skills>` from, not on the
|
|
144
|
+
// rendered prompt: that prompt predates this turn's tool changes, and on
|
|
145
|
+
// the turn `read`/`bash` return from a full denial it lists no catalogue.
|
|
146
|
+
options.skills = withoutDeniedSkills(
|
|
147
|
+
options.skills,
|
|
130
148
|
this.resolver,
|
|
131
149
|
agentName,
|
|
132
|
-
this.session.getPathNormalizer(),
|
|
133
150
|
);
|
|
134
|
-
|
|
135
|
-
return skillPromptResult.prompt !== event.systemPrompt
|
|
136
|
-
? { systemPrompt: skillPromptResult.prompt }
|
|
137
|
-
: {};
|
|
151
|
+
return {};
|
|
138
152
|
}
|
|
139
153
|
|
|
140
154
|
/**
|
|
141
|
-
* Whether this node
|
|
155
|
+
* Whether this node states its own tool surface through `sections`.
|
|
142
156
|
*
|
|
143
|
-
*
|
|
144
|
-
*
|
|
145
|
-
*
|
|
146
|
-
*
|
|
157
|
+
* Only a subagent child does: its prompt is always a custom one, under which
|
|
158
|
+
* Pi writes no tool list or rules, and its inherited identity carries none
|
|
159
|
+
* either. A root needs none — Pi renders its own from the narrowed active
|
|
160
|
+
* set, or, under an operator's custom prompt, deliberately writes none.
|
|
147
161
|
*/
|
|
148
|
-
private
|
|
162
|
+
private isSubagentUnderCustomPrompt(
|
|
149
163
|
event: BeforeAgentStartPayload,
|
|
150
164
|
ctx: ExtensionContext,
|
|
151
165
|
): boolean {
|
|
152
|
-
return
|
|
166
|
+
return hasCustomPrompt(event) && this.detector.isSubagent(ctx);
|
|
153
167
|
}
|
|
154
168
|
|
|
155
169
|
private observeToolSurface(
|
|
@@ -167,5 +181,5 @@ export class AgentPrepHandler {
|
|
|
167
181
|
* test, so an empty string reads here the way it reads there: as none.
|
|
168
182
|
*/
|
|
169
183
|
function hasCustomPrompt(event: BeforeAgentStartPayload): boolean {
|
|
170
|
-
return Boolean(event.systemPromptOptions
|
|
184
|
+
return Boolean(event.systemPromptOptions.customPrompt);
|
|
171
185
|
}
|
|
@@ -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
|
}
|