@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.
@@ -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 { resolveSkillPromptEntries } from "#src/exposure/skill-prompt-sanitizer";
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 { renderToolSurface } from "#src/exposure/tool-surface-prompt";
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
- systemPrompt: string;
25
+ /** The prompt rendered from `systemPromptOptions` as it stands right now. */
26
+ readonly systemPrompt: string;
22
27
  /**
23
- * The parts Pi assembled the prompt from. `toolSnippets` is what lets this
24
- * handler render the session's own tool list instead of editing the one Pi
25
- * wrote — including in a child, whose inherited identity carries none.
26
- * `customPrompt` says whether Pi wrote a preamble at all: under one, it
27
- * writes no tool surface, so there is nothing of Pi's to remove, and a root
28
- * node adds none either.
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
- customPrompt?: string;
34
- toolSnippets?: Record<string, string>;
35
- promptGuidelines?: readonly string[];
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
- * Recomputes the active tool set and the returned system-prompt override on
58
- * every fire (no memoization): the override must be returned each turn so that
59
- * skill filtering is reapplied and the wire prompt stays stable across turns,
60
- * rather than letting Pi reset to its skill-unfiltered base prompt on a cache
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
- * The tool surface is relocated rather than edited in place, so a subagent
64
- * child's inherited identity stays byte-identical to its parent's (#890).
65
- * A root node whose prompt Pi built from a custom one states no tool surface,
66
- * matching Pi, which writes none there; a subagent child's prompt is always a
67
- * custom one, and it still states its own tools.
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 toolSurfacePrompt = this.statesOwnToolSurface(event, ctx)
120
- ? renderToolSurface(event.systemPrompt, {
121
- allowedTools,
122
- toolSnippets: event.systemPromptOptions?.toolSnippets ?? {},
123
- guidelinesByTool: registered.guidelinesByTool,
124
- promptGuidelines: event.systemPromptOptions?.promptGuidelines ?? [],
125
- piAuthoredPreamble: !hasCustomPrompt(event),
126
- })
127
- : event.systemPrompt;
128
- const skillPromptResult = resolveSkillPromptEntries(
129
- toolSurfacePrompt,
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
- this.session.setActiveSkillEntries(skillPromptResult.entries);
135
- return skillPromptResult.prompt !== event.systemPrompt
136
- ? { systemPrompt: skillPromptResult.prompt }
137
- : {};
151
+ return {};
138
152
  }
139
153
 
140
154
  /**
141
- * Whether this node renders its own tool surface into the prompt.
155
+ * Whether this node states its own tool surface through `sections`.
142
156
  *
143
- * Every node does except a root whose prompt Pi built from a custom one:
144
- * there Pi writes no tool list or rules, and the operator's prompt is left
145
- * as Pi built it. A subagent child's prompt is always a custom one, and its
146
- * inherited identity carries no tool list, so it still states its own.
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 statesOwnToolSurface(
162
+ private isSubagentUnderCustomPrompt(
149
163
  event: BeforeAgentStartPayload,
150
164
  ctx: ExtensionContext,
151
165
  ): boolean {
152
- return !hasCustomPrompt(event) || this.detector.isSubagent(ctx);
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?.customPrompt);
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
- * `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
  }