@gotgenes/pi-permission-system 32.0.4 → 32.0.6
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 +12 -9
- package/package.json +1 -1
- package/src/authority/authorizer-chain-audit.ts +99 -0
- package/src/authority/authorizer-selection.ts +30 -19
- package/src/exposure/tool-surface-prompt.ts +100 -36
- package/src/handlers/before-agent-start.ts +6 -0
- package/src/index.ts +5 -0
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,32 @@ All notable changes to this project will be documented in this file.
|
|
|
5
5
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
|
6
6
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
7
7
|
|
|
8
|
+
## [32.0.6](https://github.com/gotgenes/pi-packages/compare/pi-permission-system-v32.0.5...pi-permission-system-v32.0.6) (2026-09-16)
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
### Bug Fixes
|
|
12
|
+
|
|
13
|
+
* **pi-permission-system:** stop a removed prompt section from swallowing the prose after it ([af79199](https://github.com/gotgenes/pi-packages/commit/af79199ac42e6b09587bf0cbb256435a176660ab))
|
|
14
|
+
* **pi-permission-system:** keep a custom system prompt's own tool and guideline sections ([7cce96a](https://github.com/gotgenes/pi-packages/commit/7cce96af106d4822799b4728ecf1432526a79ba1))
|
|
15
|
+
* **pi-permission-system:** leave an unedited prompt region byte-identical ([1f1ce66](https://github.com/gotgenes/pi-packages/commit/1f1ce669d6d19d591da1262e89b3c919300b0f75))
|
|
16
|
+
|
|
17
|
+
### Documentation
|
|
18
|
+
|
|
19
|
+
* **pi-permission-system:** record the tool-surface removal boundary ([ebbb8f0](https://github.com/gotgenes/pi-packages/commit/ebbb8f07c77eaa22c45b83e2d43397770dde1054))
|
|
20
|
+
* **pi-permission-system:** record the footerless-prompt edge in the tool-surface pass ([2aa7435](https://github.com/gotgenes/pi-packages/commit/2aa74353864cf648f802a8a63cbe5b1b141c27af))
|
|
21
|
+
|
|
22
|
+
## [32.0.5](https://github.com/gotgenes/pi-packages/compare/pi-permission-system-v32.0.4...pi-permission-system-v32.0.5) (2026-09-16)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
### Bug Fixes
|
|
26
|
+
|
|
27
|
+
* **pi-permission-system:** tell the operator when a configured chain link is missing ([3662541](https://github.com/gotgenes/pi-packages/commit/3662541d4ebc555d54faf2934085d22b36ee618e)), closes [#861](https://github.com/gotgenes/pi-packages/issues/861)
|
|
28
|
+
|
|
29
|
+
### Documentation
|
|
30
|
+
|
|
31
|
+
* **pi-permission-system:** record the unregistered-link warning ([f70b1e3](https://github.com/gotgenes/pi-packages/commit/f70b1e3ce52a38744da2a07880878efb61dfae00)), closes [#861](https://github.com/gotgenes/pi-packages/issues/861)
|
|
32
|
+
* **pi-permission-system:** state the resolved skip in the boundary test comment ([9cd18f1](https://github.com/gotgenes/pi-packages/commit/9cd18f1ef6bd8f9dbabf6fc6ce6b9dfa58d64764)), closes [#861](https://github.com/gotgenes/pi-packages/issues/861)
|
|
33
|
+
|
|
8
34
|
## [32.0.4](https://github.com/gotgenes/pi-packages/compare/pi-permission-system-v32.0.3...pi-permission-system-v32.0.4) (2026-09-16)
|
|
9
35
|
|
|
10
36
|
|
package/docs/configuration.md
CHANGED
|
@@ -226,9 +226,11 @@ Three invariants govern the chain:
|
|
|
226
226
|
|
|
227
227
|
1. **Config order wins, never registration order.**
|
|
228
228
|
The order in `authorizerChain` — not the order extensions happen to register in — fixes the security-relevant chain order.
|
|
229
|
-
2. **A missing link is skipped fail-safe.**
|
|
230
|
-
A name with no registered link is skipped
|
|
229
|
+
2. **A missing link is skipped fail-safe, and you are told.**
|
|
230
|
+
A name with no registered link is skipped; the `ask` still reaches the terminal.
|
|
231
231
|
Absence of a judge means *more* prompting, never less.
|
|
232
|
+
Because you asked for that judge and did not get it, the skip also raises a warning naming the link — once per session per name, beside the per-ask review record.
|
|
233
|
+
Three things leave the identical absence, so the warning names the likeliest and admits the others: the extension providing the link is not loaded in this session (a subagent child's `excludedExtensionPackages` does this), it failed to load, or it declined to register because it has no configuration of its own.
|
|
232
234
|
3. **Registration alone grants no authority.**
|
|
233
235
|
Installing a judge extension gives it nothing; a link decides nothing until you name it here (opt-in activation).
|
|
234
236
|
|
|
@@ -244,12 +246,12 @@ The subagent itself resolves no links (an extension cannot register one in a chi
|
|
|
244
246
|
|
|
245
247
|
Three review-log records make the chain observable, all keyed by the ask's `requestId`:
|
|
246
248
|
|
|
247
|
-
| Record | Meaning
|
|
248
|
-
| ------------------------------------ |
|
|
249
|
-
| `authorizer_chain_resolved` | the links consulted on this ask, recorded before they run — a link that defers otherwise leaves no trace
|
|
250
|
-
| `authorizer_chain_delegated` | the ask came from a relaying subagent node; the named links were deliberately not run here
|
|
251
|
-
| `authorizer_chain_unregistered_link` | a configured name had no registered link — a real misconfiguration; the ask still reaches the terminal
|
|
252
|
-
| `authorizer_link_vacant` | a link was registered on a relaying node, which runs no chain — accepted and recorded, never consulted
|
|
249
|
+
| Record | Meaning |
|
|
250
|
+
| ------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
|
251
|
+
| `authorizer_chain_resolved` | the links consulted on this ask, recorded before they run — a link that defers otherwise leaves no trace |
|
|
252
|
+
| `authorizer_chain_delegated` | the ask came from a relaying subagent node; the named links were deliberately not run here |
|
|
253
|
+
| `authorizer_chain_unregistered_link` | a configured name had no registered link — a real misconfiguration; the ask still reaches the terminal, and the first skip of that name also warns you |
|
|
254
|
+
| `authorizer_link_vacant` | a link was registered on a relaying node, which runs no chain — accepted and recorded, never consulted |
|
|
253
255
|
|
|
254
256
|
Extension authors: register a link from a `permissions:ready` handler via `getPermissionsService(sessionId).registerAuthorizer(name, authorize)`, taking `sessionId` from that event's payload; the callback receives the ask details and a narrow, session-scoped `PermissionQuery` (`checkPermission` / `getToolPermission`) so it can consult the deterministic engine at gate parity.
|
|
255
257
|
Registration returns a disposer, and only one link may hold a given name.
|
|
@@ -1176,7 +1178,8 @@ Additional behaviors:
|
|
|
1176
1178
|
- On the turn a tool is restored, it is callable immediately but its `Available tools:` line reappears one turn later: pi builds the prompt parts an extension receives before the extension runs, so the restored tool has no one-line description to render until it is already active
|
|
1177
1179
|
- A tool is removed only when every value under its surface resolves to `deny`; a surface with any reachable `allow` or `ask` pattern stays available (see [Tool Surfaces](#tool-surfaces))
|
|
1178
1180
|
- The `Available tools:` and `Guidelines:` sections are **relocated** rather than edited in place: the copies pi wrote are removed, and this session's own are rendered at the end of the system prompt, after pi's `Current working directory:` footer.
|
|
1179
|
-
Each session states its own tool surface, which is what keeps a subagent child's inherited prompt byte-identical to its parent's (see [ADR 0014](decisions/0014-tool-surface-is-node-local-prose.md)); the tool list moves to the end of the prompt for every session, whether or not anything is denied
|
|
1181
|
+
Each session states its own tool surface, which is what keeps a subagent child's inherited prompt byte-identical to its parent's (see [ADR 0014](decisions/0014-tool-surface-is-node-local-prose.md)); the tool list moves to the end of the prompt for every session, whether or not anything is denied.
|
|
1182
|
+
Only the copies pi wrote are removed: a custom system prompt (`.pi/SYSTEM.md`, `~/.pi/agent/SYSTEM.md`, `--system-prompt`) keeps its own text untouched, sections and all, because pi writes no tool surface of its own under one — so a prompt that lists tools itself is shown alongside this session's block rather than replaced by it.
|
|
1180
1183
|
- The rendered sections follow pi's own rules: a tool is listed only when pi supplied a one-line description for it, and the guideline bullets are the allowed tools' own contributions around pi's built-in ones
|
|
1181
1184
|
- The prompt is recomputed and returned on every turn but is stable across turns for a stable policy/agent, so the provider's prompt cache (tools + system prefix) is preserved rather than rewritten each turn.
|
|
1182
1185
|
A policy change is an intentional cache transition, as a mid-session agent switch already is.
|
package/package.json
CHANGED
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* authorizer-chain-audit.ts — Report a configured `authorizerChain` link this
|
|
3
|
+
* node's registry could not resolve.
|
|
4
|
+
*
|
|
5
|
+
* Registrations are node-local (ADR 0012 decision 1), and a link produces a
|
|
6
|
+
* *verdict* rather than a fact, so live authority stays converged at the
|
|
7
|
+
* adjudicating node and a link is never inherited from an ancestor. A node that
|
|
8
|
+
* adjudicates locally therefore runs whatever links loaded in it, and skips the
|
|
9
|
+
* rest fail-safe (ADR 0007 §4 invariant 2) — the ask still reaches the
|
|
10
|
+
* terminal. That resolution is deliberate; its silence was not. The operator
|
|
11
|
+
* named a judge in config, did not get it, and the only trace was a line in a
|
|
12
|
+
* JSONL file.
|
|
13
|
+
*
|
|
14
|
+
* The two halves of the alarm fire at different rates on purpose, for two
|
|
15
|
+
* different readers. The review entry is the auditor's durable record and must
|
|
16
|
+
* be complete, so it is written for every skipped ask. The visible warning is
|
|
17
|
+
* for the operator about to answer the prompt the link should have answered, so
|
|
18
|
+
* it is latched: one per configured name, whose count is bounded by
|
|
19
|
+
* `authorizerChain.length`.
|
|
20
|
+
*
|
|
21
|
+
* A relaying node cannot reach this audit at all. `AuthorizerSelection` returns
|
|
22
|
+
* from `linksFor` before resolving anything, recording
|
|
23
|
+
* `authorizer_chain_delegated` instead — its ask is adjudicated one hop up
|
|
24
|
+
* (ADR 0007 §7), where an absent link is the design rather than a
|
|
25
|
+
* misconfiguration.
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
/** The narrow log seam this audit needs (ISP): a durable record and a warning. */
|
|
29
|
+
export interface AuthorizerChainAuditLog {
|
|
30
|
+
review(event: string, details?: Record<string, unknown>): void;
|
|
31
|
+
warn(message: string): void;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** A configured chain link this node's registry could not resolve. */
|
|
35
|
+
export interface UnregisteredLink {
|
|
36
|
+
/** The operator-configured name, exactly as written in `authorizerChain`. */
|
|
37
|
+
name: string;
|
|
38
|
+
/** The ask whose chain resolution skipped it. */
|
|
39
|
+
requestId: string;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** The audit seam `AuthorizerSelection` drives when it skips a name (ISP). */
|
|
43
|
+
export interface UnregisteredLinkAuditor {
|
|
44
|
+
auditUnregisteredLink(link: UnregisteredLink): void;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* The agent-facing text for a configured link that is not registered here.
|
|
49
|
+
*
|
|
50
|
+
* Three causes leave the identical absence — the provider was excluded from
|
|
51
|
+
* this session's extensions, it failed to load, or it declined to register
|
|
52
|
+
* because it has no configuration of its own (the shape
|
|
53
|
+
* `@gotgenes/pi-permission-model-judge` uses to let an operator opt out
|
|
54
|
+
* per project). The message names the likeliest and admits the others, rather
|
|
55
|
+
* than accusing the operator of a contradiction it cannot prove.
|
|
56
|
+
*
|
|
57
|
+
* Every clause is a statement of what already happened. It deliberately does
|
|
58
|
+
* not claim later asks will skip the link too: registration is honored any time
|
|
59
|
+
* before an ask (ADR 0007 §4), so a link may yet arrive.
|
|
60
|
+
*/
|
|
61
|
+
export function unregisteredLinkMessage(name: string): string {
|
|
62
|
+
return (
|
|
63
|
+
`pi-permission-system: authorizerChain names "${name}", but no link with ` +
|
|
64
|
+
"that name is registered in this session, so this ask is being decided " +
|
|
65
|
+
"without it. Most often the extension providing the link is not loaded " +
|
|
66
|
+
"here (a subagent child's excludedExtensionPackages does this); it may " +
|
|
67
|
+
"also have failed to load, or declined to register because it has no " +
|
|
68
|
+
"configuration of its own. Every skipped ask is recorded in the " +
|
|
69
|
+
"permission review log as authorizer_chain_unregistered_link."
|
|
70
|
+
);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Records each skipped chain link, and warns the operator once per name.
|
|
75
|
+
*
|
|
76
|
+
* The latch is a plain field with no re-arm hook, because the extension factory
|
|
77
|
+
* is re-invoked per session generation — a `/new`, `/resume`, `/fork`, or
|
|
78
|
+
* `/import` switch builds a fresh audit. A `session_start` with
|
|
79
|
+
* `reason: "reload"` reuses this instance and deliberately does not re-warn for
|
|
80
|
+
* a name already reported; a name newly added to `authorizerChain` by that same
|
|
81
|
+
* reload has no entry yet, so it warns on its first skip.
|
|
82
|
+
*/
|
|
83
|
+
export class AuthorizerChainAudit implements UnregisteredLinkAuditor {
|
|
84
|
+
private readonly warned = new Set<string>();
|
|
85
|
+
|
|
86
|
+
constructor(private readonly log: AuthorizerChainAuditLog) {}
|
|
87
|
+
|
|
88
|
+
auditUnregisteredLink(link: UnregisteredLink): void {
|
|
89
|
+
this.log.review("authorizer_chain_unregistered_link", {
|
|
90
|
+
requestId: link.requestId,
|
|
91
|
+
name: link.name,
|
|
92
|
+
});
|
|
93
|
+
if (this.warned.has(link.name)) {
|
|
94
|
+
return;
|
|
95
|
+
}
|
|
96
|
+
this.warned.add(link.name);
|
|
97
|
+
this.log.warn(unregisteredLinkMessage(link.name));
|
|
98
|
+
}
|
|
99
|
+
}
|
|
@@ -7,6 +7,7 @@ import {
|
|
|
7
7
|
selectAuthorizer,
|
|
8
8
|
} from "./authorizer";
|
|
9
9
|
import { composeAuthorizerChain } from "./authorizer-chain";
|
|
10
|
+
import type { UnregisteredLinkAuditor } from "./authorizer-chain-audit";
|
|
10
11
|
import type { AuthorizerLookup } from "./authorizer-registry";
|
|
11
12
|
import { encloseInDelegationEnvelope } from "./delegation-envelope";
|
|
12
13
|
import type { PermissionPromptDecision } from "./permission-dialog";
|
|
@@ -61,6 +62,28 @@ export interface AdjudicationRole {
|
|
|
61
62
|
adjudicatesLocally(): boolean;
|
|
62
63
|
}
|
|
63
64
|
|
|
65
|
+
/**
|
|
66
|
+
* Everything {@link AuthorizerSelection} is constructed with: the
|
|
67
|
+
* {@link AuthorizerSelectionDeps} `selectAuthorizer` itself needs, plus the
|
|
68
|
+
* collaborators only the class uses to resolve and run the chain.
|
|
69
|
+
*
|
|
70
|
+
* Named rather than left anonymous on the constructor because the test
|
|
71
|
+
* fixtures mirror it: an addition here is otherwise an addition in two places.
|
|
72
|
+
* `selectAuthorizer` keeps the narrower parameter type (ISP) — it resolves no
|
|
73
|
+
* links and must not see the chain collaborators.
|
|
74
|
+
*/
|
|
75
|
+
export type AuthorizerSelectionConstructorDeps = AuthorizerSelectionDeps & {
|
|
76
|
+
prompter: PermissionPrompterApi;
|
|
77
|
+
/** The session-scoped query injected into each chain link (ADR 0007 §3). */
|
|
78
|
+
getPermissionQuery: () => PermissionQuery;
|
|
79
|
+
/** Read-only lookup of registered links by name. */
|
|
80
|
+
authorizerRegistry: AuthorizerLookup;
|
|
81
|
+
/** The operator's configured link names, read live per ask. */
|
|
82
|
+
getAuthorizerChain: () => string[];
|
|
83
|
+
/** Told about each configured name the registry could not resolve. */
|
|
84
|
+
chainAudit: UnregisteredLinkAuditor;
|
|
85
|
+
};
|
|
86
|
+
|
|
64
87
|
/**
|
|
65
88
|
* Context-owning selection root for the Authorizer spine.
|
|
66
89
|
*
|
|
@@ -79,17 +102,7 @@ export class AuthorizerSelection
|
|
|
79
102
|
private authority: SelectedAuthority | null = null;
|
|
80
103
|
private relayTarget: PermissionForwardingTarget | null = null;
|
|
81
104
|
|
|
82
|
-
constructor(
|
|
83
|
-
private readonly deps: AuthorizerSelectionDeps & {
|
|
84
|
-
prompter: PermissionPrompterApi;
|
|
85
|
-
/** The session-scoped query injected into each chain link (ADR 0007 §3). */
|
|
86
|
-
getPermissionQuery: () => PermissionQuery;
|
|
87
|
-
/** Read-only lookup of registered links by name. */
|
|
88
|
-
authorizerRegistry: AuthorizerLookup;
|
|
89
|
-
/** The operator's configured link names, read live per ask. */
|
|
90
|
-
getAuthorizerChain: () => string[];
|
|
91
|
-
},
|
|
92
|
-
) {}
|
|
105
|
+
constructor(private readonly deps: AuthorizerSelectionConstructorDeps) {}
|
|
93
106
|
|
|
94
107
|
/**
|
|
95
108
|
* Select the live authority for `ctx` and store it. The non-terminal
|
|
@@ -165,10 +178,11 @@ export class AuthorizerSelection
|
|
|
165
178
|
|
|
166
179
|
/**
|
|
167
180
|
* Resolve the operator's `authorizerChain` names to registered links, in
|
|
168
|
-
* config order (ADR 0007 invariant 1). An unregistered name is skipped
|
|
169
|
-
*
|
|
170
|
-
*
|
|
171
|
-
*
|
|
181
|
+
* config order (ADR 0007 invariant 1). An unregistered name is skipped
|
|
182
|
+
* fail-safe (invariant 2 — more prompting, never less) and handed to the
|
|
183
|
+
* chain audit, which records it and tells the operator once per name; each
|
|
184
|
+
* resolved link is wrapped in the bounded-delegation envelope so an `allow`
|
|
185
|
+
* on an excluded surface cannot exceed the operator's policy.
|
|
172
186
|
*
|
|
173
187
|
* The resolved names are recorded against the ask before any link runs — a
|
|
174
188
|
* link that defers decides nothing and would otherwise leave no evidence it
|
|
@@ -184,10 +198,7 @@ export class AuthorizerSelection
|
|
|
184
198
|
for (const name of configured) {
|
|
185
199
|
const authorize = this.deps.authorizerRegistry.get(name);
|
|
186
200
|
if (authorize === undefined) {
|
|
187
|
-
this.deps.
|
|
188
|
-
requestId,
|
|
189
|
-
name,
|
|
190
|
-
});
|
|
201
|
+
this.deps.chainAudit.auditUnregisteredLink({ requestId, name });
|
|
191
202
|
continue;
|
|
192
203
|
}
|
|
193
204
|
resolved.push(name);
|
|
@@ -10,9 +10,17 @@
|
|
|
10
10
|
* differs from its parent's (#890).
|
|
11
11
|
*
|
|
12
12
|
* So the region is *relocated* rather than narrowed: the sections Pi wrote are
|
|
13
|
-
* removed
|
|
14
|
-
*
|
|
15
|
-
*
|
|
13
|
+
* removed, and this node's own are rendered at the end of the prompt, past
|
|
14
|
+
* everything a child inherits. Each session then states its own tool surface
|
|
15
|
+
* and no session edits another's bytes.
|
|
16
|
+
*
|
|
17
|
+
* Removal is bounded to the text this package or Pi wrote. The prompt is split
|
|
18
|
+
* at Pi's `Current working directory:` footer, which it writes last and
|
|
19
|
+
* unconditionally: everything after it was appended by an extension, and
|
|
20
|
+
* everything before it is Pi's own preamble only when Pi did not build the
|
|
21
|
+
* prompt from a `customPrompt`. Under a custom prompt Pi writes no tool
|
|
22
|
+
* surface at all, so a section matched above the footer is a user's or another
|
|
23
|
+
* extension's — removing it destroyed their text (#919, #932).
|
|
16
24
|
*
|
|
17
25
|
* Rendering follows `buildSystemPrompt`'s own rules — a tool is listed only
|
|
18
26
|
* when it has a snippet, and the guideline bullets are the allowed tools' own
|
|
@@ -28,6 +36,15 @@ export interface ToolSurfaceInputs {
|
|
|
28
36
|
readonly toolSnippets: Readonly<Record<string, string>>;
|
|
29
37
|
/** Guideline bullets each tool contributes, keyed by tool name. */
|
|
30
38
|
readonly guidelinesByTool: ReadonlyMap<string, readonly string[]>;
|
|
39
|
+
/**
|
|
40
|
+
* Whether Pi wrote the prompt's preamble itself.
|
|
41
|
+
*
|
|
42
|
+
* False when Pi assembled the prompt from `customPrompt` — a user's
|
|
43
|
+
* SYSTEM.md, or a subagent child's assembled prompt — in which case Pi wrote
|
|
44
|
+
* no tool-surface sections and every line above its footer belongs to
|
|
45
|
+
* somebody else.
|
|
46
|
+
*/
|
|
47
|
+
readonly piAuthoredPreamble: boolean;
|
|
31
48
|
}
|
|
32
49
|
|
|
33
50
|
type LineSection = {
|
|
@@ -46,6 +63,18 @@ const GUIDELINES_SECTION_HEADER = "Guidelines:";
|
|
|
46
63
|
*/
|
|
47
64
|
const CUSTOM_TOOLS_FILLER_PREFIX = "In addition to the tools above";
|
|
48
65
|
|
|
66
|
+
/** What Pi writes under `Available tools:` when no selected tool has a snippet. */
|
|
67
|
+
const EMPTY_LIST_PLACEHOLDER = "(none)";
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* The first line of the footer Pi writes last, in both of its branches.
|
|
71
|
+
*
|
|
72
|
+
* It is the boundary between what Pi assembled and what extensions appended
|
|
73
|
+
* after it — the same anchor `@gotgenes/pi-subagents` uses to find Pi's
|
|
74
|
+
* session-resolved tail.
|
|
75
|
+
*/
|
|
76
|
+
const PROMPT_FOOTER_PREFIX = "Current working directory: ";
|
|
77
|
+
|
|
49
78
|
/** Pi's two unconditional guideline bullets, in the order it writes them. */
|
|
50
79
|
const UNIVERSAL_GUIDELINES: readonly string[] = [
|
|
51
80
|
"Be concise in your responses",
|
|
@@ -63,23 +92,69 @@ export function renderToolSurface(
|
|
|
63
92
|
systemPrompt: string,
|
|
64
93
|
inputs: ToolSurfaceInputs,
|
|
65
94
|
): string {
|
|
66
|
-
const lines =
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
95
|
+
const lines = normalizePrompt(systemPrompt).split("\n");
|
|
96
|
+
const tailStart = extensionTailStart(lines);
|
|
97
|
+
const body = [
|
|
98
|
+
settleRegion(lines.slice(0, tailStart), inputs.piAuthoredPreamble),
|
|
99
|
+
settleRegion(lines.slice(tailStart), true),
|
|
100
|
+
]
|
|
101
|
+
.filter((region) => region.length > 0)
|
|
102
|
+
.join("\n")
|
|
103
|
+
.trimEnd();
|
|
70
104
|
const block = renderToolSurfaceBlock(inputs);
|
|
71
105
|
|
|
72
106
|
return body.length > 0 ? `${body}\n\n${block}` : block;
|
|
73
107
|
}
|
|
74
108
|
|
|
109
|
+
/**
|
|
110
|
+
* Where the text extensions appended begins: the line after Pi's footer, or
|
|
111
|
+
* the end of the prompt when nothing downstream left one.
|
|
112
|
+
*
|
|
113
|
+
* The last footer is Pi's own — it appends one after everything it assembled,
|
|
114
|
+
* so a line of the same shape in a custom prompt is always above it.
|
|
115
|
+
*
|
|
116
|
+
* Accepted edge: a prompt carrying no footer at all is treated as all head, so
|
|
117
|
+
* a block appended to *that* prompt cannot be found and replaced, and a custom
|
|
118
|
+
* preamble would collect a second one. Pi writes the footer last and in both
|
|
119
|
+
* branches, so reaching this needs a downstream rewrite of Pi's whole output —
|
|
120
|
+
* which has already broken `@gotgenes/pi-subagents`' identity anchor, since it
|
|
121
|
+
* reads the same line.
|
|
122
|
+
*/
|
|
123
|
+
function extensionTailStart(lines: readonly string[]): number {
|
|
124
|
+
const footerAt = lines.findLastIndex((line) =>
|
|
125
|
+
line.startsWith(PROMPT_FOOTER_PREFIX),
|
|
126
|
+
);
|
|
127
|
+
return footerAt === -1 ? lines.length : footerAt + 1;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* One region's surviving text: its sections removed, when they are ours to
|
|
132
|
+
* remove.
|
|
133
|
+
*
|
|
134
|
+
* Blank runs are collapsed only where a removal opened one, so a region this
|
|
135
|
+
* pass took nothing out of is returned exactly as it arrived rather than
|
|
136
|
+
* reflowed by a pass that had nothing to do with it.
|
|
137
|
+
*/
|
|
138
|
+
function settleRegion(
|
|
139
|
+
lines: readonly string[],
|
|
140
|
+
removalAllowed: boolean,
|
|
141
|
+
): string {
|
|
142
|
+
if (!removalAllowed) {
|
|
143
|
+
return lines.join("\n");
|
|
144
|
+
}
|
|
145
|
+
const kept = removeToolSurfaceSections(lines);
|
|
146
|
+
const text = kept.join("\n");
|
|
147
|
+
return kept.length === lines.length ? text : collapseExtraBlankLines(text);
|
|
148
|
+
}
|
|
149
|
+
|
|
75
150
|
/**
|
|
76
151
|
* Remove the `Available tools:` and `Guidelines:` sections, and the filler
|
|
77
|
-
* sentence between them.
|
|
152
|
+
* sentence between them, from one region.
|
|
78
153
|
*
|
|
79
154
|
* Each section is located by its own header, so the two are removed whether
|
|
80
|
-
* they sit adjacent in Pi's preamble or alone in
|
|
81
|
-
*
|
|
82
|
-
*
|
|
155
|
+
* they sit adjacent in Pi's preamble or alone in the tail — including a block
|
|
156
|
+
* this function already produced, which is what makes it safe to apply to its
|
|
157
|
+
* own output, and what keeps it order-independent with a second writer.
|
|
83
158
|
*/
|
|
84
159
|
function removeToolSurfaceSections(lines: readonly string[]): string[] {
|
|
85
160
|
let remaining = [...lines];
|
|
@@ -209,21 +284,26 @@ function collapseExtraBlankLines(text: string): string {
|
|
|
209
284
|
return text.replace(/\n{3,}/g, "\n\n").trimEnd();
|
|
210
285
|
}
|
|
211
286
|
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
287
|
+
/**
|
|
288
|
+
* Whether the line belongs to the body of the section above it.
|
|
289
|
+
*
|
|
290
|
+
* Pi writes a section as its header, then bullets — or the `(none)` placeholder
|
|
291
|
+
* when the list is empty — and separates it from what follows with a blank
|
|
292
|
+
* line. Anything else is already outside the section, however it is punctuated:
|
|
293
|
+
* a section that ran on to "the next line ending in a colon" swallowed the
|
|
294
|
+
* prose in between, which is somebody else's text whenever the match was not
|
|
295
|
+
* Pi's own (#919, #932).
|
|
296
|
+
*/
|
|
219
297
|
function isSectionBodyLine(line: string): boolean {
|
|
220
298
|
const trimmed = line.trim();
|
|
221
299
|
if (trimmed.length === 0) return true; // blank line
|
|
222
300
|
if (trimmed.startsWith("- ")) return true; // bullet
|
|
301
|
+
if (trimmed === EMPTY_LIST_PLACEHOLDER) return true; // Pi's empty list
|
|
223
302
|
if (line !== line.trimStart()) return true; // indented
|
|
224
303
|
return false;
|
|
225
304
|
}
|
|
226
305
|
|
|
306
|
+
/** The header line plus its own body, or `null` when the header is absent. */
|
|
227
307
|
function findSection(
|
|
228
308
|
lines: readonly string[],
|
|
229
309
|
header: string,
|
|
@@ -233,25 +313,9 @@ function findSection(
|
|
|
233
313
|
return null;
|
|
234
314
|
}
|
|
235
315
|
|
|
236
|
-
// If a subsequent recognised section header exists, use it as the boundary.
|
|
237
|
-
// This preserves the original behaviour for the common case where sections
|
|
238
|
-
// are adjacent (e.g. "Available tools:" followed by "Guidelines:") and
|
|
239
|
-
// ensures any prose continuation between the two headers is also removed.
|
|
240
|
-
for (let index = start + 1; index < lines.length; index += 1) {
|
|
241
|
-
if (isTopLevelSectionHeader(lines[index])) {
|
|
242
|
-
return { start, end: index };
|
|
243
|
-
}
|
|
244
|
-
}
|
|
245
|
-
|
|
246
|
-
// No subsequent section header — stop at the first non-body line so that
|
|
247
|
-
// content after the section (e.g. custom user notes) is not silently deleted.
|
|
248
316
|
let end = start + 1;
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
end = index;
|
|
252
|
-
break;
|
|
253
|
-
}
|
|
254
|
-
end = index + 1;
|
|
317
|
+
while (end < lines.length && isSectionBodyLine(lines[end])) {
|
|
318
|
+
end += 1;
|
|
255
319
|
}
|
|
256
320
|
|
|
257
321
|
return { start, end };
|
|
@@ -22,8 +22,11 @@ interface BeforeAgentStartPayload {
|
|
|
22
22
|
* The parts Pi assembled the prompt from. `toolSnippets` is what lets this
|
|
23
23
|
* handler render the session's own tool list instead of editing the one Pi
|
|
24
24
|
* wrote — including in a child, whose inherited identity carries none.
|
|
25
|
+
* `customPrompt` says whether Pi wrote a preamble at all: under one, it
|
|
26
|
+
* writes no tool surface, so there is nothing of Pi's to remove.
|
|
25
27
|
*/
|
|
26
28
|
systemPromptOptions?: {
|
|
29
|
+
customPrompt?: string;
|
|
27
30
|
toolSnippets?: Record<string, string>;
|
|
28
31
|
};
|
|
29
32
|
}
|
|
@@ -106,6 +109,9 @@ export class AgentPrepHandler {
|
|
|
106
109
|
allowedTools,
|
|
107
110
|
toolSnippets: event.systemPromptOptions?.toolSnippets ?? {},
|
|
108
111
|
guidelinesByTool: registered.guidelinesByTool,
|
|
112
|
+
// Pi's own `if (customPrompt)` test, so an empty string reads here the
|
|
113
|
+
// way it reads there: as no custom prompt at all.
|
|
114
|
+
piAuthoredPreamble: !event.systemPromptOptions?.customPrompt,
|
|
109
115
|
});
|
|
110
116
|
const skillPromptResult = resolveSkillPromptEntries(
|
|
111
117
|
toolSurfacePrompt,
|
package/src/index.ts
CHANGED
|
@@ -2,6 +2,7 @@ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
|
2
2
|
import { getAgentDir, getPackageDir } from "@earendil-works/pi-coding-agent";
|
|
3
3
|
import { warmBashParser } from "#src/access-intent/bash/parser";
|
|
4
4
|
import { buildResolvedIntentFromMatchValues } from "#src/access-intent/input-normalizer";
|
|
5
|
+
import { AuthorizerChainAudit } from "#src/authority/authorizer-chain-audit";
|
|
5
6
|
import {
|
|
6
7
|
AuthorizerRegistry,
|
|
7
8
|
ObservedAuthorizerRegistrar,
|
|
@@ -168,6 +169,10 @@ export default function piPermissionSystemExtension(pi: ExtensionAPI): void {
|
|
|
168
169
|
// resolved in config order at activation.
|
|
169
170
|
authorizerRegistry,
|
|
170
171
|
getAuthorizerChain: () => configStore.current().authorizerChain ?? [],
|
|
172
|
+
// Records each configured name this node could not resolve, and tells the
|
|
173
|
+
// operator once per name — the ask is decided without the judge they
|
|
174
|
+
// asked for, and the review log alone never said so (#861).
|
|
175
|
+
chainAudit: new AuthorizerChainAudit(logger),
|
|
171
176
|
});
|
|
172
177
|
|
|
173
178
|
// Resolver composes the manager + session ruleset and owns the
|