@signalridge/pi-approvals 0.0.0-stage → 1.2.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 ADDED
@@ -0,0 +1,5 @@
1
+ # Changelog
2
+
3
+ ## 1.2.0
4
+
5
+ Initial opt-in experimental human approval broker, with explicit pending command reviews, native RPC confirmations, and session-owned fail-closed cleanup.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 signalridge
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,41 @@
1
- # Temporary Holding Version
1
+ # @signalridge/pi-approvals
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ **Opt-in experimental extension**, tested with Pi **1.0.4**. Loading it explicitly installs a session-scoped human approval broker. Root canonical native `edit`, `write`, `bash`, and public `user_bash` execution additionally require the human's explicit `--approvals-native-tools` certification. It does not enable itself or write global configuration. The independent `@signalridge/pi-approval-core` library owns the version-1 contract and reviewed factories.
4
+
5
+ ## Human workflow
6
+
7
+ Proposed operations wait with an owned footer count. They do **not** open unsolicited dialogs, replace the editor, steal native/foreign dialog focus, or automatically ask a parent model for clarification.
8
+
9
+ - `/approvals` or `/approvals list` lists pending IDs, source labels, and operation kinds.
10
+ - `/approvals review <id>` or `/approvals approve <id>` opens a full bounded review and a human decision. Typing `approve <id>` without the command/confirmation does not authorize anything.
11
+ - `/approvals deny <id>` closes exactly that request, including while a review/foreign dialog is open; denial opens no UI and never grants another request.
12
+
13
+ Commands run while tools wait; no `waitForIdle` is used. In TUI mode a full-terminal public overlay shows the escaped full diff or exact command/CWD/source. Header/controls/body fit terminal bounds; narrow terminals use a fully scrollable document fallback, including at one column. **Ctrl+A** approves that one review; **Esc** denies; Up/Down/PgUp/PgDn/Home/End navigate the review. Enter does not approve. There is no automatic next review, so repeated approval keys cannot approve subsequent requests. A currently observed public native/extension dialog must close before a review opens. A synchronous, short-lived non-capturing public overlay bridge borrows the TUI, completing only after its native admission; the review then owns a raw overlay and closes only its identity handle, never the native pop-last callback. Later capturing and nonCapturing overlays therefore survive cancellation, denial, expiry and retirement: the cancelled review closes and releases its review lock without waiting for a foreign dialog to close. The foreign dialog retains focus and remains respondable. An unfocused review renders no rows, so native confirm/select/input questions are not obscured; focus transitions request a redraw and native focus restoration may safely resume the same review. Affirmative completion still requires the review's own focus. Normal editor draft/focus restoration belongs to the public custom-UI host contract; this extension never writes editor text.
14
+
15
+ Reviews use lossless hard wrapping, never word wrapping that discards a separator. Over-wide or zero-column grapheme clusters and Thai/Lao AM characters rewritten by native output normalization are escaped by code point. The compact fallback additionally exposes preview whitespace as `\u{hex}` (including newlines) and doubles literal preview backslashes, so spaces, data newlines, soft wrapping and literal escape text remain distinguishable. The scrollable header labels this preview representation; operation bytes and hashes are unchanged. Layout is cached across scrolling, and invalidated on width or compact-mode changes. Regression coverage includes actual native terminal writes after normalization, not only pre-normalization composed snapshots.
16
+
17
+ RPC uses the public native `confirm` transport when the human invokes the command. The client must display the full review and reply to the fresh `extension_ui_request.id` using `extension_ui_response`; lifecycle approval IDs and model text are not transport nonces. RPC clients must preserve human-only response ownership. A dialog timeout denies. Print/JSON/no-UI modes deny immediately, before native filesystem/process mutations.
18
+
19
+ Each approval is per-operation. There is no allow-all mode or permission cache, no response tool, and no background conversion of built-in bash. Pending operations are immutable; the broker caps pending requests at 32, retains at most 128 metadata-only resolutions, and expires requests after five minutes. Caller cancellation, committed branch/session divergence, reload/invalidation, missing UI, callback errors, and peer retirement fail closed. Native rename/custom-entry appends and parallel results preserve append affinity using full branch-entry fingerprints. Vetoed navigation and aborted summaries do not retire an unchanged session. Committed `session_start` and `session_tree` bindings install fresh brokers and wrapper closures; captured old services remain permanently retired. Neither cancellation nor denial resolves another request.
20
+
21
+ ## Coverage and ownership
22
+
23
+ A loaded extension always advertises one typed service, including before startup and after unavailable/collision/retirement failures; discovery reports no peer only when the extension is absent. Discovery checks broker liveness, including canonical binding, session/branch and UI availability, even when no request is pending. On retirement or liveness loss it advertises a distinct, present rejecting service, so previously captured post-approval launch guards fail permanently instead of authorizing work after asynchronous startup. An unavailable service never invites ungated fallback or reconnects an old capture. `/approvals` reports the root policy to the human.
24
+
25
+ **Root certification is required:** `--approvals-native-tools` is a public boolean CLI flag, default **false**. The human certifies that canonical backends are the host's standard local native Pi backends. Without it, root canonical calls and user bash deny clearly, before process/filesystem mutation, and original backends are **not replaced**. The human service can still review independently integrated child/background operations that construct their own known native factories. The model cannot enable this policy through a tool.
26
+
27
+ With certification, committed session bindings install reviewed definitions only for available admitted canonical tools. Missing/excluded tools are stronger host admission, never activated by this extension. Known selected extension/custom canonical collisions are rejected, not replaced. The exact current stricter active loadout is restored after late registration, including SDK initial allowlists and earlier Plan deactivations. Each canonical call must have a captured reviewed binding of that exact name with matching effective schema/description/source metadata; an empty or partial initial binding never admits a later canonical registration. Such arrivals are blocked, not silently rebound or added to this extension's loadout. Subsequent ownership loss of captured bindings blocks calls and retires requests. Each invocation also rechecks the exact tool's live active admission through post-approval IO, so a later Plan restriction withdraws pending ordinary writes without another approval or restoring excluded tools.
28
+
29
+ **Public-SDK limitation:** Pi 1.0.4 labels `AgentSession.baseToolsOverride` backends `builtin:<name>`, even when their operations are custom, rejecting, or remote. Public metadata cannot certify backend provenance. The flag is explicit human certification, **not metadata proof**. SDK hosts with overridden backends must omit it and supply an explicit known-backend reviewed integration. Never use this flag to silently substitute unknown custom operations with local operations.
30
+
31
+ Reviewed bash uses the core's **owned POSIX backend**, not SDK local-backend delegation. The native SDK still owns tool output/truncation. Ownership is rechecked after asynchronous CWD preparation before supervisor spawn and again immediately before command IPC dispatch; a short-lived process-group anchor/IPC reaper owns cancellation, timeout, descendant cleanup and host teardown without changing host signal behavior. Background group members cannot outlive the command. POSIX standalone Node.js with public SEA detection is required: Windows, Bun/compiled Pi, Node SEA applications and unknown interpreter capabilities are refused before approval or subprocess startup. Processes that deliberately escape the group are not sandboxed. See the core README for lifecycle qualifications; no post-dispatch rollback is promised.
32
+
33
+ ## Not a security boundary
34
+
35
+ Approval is not an OS sandbox. Extensions and local event-bus callbacks are trusted same-process code. Command/mutation-shaped noncanonical calls (own `command`, or `path` plus content/edit properties, including one `input` envelope) and tools declaring these known schema properties even when optional are denied unless exactly one synchronous cooperative adapter advertises the current effective schema by object identity on `signalridge:approvals:adapter:v1`. See the core contract for validators and response shape. No source-name heuristic authorizes aliases; older command adapters without the capability are blocked. Unrelated metadata tools are not blanket-blocked.
36
+
37
+ The root public hook has no post-handler check. Known schema properties are conservatively classified before later normalizers run; arbitrary unknown properties, schema compositions/references and arbitrary extension semantics are **not** a sandbox. This bounded policy does **not** detect arbitrary evaluation/execution in tools without known command/mutation shapes; those tools, child runtimes, MCP tools, and external processes need separately restricted admission or explicit integrations. Cooperative peers are trusted to gate their final backend operation and to attest only while their own context/binding remains live. Captured services fail closed; absence is not permission to bypass a previously loaded service.
38
+
39
+ File reviews retain native edit BOM/CRLF/fuzzy/original-batch semantics. Limits: 256 KiB files/input, 64 KiB review, no clipped approval. Before synchronous diff generation, before/after together must have at most 2,048 line tokens counted by `split("\n").length` (including empty/trailing tokens); larger files fail without requesting approval or writing even for small edits. Invalid UTF-8/binary files and target symlink/hardlink aliases are rejected; display controls are explicitly escaped, actual byte hashes are complete. Stable ancestor symlinks are permitted with identity/realpath rechecks. Queue and post-review checks are not OS-atomic against external writers/aliases/processes. Approved write directory creation may remain after a subsequent failure/cancellation; no rollback is promised.
40
+
41
+ Full reviewed changes/commands can contain secrets. The broker shows them only in human reviews; its pending notices, status, errors, and lifecycle metadata do not echo proposed credentials. Native SDK tool output/error behavior is retained. Loading third-party extensions remains a trust decision.
package/package.json CHANGED
@@ -1,6 +1,35 @@
1
1
  {
2
2
  "name": "@signalridge/pi-approvals",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "1.2.0",
4
+ "description": "Opt-in experimental human approval broker for canonical native Pi mutations and bash.",
5
+ "type": "module",
6
+ "license": "MIT",
7
+ "private": false,
8
+ "keywords": ["pi-package", "pi-extension", "pi", "approvals"],
9
+ "pi": { "extensions": ["./src/index.ts"] },
10
+ "piExtension": { "lifecycle": "experimental" },
11
+ "files": ["README.md", "LICENSE", "CHANGELOG.md", "src"],
12
+ "scripts": {
13
+ "lint": "biome check src/ test/ vitest.config.ts --error-on-warnings",
14
+ "format": "biome check --write src/ test/ vitest.config.ts",
15
+ "typecheck": "tsc --noEmit",
16
+ "test": "vitest run --config vitest.config.ts",
17
+ "check": "bun run lint && bun run typecheck && bun run test"
18
+ },
19
+ "dependencies": { "@signalridge/pi-approval-core": "^1.2.0" },
20
+ "peerDependencies": { "@earendil-works/pi-coding-agent": "*", "@earendil-works/pi-tui": "*", "typebox": "*" },
21
+ "devDependencies": {
22
+ "@earendil-works/pi-coding-agent": "1.0.4",
23
+ "@earendil-works/pi-ai": "1.0.4",
24
+ "@earendil-works/pi-tui": "1.0.4",
25
+ "typebox": "1.3.27",
26
+ "@biomejs/biome": "^2.5.7",
27
+ "@types/node": "^24.10.0",
28
+ "typescript": "^6.0.0",
29
+ "vitest": "^4.1.11"
30
+ },
31
+ "repository": { "type": "git", "url": "https://github.com/signalridge/pi-extensions", "directory": "packages/pi-approvals" },
32
+ "homepage": "https://github.com/signalridge/pi-extensions/tree/main/packages/pi-approvals",
33
+ "bugs": { "url": "https://github.com/signalridge/pi-extensions/issues" },
34
+ "publishConfig": { "access": "public" }
35
+ }
package/src/broker.ts ADDED
@@ -0,0 +1,236 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
3
+ import {
4
+ APPROVAL_LIFECYCLE_CHANNEL,
5
+ type ApprovalEvents,
6
+ type ApprovalLifecycle,
7
+ type ApprovalOperation,
8
+ type ApprovalOutcome,
9
+ type ApprovalRequestOptions,
10
+ type ApprovalService,
11
+ assertCurrent,
12
+ validateLifecycle,
13
+ validateOperation,
14
+ } from "@signalridge/pi-approval-core";
15
+
16
+ export const MAX_PENDING = 32;
17
+ export const MAX_RETAINED = 128;
18
+ export const REQUEST_TIMEOUT_MS = 5 * 60 * 1000;
19
+ interface Pending {
20
+ readonly id: string;
21
+ readonly operation: ApprovalOperation;
22
+ readonly controller: AbortController;
23
+ readonly check: () => void;
24
+ readonly finish: (outcome: ApprovalOutcome) => void;
25
+ reviewing: boolean;
26
+ }
27
+ function fingerprint(ctx: ExtensionContext): readonly string[] {
28
+ return ctx.sessionManager.getBranch().map((entry) => JSON.stringify(entry));
29
+ }
30
+ function extendsBranch(before: readonly string[], after: readonly string[]): boolean {
31
+ return before.length <= after.length && before.every((entry, index) => entry === after[index]);
32
+ }
33
+ export class ApprovalBroker {
34
+ readonly service: ApprovalService;
35
+ readonly pending = new Map<string, Pending>();
36
+ readonly retained: ApprovalLifecycle[] = [];
37
+ private retired = false;
38
+ private reviewing = false;
39
+ private readonly boundManager: ExtensionContext["sessionManager"];
40
+ private readonly boundSession: string;
41
+ private branch: readonly string[];
42
+
43
+ constructor(
44
+ private readonly events: ApprovalEvents,
45
+ private readonly currentContext: () => ExtensionContext,
46
+ private readonly isBound: () => boolean,
47
+ private readonly timeoutMs = REQUEST_TIMEOUT_MS,
48
+ ) {
49
+ const initial = currentContext();
50
+ this.boundManager = initial.sessionManager;
51
+ this.branch = fingerprint(initial);
52
+ this.boundSession = JSON.stringify([
53
+ initial.sessionManager.getSessionId(),
54
+ initial.sessionManager.getSessionFile(),
55
+ ]);
56
+ this.service = Object.freeze({
57
+ version: 1,
58
+ id: randomUUID(),
59
+ request: (operation: ApprovalOperation, options: ApprovalRequestOptions) => this.request(operation, options),
60
+ });
61
+ }
62
+ private emit(
63
+ id: string,
64
+ operation: ApprovalOperation,
65
+ phase: ApprovalLifecycle["phase"],
66
+ outcome?: ApprovalOutcome,
67
+ ): void {
68
+ const event = validateLifecycle({
69
+ version: 1,
70
+ serviceId: this.service.id,
71
+ requestId: id,
72
+ source: operation.source,
73
+ toolCallId: operation.toolCallId,
74
+ phase,
75
+ ...(outcome ? { outcome } : {}),
76
+ });
77
+ this.events.emit(APPROVAL_LIFECYCLE_CHANNEL, event);
78
+ if (phase === "resolved") {
79
+ this.retained.push(event);
80
+ if (this.retained.length > MAX_RETAINED) this.retained.shift();
81
+ }
82
+ }
83
+ retire(): void {
84
+ if (this.retired) return;
85
+ this.retired = true;
86
+ for (const item of this.pending.values()) item.finish("retired");
87
+ }
88
+ /** Discovery must observe retirement even after the last request has resolved. */
89
+ get isLive(): boolean {
90
+ try {
91
+ this.assertLive();
92
+ return true;
93
+ } catch {
94
+ return false;
95
+ }
96
+ }
97
+ private assertLive(): ExtensionContext {
98
+ if (this.retired) throw new Error("Approval service retired");
99
+ try {
100
+ if (!this.isBound()) throw new Error("Approval canonical tool bindings were replaced");
101
+ const ctx = this.currentContext();
102
+ if (
103
+ ctx.sessionManager !== this.boundManager ||
104
+ JSON.stringify([ctx.sessionManager.getSessionId(), ctx.sessionManager.getSessionFile()]) !== this.boundSession
105
+ )
106
+ throw new Error("Approval native session changed");
107
+ const branch = fingerprint(ctx);
108
+ if (!extendsBranch(this.branch, branch)) throw new Error("Approval native branch diverged");
109
+ this.branch = branch;
110
+ if (!ctx.hasUI || (ctx.mode !== "tui" && ctx.mode !== "rpc")) throw new Error("No human approval UI available");
111
+ return ctx;
112
+ } catch (error) {
113
+ this.retire();
114
+ throw error;
115
+ }
116
+ }
117
+ request(value: ApprovalOperation, options: ApprovalRequestOptions): Promise<void> {
118
+ try {
119
+ const operation = validateOperation(value);
120
+ if (
121
+ !options ||
122
+ typeof options.isCurrent !== "function" ||
123
+ (options.signal !== undefined && !(options.signal instanceof AbortSignal))
124
+ )
125
+ throw new Error("Invalid approval owner options");
126
+ assertCurrent(options);
127
+ const ctx = this.assertLive();
128
+ const manager = ctx.sessionManager;
129
+ const captured = fingerprint(ctx);
130
+ if (this.pending.size >= MAX_PENDING) throw new Error("Too many pending approvals");
131
+ const id = randomUUID();
132
+ const controller = new AbortController();
133
+ const check = () => {
134
+ assertCurrent(options);
135
+ const now = this.assertLive();
136
+ if (now.sessionManager !== manager || !extendsBranch(captured, fingerprint(now))) {
137
+ this.retire();
138
+ throw new Error("Approval native session or branch changed");
139
+ }
140
+ };
141
+ return new Promise<void>((resolve, reject) => {
142
+ let finished = false;
143
+ const onAbort = () => finish("cancelled");
144
+ const timer = setTimeout(() => finish("expired"), this.timeoutMs);
145
+ const monitor = setInterval(() => {
146
+ try {
147
+ check();
148
+ } catch {
149
+ finish("retired");
150
+ }
151
+ }, 100);
152
+ const finish = (outcome: ApprovalOutcome) => {
153
+ if (finished) return;
154
+ if (outcome === "approved") {
155
+ try {
156
+ check();
157
+ } catch {
158
+ outcome = "retired";
159
+ }
160
+ }
161
+ if (finished) return;
162
+ finished = true;
163
+ clearTimeout(timer);
164
+ clearInterval(monitor);
165
+ options.signal?.removeEventListener("abort", onAbort);
166
+ this.pending.delete(id);
167
+ controller.abort();
168
+ try {
169
+ this.emit(id, operation, "resolved", outcome);
170
+ this.status();
171
+ } catch {
172
+ outcome = "error";
173
+ }
174
+ if (outcome === "approved") resolve();
175
+ else reject(new Error(`Approval ${outcome}`));
176
+ };
177
+ this.pending.set(id, { id, operation, controller, check, finish, reviewing: false });
178
+ options.signal?.addEventListener("abort", onAbort, { once: true });
179
+ try {
180
+ check();
181
+ this.emit(id, operation, "waiting");
182
+ this.status();
183
+ } catch {
184
+ finish("error");
185
+ }
186
+ });
187
+ } catch (error) {
188
+ return Promise.reject(error);
189
+ }
190
+ }
191
+ private status(): void {
192
+ const ctx = this.currentContext();
193
+ if (ctx.hasUI)
194
+ ctx.ui.setStatus(
195
+ `approvals:${this.service.id}`,
196
+ this.pending.size ? `Approvals: ${this.pending.size} (/approvals)` : undefined,
197
+ );
198
+ }
199
+ /** Human command only. Never called from a tool or event-bus response. */
200
+ async review(
201
+ id: string,
202
+ ctx: ExtensionContext,
203
+ decide: (operation: ApprovalOperation, signal: AbortSignal) => Promise<boolean>,
204
+ deny = false,
205
+ ): Promise<void> {
206
+ const item = this.pending.get(id);
207
+ if (!item) throw new Error("Unknown pending approval");
208
+ if (!ctx.hasUI || (ctx.mode !== "tui" && ctx.mode !== "rpc")) throw new Error("No human approval UI available");
209
+ item.check();
210
+ // Denial opens no UI and may cancel this or another request while a review is open.
211
+ if (deny) {
212
+ try {
213
+ this.emit(id, item.operation, "respond", "denied");
214
+ item.finish("denied");
215
+ } catch (error) {
216
+ item.finish("error");
217
+ throw error;
218
+ }
219
+ return;
220
+ }
221
+ if (this.reviewing || item.reviewing) throw new Error("An approval review is already open");
222
+ this.reviewing = item.reviewing = true;
223
+ try {
224
+ const confirmed = await decide(item.operation, item.controller.signal);
225
+ if (!this.pending.has(id)) return;
226
+ item.check();
227
+ this.emit(id, item.operation, "respond", confirmed === true ? "approved" : "denied");
228
+ item.finish(confirmed === true ? "approved" : "denied");
229
+ } catch (error) {
230
+ item.finish("error");
231
+ throw error;
232
+ } finally {
233
+ this.reviewing = item.reviewing = false;
234
+ }
235
+ }
236
+ }
package/src/index.ts ADDED
@@ -0,0 +1,232 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import type { ExtensionAPI, ExtensionContext, ToolDefinition, ToolInfo } from "@earendil-works/pi-coding-agent";
3
+ import {
4
+ APPROVAL_QUERY_CHANNEL,
5
+ type ApprovalService,
6
+ captureApprovalService,
7
+ createApprovalBashOperations,
8
+ createApprovalToolDefinitions,
9
+ hashBytes,
10
+ queryApprovalAdapter,
11
+ requiresApprovalAdapter,
12
+ schemaRequiresApprovalAdapter,
13
+ validateQuery,
14
+ } from "@signalridge/pi-approval-core";
15
+ import type { TSchema } from "typebox";
16
+ import { ApprovalBroker } from "./broker.js";
17
+ import { confirmOperation } from "./review.js";
18
+
19
+ const CANONICAL = ["edit", "write", "bash"] as const;
20
+ const NATIVE_FLAG = "approvals-native-tools";
21
+ function unavailable(): ApprovalService {
22
+ return Object.freeze({
23
+ version: 1,
24
+ id: randomUUID(),
25
+ request: async () => {
26
+ throw new Error("Approval session is unavailable or not yet bound");
27
+ },
28
+ });
29
+ }
30
+ function sameTool(a: ToolInfo, b: ToolInfo | undefined): boolean {
31
+ return b?.parameters === a.parameters && b.description === a.description && b.sourceInfo.path === a.sourceInfo.path;
32
+ }
33
+ export default function approvals(pi: ExtensionAPI): void {
34
+ pi.registerFlag(NATIVE_FLAG, {
35
+ type: "boolean",
36
+ default: false,
37
+ description: "Certify canonical backends are standard local native Pi tools; allow reviewed native replacements",
38
+ });
39
+ let broker: ApprovalBroker | undefined;
40
+ let advertised = unavailable();
41
+ let context: ExtensionContext | undefined;
42
+ let bindings: readonly ToolInfo[] = [];
43
+ let bindingFailed = false;
44
+ let rootReason = "Root canonical calls require explicit --approvals-native-tools certification";
45
+ let promptDepth = 0;
46
+ pi.on("ui_prompt_start", () => {
47
+ promptDepth++;
48
+ });
49
+ pi.on("ui_prompt_end", () => {
50
+ promptDepth = Math.max(0, promptDepth - 1);
51
+ });
52
+ const getContext = () => {
53
+ if (!context) throw new Error("Approval session is not bound");
54
+ return context;
55
+ };
56
+ const isBound = (name?: string) => {
57
+ const all = pi.getAllTools();
58
+ return (
59
+ !bindingFailed &&
60
+ (name === undefined || bindings.some((captured) => captured.name === name)) &&
61
+ bindings.every((captured) =>
62
+ sameTool(
63
+ captured,
64
+ all.find((tool) => tool.name === captured.name),
65
+ ),
66
+ )
67
+ );
68
+ };
69
+ pi.events.on(APPROVAL_QUERY_CHANNEL, (value) => {
70
+ const query = validateQuery(value);
71
+ if (broker && advertised === broker.service && !broker.isLive) advertised = unavailable();
72
+ query.respond(advertised);
73
+ });
74
+ const bind = (ctx: ExtensionContext) => {
75
+ broker?.retire();
76
+ context = ctx;
77
+ bindingFailed = true;
78
+ rootReason = "Approvals unavailable while binding the session";
79
+ const previous = bindings;
80
+ bindings = [];
81
+ const next = new ApprovalBroker(pi.events, getContext, isBound);
82
+ broker = next;
83
+ advertised = next.service;
84
+ if (pi.getFlag(NATIVE_FLAG) !== true) {
85
+ bindingFailed = false;
86
+ rootReason =
87
+ "Root canonical calls require explicit --approvals-native-tools certification; original backends were not replaced";
88
+ return;
89
+ }
90
+ const existing = pi.getAllTools();
91
+ const admitted = CANONICAL.filter((name) => existing.some((tool) => tool.name === name));
92
+ for (const name of admitted) {
93
+ const tool = existing.find((candidate) => candidate.name === name);
94
+ if (
95
+ !tool ||
96
+ (tool.sourceInfo.path !== `builtin:${name}` &&
97
+ !previous.some((old) => old.name === name && sameTool(old, tool)))
98
+ ) {
99
+ bindingFailed = true;
100
+ rootReason = `Approvals reject custom canonical ${name}; its backend was not replaced`;
101
+ return;
102
+ }
103
+ }
104
+ const sessionId = ctx.sessionManager.getSessionId();
105
+ const settings = pi.getSettings();
106
+ const marker = `[Approval binding ${next.service.id}]`;
107
+ // Discovery calls broker.isLive: capture only AFTER synchronous bindings
108
+ // are complete, or bindingFailed would permanently retire the new broker.
109
+ let captured: ReturnType<typeof captureApprovalService>;
110
+ const [edit, write, bash] = createApprovalToolDefinitions({
111
+ source: "root",
112
+ request: (operation, options) => {
113
+ if (!captured || captured.service.id !== next.service.id) throw new Error("Approval service unavailable");
114
+ return captured.service.request(operation, options);
115
+ },
116
+ isCurrent: (callCtx) => {
117
+ if (!captured) return false;
118
+ captured.assertCurrent();
119
+ return (
120
+ broker === next &&
121
+ next.isLive &&
122
+ callCtx.hasUI &&
123
+ (callCtx.mode === "tui" || callCtx.mode === "rpc") &&
124
+ callCtx.sessionManager === ctx.sessionManager &&
125
+ callCtx.sessionManager.getSessionId() === sessionId &&
126
+ isBound()
127
+ );
128
+ },
129
+ isToolCurrent: (_ctx, name) => isBound(name) && pi.getActiveTools().includes(name),
130
+ shellPath: settings.shellPath,
131
+ commandPrefix: settings.shellCommandPrefix,
132
+ });
133
+ const active = pi.getActiveTools();
134
+ // SDK allowlists can reactivate existing tools even on defaultActive:false registration.
135
+ // Restore the exact current stricter loadout after all late registrations.
136
+ const register = <T extends TSchema, D, S>(definition: ToolDefinition<T, D, S>) => {
137
+ if (admitted.some((name) => name === definition.name))
138
+ pi.registerTool<T, D, S>({
139
+ ...definition,
140
+ defaultActive: false,
141
+ description: `${definition.description}\n${marker}`,
142
+ });
143
+ };
144
+ register(edit);
145
+ register(write);
146
+ register(bash);
147
+ pi.setActiveTools(active);
148
+ bindings = pi.getAllTools().filter((tool) => admitted.some((name) => name === tool.name));
149
+ bindingFailed = bindings.length !== admitted.length || !bindings.every((tool) => tool.description.includes(marker));
150
+ rootReason = bindingFailed ? "Approvals lost canonical tool ownership (extension collision/load order)" : "";
151
+ if (!bindingFailed) captured = captureApprovalService(pi.events);
152
+ };
153
+ pi.on("session_start", (_event, ctx) => bind(ctx));
154
+ pi.on("session_tree", (_event, ctx) => bind(ctx));
155
+ pi.on("session_shutdown", () => broker?.retire());
156
+ pi.on("tool_call", (event) => {
157
+ if (CANONICAL.some((name) => name === event.toolName)) {
158
+ if (!broker || rootReason || !isBound(event.toolName)) {
159
+ if (!isBound()) broker?.retire();
160
+ return { block: true, reason: rootReason || "Approvals unavailable or canonical binding replaced" };
161
+ }
162
+ } else {
163
+ const effective = pi.getAllTools().find((tool) => tool.name === event.toolName);
164
+ // Root public hooks have no post-handler boundary. Require the adapter
165
+ // before later normalizers can populate known optional schema properties.
166
+ if (requiresApprovalAdapter(event.input) || (effective && schemaRequiresApprovalAdapter(effective.parameters))) {
167
+ try {
168
+ if (!effective) throw new Error("No effective tool binding");
169
+ queryApprovalAdapter(pi.events, event.toolName, effective.parameters);
170
+ } catch {
171
+ return {
172
+ block: true,
173
+ reason:
174
+ "Command/mutation-shaped tool requires exactly one live cooperative approval adapter; unreviewed fallback is blocked",
175
+ };
176
+ }
177
+ }
178
+ }
179
+ });
180
+ pi.on("user_bash", (event, ctx) => {
181
+ const owner = broker;
182
+ if (rootReason || !owner || !isBound()) throw new Error(rootReason || "Approvals unavailable for user bash");
183
+ const captured = captureApprovalService(pi.events);
184
+ if (!captured || captured.service.id !== owner.service.id)
185
+ throw new Error("Approval service unavailable for user bash");
186
+ const settings = pi.getSettings();
187
+ return {
188
+ operations: createApprovalBashOperations({
189
+ source: "user-bash",
190
+ toolCallId: randomUUID(),
191
+ inputHash: hashBytes(JSON.stringify({ command: event.command, cwd: event.cwd })),
192
+ request: (operation, options) => captured.service.request(operation, options),
193
+ isCurrent: () => {
194
+ captured.assertCurrent();
195
+ return (
196
+ broker === owner &&
197
+ owner.isLive &&
198
+ ctx.sessionManager === getContext().sessionManager &&
199
+ isBound("bash") &&
200
+ pi.getActiveTools().includes("bash")
201
+ );
202
+ },
203
+ shellPath: settings.shellPath,
204
+ }),
205
+ };
206
+ });
207
+ pi.registerCommand("approvals", {
208
+ description: "List pending human approvals and root policy; review <id>, approve <id>, or deny <id>",
209
+ handler: async (args, ctx) => {
210
+ if (!ctx.hasUI || !broker) throw new Error("No human approval UI available");
211
+ const [action, id, extra] = args.trim().split(/\s+/u);
212
+ if (!action || action === "list") {
213
+ const lines = [...broker.pending.values()].map(
214
+ (item) => `${item.id} ${item.operation.source} ${item.operation.kind} /approvals review ${item.id}`,
215
+ );
216
+ ctx.ui.notify(
217
+ [
218
+ rootReason || "Root policy: reviewed native tools",
219
+ ...(lines.length ? lines : ["No pending approvals"]),
220
+ ].join("\n"),
221
+ "info",
222
+ );
223
+ return;
224
+ }
225
+ if (!id || extra || !["review", "approve", "deny"].includes(action))
226
+ throw new Error("Usage: /approvals [list | review <id> | approve <id> | deny <id>]");
227
+ if (action !== "deny" && promptDepth > 0)
228
+ throw new Error("Close the current native/extension dialog before reviewing approvals");
229
+ await broker.review(id, ctx, (operation, signal) => confirmOperation(ctx, operation, signal), action === "deny");
230
+ },
231
+ });
232
+ }
package/src/review.ts ADDED
@@ -0,0 +1,214 @@
1
+ import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
2
+ import {
3
+ type Component,
4
+ matchesKey,
5
+ type OverlayHandle,
6
+ type TUI,
7
+ truncateToWidth,
8
+ visibleWidth,
9
+ } from "@earendil-works/pi-tui";
10
+ import type { ApprovalOperation } from "@signalridge/pi-approval-core";
11
+
12
+ const graphemes = new Intl.Segmenter(undefined, { granularity: "grapheme" });
13
+
14
+ /** Hard-wrap without discarding a separator at a word boundary. Compact views
15
+ * encode preview whitespace/backslashes so data newlines cannot look like soft
16
+ * wrapping, and a literal escape cannot impersonate an encoded separator. */
17
+ function wrapReview(source: string, width: number, exactWhitespace: boolean): string[] {
18
+ const lines: string[] = [];
19
+ let line = "";
20
+ let columns = 0;
21
+ const append = (value: string) => {
22
+ for (const { segment } of graphemes.segment(value)) {
23
+ const size = visibleWidth(segment);
24
+ if (columns + size > width && line) {
25
+ lines.push(line);
26
+ line = "";
27
+ columns = 0;
28
+ }
29
+ line += segment;
30
+ columns += size;
31
+ }
32
+ };
33
+ for (const { segment } of graphemes.segment(source)) {
34
+ if (segment === "\n") {
35
+ if (exactWhitespace) append("\\u{a}");
36
+ lines.push(line);
37
+ line = "";
38
+ columns = 0;
39
+ continue;
40
+ }
41
+ const size = visibleWidth(segment);
42
+ // Pi's output layer compatibility-decomposes Thai/Lao AM. Preserve the
43
+ // original source code points instead of approving a normalized lookalike.
44
+ const encodeCluster =
45
+ size === 0 ||
46
+ size > width ||
47
+ /[\u0e33\u0eb3]/u.test(segment) ||
48
+ (exactWhitespace && /\p{White_Space}/u.test(segment));
49
+ append(
50
+ encodeCluster
51
+ ? Array.from(segment, (char) => `\\u{${char.codePointAt(0)?.toString(16)}}`).join("")
52
+ : exactWhitespace
53
+ ? segment.replaceAll("\\", "\\\\")
54
+ : segment,
55
+ );
56
+ }
57
+ lines.push(line);
58
+ return lines;
59
+ }
60
+
61
+ interface ReviewComponent extends Component {
62
+ focused: boolean;
63
+ reconcile(): void;
64
+ dispose(): void;
65
+ }
66
+ export function createReviewComponent(
67
+ tui: TUI,
68
+ operation: ApprovalOperation,
69
+ signal: AbortSignal,
70
+ done: (approved: boolean) => void,
71
+ canComplete: () => boolean = () => true,
72
+ ): ReviewComponent {
73
+ let offset = 0;
74
+ let height = 1;
75
+ let total = 1;
76
+ let finished = false;
77
+ let focused = false;
78
+ let decision: boolean | undefined;
79
+ const header = "APPROVAL REVIEW";
80
+ const controls = "Ctrl+A approve | Esc deny";
81
+ const scrolling = "Up/Down/PgUp/PgDn/Home/End scroll";
82
+ let layout: { width: number; compact: boolean; source: string; lines: string[] } | undefined;
83
+ const reconcile = () => {
84
+ if (finished || decision === undefined || !canComplete()) return;
85
+ finished = true;
86
+ done(signal.aborted ? false : decision);
87
+ };
88
+ const finish = (value: boolean) => {
89
+ if (finished) return;
90
+ decision = signal.aborted ? false : value;
91
+ reconcile();
92
+ tui.requestRender();
93
+ };
94
+ const abort = () => finish(false);
95
+ signal.addEventListener("abort", abort, { once: true });
96
+ if (signal.aborted) queueMicrotask(abort);
97
+ return {
98
+ get focused() {
99
+ return focused;
100
+ },
101
+ set focused(value: boolean) {
102
+ if (focused === value) return;
103
+ focused = value;
104
+ tui.requestRender();
105
+ // Native hideOverlay restores focus before its close callback finishes.
106
+ // Complete only afterward, never pop the foreign overlay in that stack operation.
107
+ if (value) queueMicrotask(reconcile);
108
+ },
109
+ reconcile,
110
+ render(width: number) {
111
+ // A native question can take focus without adding an overlay. Do not
112
+ // obscure it with a full-screen review that cannot receive its answer.
113
+ if (!focused || finished) return [];
114
+ width = Math.max(1, width);
115
+ const rows = Math.max(1, tui.terminal.rows);
116
+ const compact = width < 40 || rows < 6;
117
+ if (!layout || layout.width !== width || layout.compact !== compact || layout.source !== operation.preview) {
118
+ const prefix = compact
119
+ ? wrapReview(`${header}\n${controls}\n${scrolling}\nPreview whitespace/backslashes escaped`, width, false)
120
+ : [];
121
+ layout = {
122
+ width,
123
+ compact,
124
+ source: operation.preview,
125
+ lines: [...prefix, ...wrapReview(operation.preview, width, compact)],
126
+ };
127
+ }
128
+ const lines = layout.lines;
129
+ height = compact ? rows : rows - 4;
130
+ total = lines.length;
131
+ offset = Math.min(offset, Math.max(0, total - height));
132
+ const body = lines.slice(offset, offset + height);
133
+ queueMicrotask(reconcile);
134
+ if (compact) return body;
135
+ while (body.length < height) body.push("");
136
+ return [
137
+ header,
138
+ controls,
139
+ ...body,
140
+ scrolling,
141
+ `Lines ${offset + 1}-${Math.min(total, offset + height)}/${total}`,
142
+ ].map((line) => truncateToWidth(line, width, ""));
143
+ },
144
+ handleInput(data: string) {
145
+ if (finished) return;
146
+ if (signal.aborted) finish(false);
147
+ else if (focused && matchesKey(data, "ctrl+a")) finish(true);
148
+ else if (matchesKey(data, "escape")) finish(false);
149
+ else {
150
+ if (matchesKey(data, "up")) offset = Math.max(0, offset - 1);
151
+ if (matchesKey(data, "down")) offset += 1;
152
+ if (matchesKey(data, "pageUp")) offset = Math.max(0, offset - Math.max(1, height - 1));
153
+ if (matchesKey(data, "pageDown")) offset += Math.max(1, height - 1);
154
+ if (matchesKey(data, "home")) offset = 0;
155
+ if (matchesKey(data, "end")) offset = Math.max(0, total - height);
156
+ tui.requestRender();
157
+ }
158
+ },
159
+ invalidate() {},
160
+ dispose() {
161
+ finished = true;
162
+ signal.removeEventListener("abort", abort);
163
+ },
164
+ };
165
+ }
166
+ /** Human command only. Borrow the public TUI synchronously, then own an
167
+ * identity-addressed overlay, never a long-lived native pop-last callback. */
168
+ export async function confirmOperation(
169
+ ctx: ExtensionContext,
170
+ operation: ApprovalOperation,
171
+ signal: AbortSignal,
172
+ ): Promise<boolean> {
173
+ if (!ctx.hasUI || signal.aborted) return false;
174
+ if (ctx.mode === "rpc")
175
+ return ctx.ui.confirm("Approve exactly this operation?", operation.preview, { signal, timeout: 60_000 });
176
+ if (ctx.mode !== "tui") return false;
177
+ let tui: TUI | undefined;
178
+ let finishBridge: (() => void) | undefined;
179
+ await ctx.ui.custom<void>(
180
+ (host, _theme, _keys, done) => {
181
+ tui = host;
182
+ finishBridge = done;
183
+ return { render: () => [], invalidate() {} };
184
+ },
185
+ // Native done pops the last overlay: complete synchronously only after
186
+ // admission, before any other interaction can interleave. Never touch the
187
+ // editor text, paste map, cursor or Undo history.
188
+ { overlay: true, overlayOptions: { nonCapturing: true }, onHandle: () => finishBridge?.() },
189
+ );
190
+ if (!tui || signal.aborted) return false;
191
+ const host = tui;
192
+ return new Promise<boolean>((resolve, reject) => {
193
+ let handle: OverlayHandle | undefined;
194
+ const component = createReviewComponent(
195
+ host,
196
+ operation,
197
+ signal,
198
+ (approved) => {
199
+ handle?.hide();
200
+ component.dispose();
201
+ resolve(approved);
202
+ },
203
+ () => handle !== undefined && (signal.aborted || handle.isFocused()),
204
+ );
205
+ try {
206
+ handle = host.showOverlay(component, { anchor: "top-left", width: "100%", maxHeight: "100%", margin: 0 });
207
+ component.reconcile();
208
+ } catch (error) {
209
+ handle?.hide();
210
+ component.dispose();
211
+ reject(error);
212
+ }
213
+ });
214
+ }