@arbiterforge/ca-pi 0.17.1 → 0.17.2

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/README.md CHANGED
@@ -10,7 +10,7 @@ and durable project context. You decide. codeArbiter enforces.
10
10
  <img alt="Claude Code plugin" src="https://img.shields.io/badge/Claude_Code-plugin-d97757">
11
11
  <img alt="Codex plugin" src="https://img.shields.io/badge/OpenAI_Codex-plugin-10a37f">
12
12
  <img alt="Pi Feature Forge preview" src="https://img.shields.io/badge/ca--pi-Feature_Forge_preview-d97757">
13
- <img alt="version 2.25.1" src="https://img.shields.io/badge/version-2.25.1-2b7489">
13
+ <img alt="version 2.25.2" src="https://img.shields.io/badge/version-2.25.2-2b7489">
14
14
  <img alt="core lanes" src="https://img.shields.io/badge/core_lanes-18-555">
15
15
  <img alt="skills" src="https://img.shields.io/badge/skills-22-555">
16
16
  <img alt="agents" src="https://img.shields.io/badge/agents-20-555">
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@arbiterforge/ca-pi",
3
- "version": "0.17.1",
3
+ "version": "0.17.2",
4
4
  "license": "AGPL-3.0-only",
5
5
  "repository": {
6
6
  "type": "git",
@@ -4,6 +4,20 @@ All notable changes to `ca-pi` are documented in this file.
4
4
 
5
5
  ## [Unreleased]
6
6
 
7
+ ## [0.17.2] - 2026-10-08
8
+
9
+ ### Fixed
10
+
11
+ - Recognize the installed `@arbiterforge/ca-pi` package and report the missing
12
+ prompt, verification and review authority precisely. New full-lane workflows
13
+ remain refused until that support is qualified.
14
+
15
+ ### Added
16
+
17
+ - Add a private, explicitly loaded native approval dialog candidate with exact
18
+ reply, pending-artifact, session and generation bindings. Ordinary extension
19
+ registration is unchanged; real human qualification remains pending.
20
+
7
21
  ## [0.17.1] - 2026-10-07
8
22
 
9
23
  ### Fixed
@@ -0,0 +1,840 @@
1
+ // src/approval-entry.ts
2
+ import { realpath as realpath4 } from "node:fs/promises";
3
+ import { dirname as dirname3, resolve as resolve6 } from "node:path";
4
+ import { fileURLToPath as fileURLToPath2 } from "node:url";
5
+
6
+ // src/bridge.ts
7
+ import { spawn, spawnSync } from "node:child_process";
8
+ import { createHash, randomUUID } from "node:crypto";
9
+ import { accessSync, constants, realpathSync, statSync } from "node:fs";
10
+ import { realpath as realpath2 } from "node:fs/promises";
11
+ import { isAbsolute, posix as posix2, win32 as win322 } from "node:path";
12
+
13
+ // src/audit-sink.ts
14
+ import { constants as fsConstants } from "node:fs";
15
+ import { lstat, open, realpath } from "node:fs/promises";
16
+ import { relative, resolve as resolve2 } from "node:path";
17
+
18
+ // src/path-boundary.ts
19
+ import { posix, resolve, win32 } from "node:path";
20
+ function flavorForPlatform(platform) {
21
+ return platform === "win32" ? "win32" : "posix";
22
+ }
23
+ function pathApiFor(flavor) {
24
+ return flavor === "win32" ? win32 : posix;
25
+ }
26
+ function lexicallyInside(path, root, flavor = flavorForPlatform(process.platform)) {
27
+ const pathApi = pathApiFor(flavor);
28
+ const suffix = pathApi.relative(root, path);
29
+ return suffix === "" || !suffix.startsWith("..") && !pathApi.isAbsolute(suffix);
30
+ }
31
+
32
+ // src/audit-sink.ts
33
+ var AUDIT_LOG_NAME = "gate-events.log";
34
+ var AUDIT_STATE_DIRECTORY = ".codearbiter";
35
+ var MAX_AUDIT_LINE_BYTES = 2048;
36
+ var NODE_AUDIT_SINK_IO = Object.freeze({ realpath, lstat, open });
37
+ function sameAuditFile(left, right) {
38
+ return left.isFile() && right.isFile() && !left.isSymbolicLink() && !right.isSymbolicLink() && left.nlink === 1 && right.nlink === 1 && left.dev === right.dev && left.ino === right.ino;
39
+ }
40
+ function sameAuditDirectory(left, right) {
41
+ return left.isDirectory() && right.isDirectory() && !left.isSymbolicLink() && !right.isSymbolicLink() && left.dev === right.dev && left.ino === right.ino;
42
+ }
43
+ async function openedAuditTarget(target, io) {
44
+ const noFollow = typeof fsConstants.O_NOFOLLOW === "number" ? fsConstants.O_NOFOLLOW : 0;
45
+ const existingFlags = fsConstants.O_WRONLY | fsConstants.O_APPEND | noFollow;
46
+ const createFlags = existingFlags | fsConstants.O_CREAT | fsConstants.O_EXCL;
47
+ for (let attempt = 0; attempt < 2; attempt += 1) {
48
+ let expected;
49
+ try {
50
+ expected = await io.lstat(target);
51
+ if (!expected.isFile() || expected.isSymbolicLink() || expected.nlink !== 1) return void 0;
52
+ } catch (error) {
53
+ if (error.code !== "ENOENT") return void 0;
54
+ }
55
+ let handle;
56
+ try {
57
+ handle = await io.open(target, expected === void 0 ? createFlags : existingFlags, 384);
58
+ } catch (error) {
59
+ if (expected === void 0 && error.code === "EEXIST" && attempt === 0) continue;
60
+ return void 0;
61
+ }
62
+ try {
63
+ const opened = await handle.stat();
64
+ const pathname = await io.lstat(target);
65
+ if (!sameAuditFile(opened, pathname) || expected !== void 0 && !sameAuditFile(opened, expected)) {
66
+ await handle.close();
67
+ return void 0;
68
+ }
69
+ return Object.freeze({ handle, identity: opened });
70
+ } catch {
71
+ try {
72
+ await handle.close();
73
+ } catch {
74
+ }
75
+ return void 0;
76
+ }
77
+ }
78
+ return void 0;
79
+ }
80
+ async function appendAuditLineWithIo(cwd, line, io) {
81
+ try {
82
+ if (Buffer.byteLength(line, "utf8") > MAX_AUDIT_LINE_BYTES || !line.endsWith("\n") || line.slice(0, -1).includes("\n")) return false;
83
+ const root = await io.realpath(cwd);
84
+ const statePath = resolve2(root, AUDIT_STATE_DIRECTORY);
85
+ const stateInfo = await io.lstat(statePath);
86
+ if (!stateInfo.isDirectory() || stateInfo.isSymbolicLink()) return false;
87
+ const state = await io.realpath(statePath);
88
+ const stateRelative = relative(root, state);
89
+ if (stateRelative === "" || !lexicallyInside(state, root) || resolve2(root, stateRelative) !== state) return false;
90
+ const stateIdentity = await io.lstat(state);
91
+ if (!sameAuditDirectory(stateInfo, stateIdentity)) return false;
92
+ const stateIsCurrent = async () => {
93
+ try {
94
+ return await io.realpath(statePath) === state && sameAuditDirectory(stateIdentity, await io.lstat(statePath));
95
+ } catch {
96
+ return false;
97
+ }
98
+ };
99
+ if (!await stateIsCurrent()) return false;
100
+ const target = resolve2(state, AUDIT_LOG_NAME);
101
+ const opened = await openedAuditTarget(target, io);
102
+ if (opened === void 0) return false;
103
+ const { handle, identity } = opened;
104
+ try {
105
+ const before = await handle.stat();
106
+ const beforePath = await io.lstat(target);
107
+ if (!sameAuditFile(identity, before) || !sameAuditFile(before, beforePath) || !await stateIsCurrent()) return false;
108
+ await handle.appendFile(line, { encoding: "utf8" });
109
+ await handle.sync();
110
+ const after = await handle.stat();
111
+ const afterPath = await io.lstat(target);
112
+ if (!sameAuditFile(before, after) || !sameAuditFile(after, afterPath) || after.size < before.size + Buffer.byteLength(line, "utf8") || !await stateIsCurrent()) return false;
113
+ } finally {
114
+ await handle.close();
115
+ }
116
+ return true;
117
+ } catch {
118
+ return false;
119
+ }
120
+ }
121
+ async function appendAuditLine(cwd, line) {
122
+ return await appendAuditLineWithIo(cwd, line, NODE_AUDIT_SINK_IO);
123
+ }
124
+
125
+ // src/redaction.ts
126
+ import { randomBytes } from "node:crypto";
127
+
128
+ // ../../ca/tools/redactor.ts
129
+ var SECRET_LINE = new RegExp(
130
+ [
131
+ "api[_-]?key",
132
+ "token",
133
+ "secret",
134
+ "password",
135
+ "BEGIN.*PRIVATE",
136
+ "sk-ant",
137
+ "sk-proj-[A-Za-z0-9_-]{8}",
138
+ "AKIA[0-9A-Z]{16}",
139
+ "ghp_[A-Za-z0-9]{36}",
140
+ "glpat-[A-Za-z0-9_-]{8}",
141
+ // basic auth in a URL: scheme://user:secret@host - the colon-and-at shape,
142
+ // not any "@", so `https://example.com/@scope/pkg` is untouched.
143
+ // NOTE the doubled backslashes: these are JS STRING literals, and "\\s" in
144
+ // a string is a literal "s". The first cut of this shipped `[^/\s:@]`,
145
+ // which silently became `[^/s:@]` and only matched by luck.
146
+ "https?://[^/\\s:@]+:[^/\\s@]+@",
147
+ // a bearer credential, which is dot-segmented base64url in practice
148
+ "Bearer\\s+[A-Za-z0-9_-]{10,}"
149
+ // DELIBERATELY NOT ADDED: a `-u user:pass` rule for curl. `-u 1000:1000`
150
+ // is an everyday docker/podman uid:gid pair, so the shape collides with
151
+ // benign input. Broad is the safe direction for this redactor, but not at
152
+ // the cost of firing on ordinary container arguments.
153
+ ].join("|"),
154
+ "i"
155
+ );
156
+ var PEM_BEGIN = /^-----BEGIN .*-----\s*$/;
157
+ var PEM_END = /^-----END .*-----\s*$/;
158
+ var REDACTION_MARKER = "[REDACTED \u2014 secret-pattern match removed before transmission]";
159
+ function redactSecrets(contents) {
160
+ const lines = contents.split("\n");
161
+ const out = [];
162
+ for (let i = 0; i < lines.length; i++) {
163
+ const line = lines[i];
164
+ if (PEM_BEGIN.test(line.trim())) {
165
+ out.push(REDACTION_MARKER);
166
+ i++;
167
+ while (i < lines.length && !PEM_END.test(lines[i].trim())) i++;
168
+ continue;
169
+ }
170
+ out.push(SECRET_LINE.test(line) ? REDACTION_MARKER : line);
171
+ }
172
+ return out.join("\n");
173
+ }
174
+
175
+ // src/redaction.ts
176
+ function redactSecrets2(value) {
177
+ return redactSecrets(value);
178
+ }
179
+ var MIN_BENIGN_LITERAL_LENGTH = 8;
180
+ function maskBenign(value, benign) {
181
+ const literals = [...new Set(benign)].filter((literal) => literal.length >= MIN_BENIGN_LITERAL_LENGTH).sort((a, b) => b.length - a.length);
182
+ const identity = { masked: value, restore: (text) => text };
183
+ if (literals.length === 0) return identity;
184
+ const nonce = randomBytes(8).toString("hex");
185
+ const captured = [];
186
+ let masked = value;
187
+ for (const literal of literals) {
188
+ const pattern = new RegExp(literal.replace(/[.*+?^${}()|[\]\\]/gu, "\\$&"), "gu");
189
+ masked = masked.replace(pattern, (match) => `${nonce}-${captured.push(match) - 1}`);
190
+ }
191
+ if (captured.length === 0) return identity;
192
+ const placeholder = new RegExp(`${nonce}-(\\d+)`, "gu");
193
+ return {
194
+ masked,
195
+ restore: (text) => text.replace(placeholder, (whole, index) => captured[Number(index)] ?? whole)
196
+ };
197
+ }
198
+ function safeDiagnostic(value, maxChars = 2e3, benignPaths = []) {
199
+ const { masked, restore } = maskBenign(value, benignPaths);
200
+ const normalized = restore(redactSecrets2(masked)).replace(/\r\n?/gu, "\n").replace(/[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f]/gu, "\uFFFD").trim();
201
+ return normalized.length <= maxChars ? normalized : `${normalized.slice(0, maxChars)}\u2026`;
202
+ }
203
+ function redactJson(value, depth = 0) {
204
+ if (depth > 32) return "[REDACTED OVERSIZE VALUE]";
205
+ if (typeof value === "string") return safeDiagnostic(value, 16e3);
206
+ if (Array.isArray(value)) return value.map((item) => redactJson(item, depth + 1));
207
+ if (value !== null && typeof value === "object") {
208
+ return Object.fromEntries(Object.entries(value).map(([key, item]) => [key, redactJson(item, depth + 1)]));
209
+ }
210
+ return value;
211
+ }
212
+
213
+ // src/bridge.ts
214
+ var RESPONSE_KEYS = /* @__PURE__ */ new Set(["version", "outcome", "ruleId", "message", "context", "resultPatch", "auditCode"]);
215
+ var OUTCOMES = /* @__PURE__ */ new Set(["allow", "block", "warn", "notice"]);
216
+ var PI_MAX_HOME_CHARS = 32768;
217
+ var CONTROL_RE = /[\u0000-\u001f\u007f-\u009f]/u;
218
+ function minimalEnvironment(identities, userHome) {
219
+ const allowed = ["SystemRoot", "WINDIR", "TEMP", "TMP"];
220
+ const env = { PYTHONIOENCODING: "utf-8", PYTHONUTF8: "1" };
221
+ for (const key of allowed) if (process.env[key] !== void 0) env[key] = process.env[key];
222
+ if (identities !== void 0) {
223
+ const pathApi = process.platform === "win32" ? win322 : posix2;
224
+ const searchDirectories = /* @__PURE__ */ new Set([
225
+ pathApi.dirname(identities.git),
226
+ pathApi.dirname(identities.python)
227
+ ]);
228
+ const systemRoot = process.env.SystemRoot ?? process.env.WINDIR;
229
+ if (process.platform === "win32" && systemRoot !== void 0 && win322.isAbsolute(systemRoot)) {
230
+ searchDirectories.add(win322.join(systemRoot, "System32"));
231
+ }
232
+ env.PATH = [...searchDirectories].join(process.platform === "win32" ? ";" : ":");
233
+ env.CODEARBITER_GIT_EXECUTABLE = identities.git;
234
+ env.CODEARBITER_PYTHON_EXECUTABLE = identities.python;
235
+ }
236
+ if (userHome !== void 0) {
237
+ if (process.platform === "win32") env.USERPROFILE = userHome;
238
+ else env.HOME = userHome;
239
+ }
240
+ return env;
241
+ }
242
+ function canonicalExecutable(candidate, platform) {
243
+ const pathApi = platform === "win32" ? win322 : posix2;
244
+ if (!pathApi.isAbsolute(candidate)) return void 0;
245
+ try {
246
+ const canonical = realpathSync(candidate);
247
+ if (!statSync(canonical).isFile()) return void 0;
248
+ if (platform !== "win32") accessSync(canonical, constants.X_OK);
249
+ return canonical;
250
+ } catch {
251
+ return void 0;
252
+ }
253
+ }
254
+ async function canonicalUserHome(projectRoot, packageRoot, platform = process.platform) {
255
+ const pathApi = platform === "win32" ? win322 : posix2;
256
+ const candidate = platform === "win32" ? process.env.USERPROFILE : process.env.HOME;
257
+ if (typeof candidate !== "string" || candidate.length < 1 || candidate.length > PI_MAX_HOME_CHARS || candidate !== candidate.trim() || CONTROL_RE.test(candidate) || !pathApi.isAbsolute(candidate)) return void 0;
258
+ try {
259
+ const canonical = await realpath2(candidate);
260
+ if (!statSync(canonical).isDirectory() || lexicallyInside(canonical, projectRoot, flavorForPlatform(platform)) || lexicallyInside(canonical, packageRoot, flavorForPlatform(platform))) return void 0;
261
+ return canonical;
262
+ } catch {
263
+ return void 0;
264
+ }
265
+ }
266
+ function trustedPathCandidate(basename, projectCwd, platform, pathValue) {
267
+ const pathApi = platform === "win32" ? win322 : posix2;
268
+ const separator = platform === "win32" ? ";" : ":";
269
+ const canonicalProject = canonicalExecutable(projectCwd, platform) ?? (() => {
270
+ try {
271
+ return realpathSync(projectCwd);
272
+ } catch {
273
+ return pathApi.resolve(projectCwd);
274
+ }
275
+ })();
276
+ for (const rawEntry of pathValue.split(separator)) {
277
+ const entry = rawEntry.trim();
278
+ if (entry === "" || !pathApi.isAbsolute(entry)) continue;
279
+ let directory;
280
+ try {
281
+ directory = realpathSync(entry);
282
+ } catch {
283
+ continue;
284
+ }
285
+ const candidate = canonicalExecutable(pathApi.join(directory, basename), platform);
286
+ if (candidate !== void 0 && !lexicallyInside(candidate, canonicalProject, flavorForPlatform(platform))) return candidate;
287
+ }
288
+ return void 0;
289
+ }
290
+ function resolveGitExecutable(projectCwd, platform = process.platform, pathValue = process.env.PATH ?? "") {
291
+ const name = platform === "win32" ? "git.exe" : "git";
292
+ const executable = trustedPathCandidate(name, projectCwd, platform, pathValue);
293
+ if (executable === void 0) {
294
+ throw new Error("codeArbiter could not resolve an absolute trusted Git executable; run /ca-doctor.");
295
+ }
296
+ return executable;
297
+ }
298
+ function windowsTaskkillExecutable() {
299
+ if (process.platform !== "win32") return void 0;
300
+ const systemRoot = process.env.SystemRoot ?? process.env.WINDIR;
301
+ if (systemRoot === void 0 || !win322.isAbsolute(systemRoot)) return void 0;
302
+ const candidate = canonicalExecutable(win322.join(systemRoot, "System32", "taskkill.exe"), "win32");
303
+ if (candidate === void 0) return void 0;
304
+ let canonicalRoot;
305
+ try {
306
+ canonicalRoot = realpathSync(systemRoot);
307
+ } catch {
308
+ return void 0;
309
+ }
310
+ return lexicallyInside(candidate, canonicalRoot, "win32") ? candidate : void 0;
311
+ }
312
+ function validResponse(value) {
313
+ if (value === null || typeof value !== "object" || Array.isArray(value)) return false;
314
+ const record2 = value;
315
+ if (Object.keys(record2).some((key) => !RESPONSE_KEYS.has(key))) return false;
316
+ if (record2.version !== 1 || typeof record2.outcome !== "string" || !OUTCOMES.has(record2.outcome)) return false;
317
+ for (const key of ["ruleId", "message", "context", "auditCode"]) {
318
+ if (record2[key] !== void 0 && typeof record2[key] !== "string") return false;
319
+ }
320
+ return true;
321
+ }
322
+ var WINDOWS_TASKKILL_TIMEOUT_MS = 1e3;
323
+ var KILL_SETTLE_DEADLINE_MS = 2e3;
324
+ function killTree(child, taskkillExecutable) {
325
+ if (child.pid === void 0) return;
326
+ if (process.platform === "win32") {
327
+ if (taskkillExecutable === void 0) {
328
+ child.kill("SIGKILL");
329
+ return;
330
+ }
331
+ void killWindowsTree(child, taskkillExecutable);
332
+ return;
333
+ }
334
+ try {
335
+ process.kill(-child.pid, "SIGKILL");
336
+ } catch {
337
+ child.kill("SIGKILL");
338
+ }
339
+ }
340
+ function killWindowsTree(child, taskkillExecutable) {
341
+ return new Promise((resolveKill) => {
342
+ let settled = false;
343
+ const finish = () => {
344
+ if (settled) return;
345
+ settled = true;
346
+ resolveKill();
347
+ };
348
+ let helper;
349
+ try {
350
+ helper = spawn(taskkillExecutable, ["/pid", String(child.pid), "/t", "/f"], {
351
+ env: minimalEnvironment(),
352
+ shell: false,
353
+ stdio: "ignore",
354
+ windowsHide: true
355
+ });
356
+ } catch {
357
+ child.kill("SIGKILL");
358
+ finish();
359
+ return;
360
+ }
361
+ const timer = setTimeout(() => {
362
+ try {
363
+ helper.kill("SIGKILL");
364
+ } catch {
365
+ }
366
+ child.kill("SIGKILL");
367
+ finish();
368
+ }, WINDOWS_TASKKILL_TIMEOUT_MS);
369
+ timer.unref?.();
370
+ helper.once("error", () => {
371
+ clearTimeout(timer);
372
+ child.kill("SIGKILL");
373
+ finish();
374
+ });
375
+ helper.once("close", (code) => {
376
+ clearTimeout(timer);
377
+ if (code !== 0) child.kill("SIGKILL");
378
+ finish();
379
+ });
380
+ });
381
+ }
382
+ var BRIDGE_CORRELATION_RE = /^[a-f0-9]{64}$/u;
383
+ function normalizedRequest(request) {
384
+ const { correlation, ...rest } = request;
385
+ return typeof correlation === "string" && BRIDGE_CORRELATION_RE.test(correlation) ? { ...rest, correlation } : rest;
386
+ }
387
+ function sanitizedResponse(response, request) {
388
+ return {
389
+ ...response,
390
+ ...response.ruleId === void 0 ? {} : { ruleId: safeDiagnostic(response.ruleId, 100) },
391
+ ...response.message === void 0 ? {} : { message: safeDiagnostic(response.message) },
392
+ ...response.context === void 0 ? {} : { context: safeDiagnostic(response.context, 16e3) },
393
+ ...response.auditCode === void 0 ? {} : { auditCode: safeDiagnostic(response.auditCode, 100) },
394
+ ...response.resultPatch === void 0 ? {} : {
395
+ resultPatch: request.event === "plan_file" ? response.resultPatch : redactJson(response.resultPatch)
396
+ }
397
+ };
398
+ }
399
+ var spawnImpl = spawn;
400
+ var BridgeClient = class {
401
+ constructor(options) {
402
+ this.options = options;
403
+ this.timeoutMs = options.timeoutMs ?? 1e4;
404
+ this.maxRequestBytes = options.maxRequestBytes ?? 262144;
405
+ this.maxStreamBytes = options.maxStreamBytes ?? 1048576;
406
+ this.ready = this.validatePaths();
407
+ this.ready.catch(() => void 0);
408
+ }
409
+ options;
410
+ ready;
411
+ timeoutMs;
412
+ maxRequestBytes;
413
+ maxStreamBytes;
414
+ async validatePaths() {
415
+ if (this.options.pythonExecutable === void 0 || this.options.gitExecutable === void 0) {
416
+ throw new Error("Python 3 or Git is unavailable");
417
+ }
418
+ if (!isAbsolute(this.options.pythonExecutable) || !isAbsolute(this.options.gitExecutable) || !isAbsolute(this.options.bridgeScript) || !isAbsolute(this.options.packageRoot)) {
419
+ throw new Error("bridge paths must be absolute");
420
+ }
421
+ const [git, python, script, root] = await Promise.all([
422
+ realpath2(this.options.gitExecutable),
423
+ realpath2(this.options.pythonExecutable),
424
+ realpath2(this.options.bridgeScript),
425
+ realpath2(this.options.packageRoot)
426
+ ]);
427
+ if (canonicalExecutable(git, process.platform) === void 0 || canonicalExecutable(python, process.platform) === void 0) {
428
+ throw new Error("bridge executable identity is invalid");
429
+ }
430
+ if (!lexicallyInside(script, root)) throw new Error("bridge script is outside the installed package");
431
+ const taskkill = windowsTaskkillExecutable();
432
+ if (process.platform === "win32" && taskkill === void 0) throw new Error("taskkill is unavailable");
433
+ return { git, python, root, script, ...taskkill === void 0 ? {} : { taskkill } };
434
+ }
435
+ failure(request, detail) {
436
+ const category = this.options.toolClasses[request.tool ?? ""] ?? "OTHER";
437
+ const advisory = request.event !== "tool_call" || category === "READ";
438
+ return {
439
+ version: 1,
440
+ outcome: advisory ? "warn" : "block",
441
+ ruleId: "PI-BRIDGE",
442
+ message: `codeArbiter Pi bridge ${safeDiagnostic(detail)}; ${advisory ? "continuing advisory operation; " : "mutation blocked; "}run /ca-doctor.`,
443
+ auditCode: advisory ? "PI_BRIDGE_WARN" : "PI_BRIDGE_BLOCK"
444
+ };
445
+ }
446
+ async auditFailure(request, response, counts) {
447
+ try {
448
+ if (this.options.shouldAuditFailure?.(request) === false) return;
449
+ } catch {
450
+ }
451
+ const line = [
452
+ `[${(/* @__PURE__ */ new Date()).toISOString()}]`,
453
+ "HOST: pi",
454
+ `RULE: ${response.ruleId ?? "PI-BRIDGE"}`,
455
+ `AUDIT: ${response.auditCode ?? "PI_BRIDGE_FAILURE"}`,
456
+ `CORRELATION: ${request.correlation ?? randomUUID()}`,
457
+ `REQUEST_BYTES: ${counts.request}`,
458
+ `STDOUT_BYTES: ${counts.stdout}`,
459
+ `STDERR_BYTES: ${counts.stderr}`
460
+ ].join(" | ") + "\n";
461
+ await appendAuditLine(request.cwd, line);
462
+ }
463
+ async failed(request, detail, counts = { request: 0, stdout: 0, stderr: 0 }) {
464
+ const response = this.failure(request, detail);
465
+ await this.auditFailure(request, response, counts);
466
+ return response;
467
+ }
468
+ async call(rawRequest, signal) {
469
+ const request = normalizedRequest(rawRequest);
470
+ let paths;
471
+ let userHome;
472
+ try {
473
+ paths = await this.ready;
474
+ } catch {
475
+ return await this.failed(request, "path validation failed");
476
+ }
477
+ try {
478
+ const project = await realpath2(request.cwd);
479
+ if (lexicallyInside(paths.git, project) || lexicallyInside(paths.python, project)) {
480
+ return await this.failed(request, "path validation failed");
481
+ }
482
+ const canonicalHome = await canonicalUserHome(project, paths.root);
483
+ if (canonicalHome === void 0) return await this.failed(request, "path validation failed");
484
+ userHome = canonicalHome;
485
+ } catch {
486
+ return await this.failed(request, "path validation failed");
487
+ }
488
+ let body;
489
+ try {
490
+ body = Buffer.from(JSON.stringify(request), "utf8");
491
+ } catch {
492
+ return await this.failed(request, "request serialization failed");
493
+ }
494
+ if (body.byteLength > this.maxRequestBytes) return await this.failed(request, "request overflow", { request: body.byteLength, stdout: 0, stderr: 0 });
495
+ if (signal.aborted) return await this.failed(request, "cancelled", { request: body.byteLength, stdout: 0, stderr: 0 });
496
+ return await new Promise((resolveResponse) => {
497
+ let child;
498
+ try {
499
+ child = spawnImpl(paths.python, [...this.options.pythonPrefixArgs ?? [], paths.script], {
500
+ cwd: paths.root,
501
+ detached: process.platform !== "win32",
502
+ env: minimalEnvironment({ git: paths.git, python: paths.python }, userHome),
503
+ shell: false,
504
+ stdio: ["pipe", "pipe", "pipe"],
505
+ windowsHide: true
506
+ });
507
+ } catch {
508
+ void this.failed(
509
+ request,
510
+ "bridge launch failed",
511
+ { request: body.byteLength, stdout: 0, stderr: 0 }
512
+ ).then(resolveResponse, () => resolveResponse(this.failure(request, "bridge launch failed")));
513
+ return;
514
+ }
515
+ const stdout = [];
516
+ const stderr = [];
517
+ let stdoutBytes = 0;
518
+ let stderrBytes = 0;
519
+ let reason;
520
+ let settled = false;
521
+ let finishing = false;
522
+ let settleDeadline;
523
+ const finish = (response) => {
524
+ if (settled) return;
525
+ settled = true;
526
+ clearTimeout(timer);
527
+ if (settleDeadline !== void 0) clearTimeout(settleDeadline);
528
+ signal.removeEventListener("abort", abort);
529
+ resolveResponse(response);
530
+ };
531
+ const failAndKill = (value) => {
532
+ if (reason !== void 0) return;
533
+ reason = value;
534
+ killTree(child, paths.taskkill);
535
+ settleDeadline = setTimeout(() => finishFailure(value), KILL_SETTLE_DEADLINE_MS);
536
+ settleDeadline.unref?.();
537
+ };
538
+ const finishFailure = (detail) => {
539
+ if (settled || finishing) return;
540
+ finishing = true;
541
+ void this.failed(request, detail, { request: body.byteLength, stdout: stdoutBytes, stderr: stderrBytes }).then(finish, () => finish(this.failure(request, detail)));
542
+ };
543
+ const collect = (target, chunk, stream) => {
544
+ const count = stream === "stdout" ? stdoutBytes : stderrBytes;
545
+ const remaining = Math.max(0, this.maxStreamBytes - count);
546
+ if (remaining > 0) target.push(chunk.subarray(0, remaining));
547
+ if (stream === "stdout") stdoutBytes += chunk.byteLength;
548
+ else stderrBytes += chunk.byteLength;
549
+ if (count + chunk.byteLength > this.maxStreamBytes) failAndKill("protocol overflow");
550
+ };
551
+ const abort = () => failAndKill("cancelled");
552
+ const timer = setTimeout(() => failAndKill("timed out"), this.timeoutMs);
553
+ signal.addEventListener("abort", abort, { once: true });
554
+ child.stdout.on("data", (chunk) => collect(stdout, chunk, "stdout"));
555
+ child.stderr.on("data", (chunk) => collect(stderr, chunk, "stderr"));
556
+ child.on("error", () => finishFailure("bridge launch failed"));
557
+ child.on("close", (code) => {
558
+ if (reason !== void 0) return finishFailure(reason);
559
+ if (code !== 0) return finishFailure("bridge process failed");
560
+ const stdoutText = Buffer.concat(stdout).toString("utf8");
561
+ let parsed;
562
+ try {
563
+ parsed = JSON.parse(stdoutText);
564
+ } catch {
565
+ return finishFailure("returned malformed protocol");
566
+ }
567
+ if (!validResponse(parsed)) return finishFailure("returned malformed protocol");
568
+ finish(sanitizedResponse(parsed, request));
569
+ });
570
+ child.stdin.on("error", () => void 0);
571
+ child.stdin.end(body);
572
+ });
573
+ }
574
+ };
575
+ function systemPythonProbe(executable, prefixArgs, cwd) {
576
+ const probe = spawnSync(executable, [...prefixArgs, "-c", "import sys; print(sys.version_info[0]); print(sys.executable)"], {
577
+ cwd,
578
+ encoding: "utf8",
579
+ env: minimalEnvironment(),
580
+ shell: false,
581
+ timeout: 2e3,
582
+ windowsHide: true
583
+ });
584
+ return { status: probe.status, stdout: probe.stdout ?? "", stderr: probe.stderr ?? "" };
585
+ }
586
+ function resolvePythonCommand(platform = process.platform, probe = systemPythonProbe, searchCwd, excludedProjectCwd, pathValue = process.env.PATH ?? "") {
587
+ const pathApi = platform === "win32" ? win322 : posix2;
588
+ const safeCwd = searchCwd ?? (platform === "win32" ? win322.parse(process.execPath).root : "/");
589
+ if (!pathApi.isAbsolute(safeCwd)) {
590
+ throw new Error("codeArbiter Python search cwd must be absolute; run /ca-doctor.");
591
+ }
592
+ const candidates = platform === "win32" ? [["py.exe", ["-3"]], ["python.exe", []], ["python3.exe", []]] : [["python3", []], ["python", []]];
593
+ for (const [candidate, prefixArgs] of candidates) {
594
+ const probedCandidate = probe === systemPythonProbe ? trustedPathCandidate(candidate, excludedProjectCwd ?? safeCwd, platform, pathValue) : candidate.replace(/\.exe$/u, "");
595
+ if (probedCandidate === void 0) continue;
596
+ const result = probe(probedCandidate, prefixArgs, safeCwd);
597
+ const lines = result.stdout.trim().split(/\r?\n/u);
598
+ const executable = lines[1] ?? "";
599
+ const absolute = platform === "win32" ? win322.isAbsolute(executable) : posix2.isAbsolute(executable);
600
+ const canonical = absolute ? probe === systemPythonProbe ? canonicalExecutable(executable, platform) : executable : void 0;
601
+ if (result.status === 0 && lines[0] === "3" && canonical !== void 0 && (probe !== systemPythonProbe || !lexicallyInside(canonical, excludedProjectCwd ?? safeCwd, flavorForPlatform(platform)))) {
602
+ return { executable: canonical, prefixArgs: [] };
603
+ }
604
+ }
605
+ throw new Error("codeArbiter could not resolve an absolute Python interpreter; run /ca-doctor.");
606
+ }
607
+
608
+ // src/approval-dialog.ts
609
+ import { randomUUID as randomUUID2 } from "node:crypto";
610
+ import { isAbsolute as isAbsolute2, resolve as resolve4 } from "node:path";
611
+ var UUID = /^[0-9a-f]{8}(?:-[0-9a-f]{4}){3}-[0-9a-f]{12}$/u;
612
+ var HASH = /^[0-9a-f]{64}$/u;
613
+ var FLAGS = [
614
+ "--no-extensions",
615
+ "--no-tools",
616
+ "--no-skills",
617
+ "--no-prompt-templates",
618
+ "--no-themes",
619
+ "--no-context-files",
620
+ "--no-session",
621
+ "-e"
622
+ ];
623
+ function validApprovalLaunch(args, entry, execArgs, nodeOptions) {
624
+ return isAbsolute2(entry) && execArgs.length === 0 && !nodeOptions && args.length === FLAGS.length + 1 && FLAGS.every((flag, index) => args[index] === flag) && isAbsolute2(args[FLAGS.length]) && resolve4(args[FLAGS.length]) === resolve4(entry);
625
+ }
626
+ function record(value) {
627
+ return value !== null && typeof value === "object" && !Array.isArray(value) && Object.getPrototypeOf(value) === Object.prototype;
628
+ }
629
+ function pendingSnapshot(value) {
630
+ return record(value) && Object.keys(value).sort().join(",") === "artifact_id,kind,model_sha256,normative_sha256,pending_sha256,revision" && typeof value.artifact_id === "string" && /^[A-Z][A-Z0-9-]{1,79}$/u.test(value.artifact_id) && (value.kind === "spec" || value.kind === "plan") && Number.isSafeInteger(value.revision) && value.revision >= 1 && [value.model_sha256, value.normative_sha256, value.pending_sha256].every((hash) => typeof hash === "string" && HASH.test(hash));
631
+ }
632
+ async function runApprovalDialog(context, options) {
633
+ let consuming = false;
634
+ const controller = new AbortController();
635
+ const abort = () => controller.abort();
636
+ context.signal?.addEventListener("abort", abort, { once: true });
637
+ let timer;
638
+ try {
639
+ const session = context.sessionManager?.getSessionId?.();
640
+ const cwd = context.cwd;
641
+ const valid = () => options.isCurrent() && context.mode === "tui" && context.hasUI === true && context.isProjectTrusted?.() === true && context.cwd === cwd && isAbsolute2(cwd) && context.sessionManager?.getSessionId?.() === session && typeof session === "string" && UUID.test(session) && !context.signal?.aborted && !controller.signal.aborted && Array.isArray(options.activeTools()) && options.activeTools().length === 0;
642
+ if (!valid() || typeof context.ui.input !== "function") return { status: "unavailable" };
643
+ const generation = randomUUID2();
644
+ const bridge = options.bridge(cwd);
645
+ const snapshot = await bridge.call({ version: 1, event: "native_approval_inspect", cwd }, controller.signal);
646
+ if (!valid()) return { status: "stale" };
647
+ if (snapshot.outcome !== "allow" || !pendingSnapshot(snapshot.resultPatch)) return { status: "unavailable" };
648
+ const pending = snapshot.resultPatch;
649
+ timer = setTimeout(abort, 3e5);
650
+ const reply = await context.ui.input(
651
+ `Review ${pending.kind} ${pending.artifact_id} revision ${pending.revision}
652
+ Normative SHA-256: ${pending.normative_sha256}
653
+ Enter the exact armed approval reply, or deny. Escape preserves the request.`,
654
+ void 0,
655
+ { signal: controller.signal, timeout: 3e5 }
656
+ );
657
+ if (!valid()) return { status: "stale" };
658
+ if (reply === void 0) return { status: "cancelled" };
659
+ if (reply === "deny") return { status: "denied" };
660
+ if (typeof reply !== "string" || Buffer.byteLength(reply, "utf8") > 512) return { status: "unmatched" };
661
+ consuming = true;
662
+ const response = await bridge.call({
663
+ version: 1,
664
+ event: "native_approval_consume",
665
+ cwd,
666
+ sessionId: session,
667
+ input: { reply, generation, pending_sha256: pending.pending_sha256 }
668
+ }, controller.signal);
669
+ if (!valid() || response.outcome !== "allow" || !record(response.resultPatch)) return { status: "outcome-unknown" };
670
+ if (response.resultPatch.approved === true && response.resultPatch.artifact_id === pending.artifact_id) {
671
+ return { status: "approved", artifact_id: pending.artifact_id };
672
+ }
673
+ if (response.resultPatch.approved === false && response.resultPatch.matched === false) return { status: "unmatched" };
674
+ return { status: "outcome-unknown" };
675
+ } catch {
676
+ return { status: consuming ? "outcome-unknown" : "unavailable" };
677
+ } finally {
678
+ if (timer !== void 0) clearTimeout(timer);
679
+ controller.abort();
680
+ context.signal?.removeEventListener("abort", abort);
681
+ }
682
+ }
683
+
684
+ // src/runtime-resolver.ts
685
+ import { lstat as lstat2, readFile, realpath as realpath3 } from "node:fs/promises";
686
+ import { createRequire } from "node:module";
687
+ import { dirname as dirname2, isAbsolute as isAbsolute3, resolve as resolve5 } from "node:path";
688
+ import { fileURLToPath, pathToFileURL } from "node:url";
689
+ var PI_RUNTIME_DIAGNOSIS = "codeArbiter could not validate the active Pi CLI runtime; start from the Pi CLI and run /ca-doctor.";
690
+ var trustedIdentities = /* @__PURE__ */ new WeakSet();
691
+ function fail(cause) {
692
+ throw new Error(PI_RUNTIME_DIAGNOSIS, cause === void 0 ? void 0 : { cause });
693
+ }
694
+ async function owningPackageRoot(file, expectedName) {
695
+ let cursor = dirname2(file);
696
+ while (true) {
697
+ const candidate = resolve5(cursor, "package.json");
698
+ try {
699
+ const manifest = JSON.parse(await readFile(candidate, "utf8"));
700
+ if (manifest.name !== expectedName) return fail();
701
+ const canonicalRoot = await realpath3(cursor);
702
+ if (!lexicallyInside(file, canonicalRoot) || !lexicallyInside(await realpath3(candidate), canonicalRoot)) return fail();
703
+ return canonicalRoot;
704
+ } catch (error) {
705
+ if (error.code !== "ENOENT") return fail(error);
706
+ }
707
+ const parent = dirname2(cursor);
708
+ if (parent === cursor) return fail();
709
+ cursor = parent;
710
+ }
711
+ }
712
+ function binTarget(manifest) {
713
+ if (typeof manifest.bin === "string") return manifest.bin;
714
+ if (manifest.bin !== null && typeof manifest.bin === "object") {
715
+ const value = manifest.bin.pi;
716
+ if (typeof value === "string") return value;
717
+ }
718
+ return fail();
719
+ }
720
+ function importTarget(manifest) {
721
+ if (manifest.exports === null || typeof manifest.exports !== "object") return fail();
722
+ const rootExport = manifest.exports["."];
723
+ if (typeof rootExport === "string") return rootExport;
724
+ if (rootExport !== null && typeof rootExport === "object") {
725
+ const value = rootExport.import;
726
+ if (typeof value === "string") return value;
727
+ }
728
+ return fail();
729
+ }
730
+ async function resolvePiRuntimeIdentity(cliCandidate) {
731
+ try {
732
+ const activeAnchor = process.argv[1];
733
+ if (typeof activeAnchor !== "string" || activeAnchor.length === 0 || !isAbsolute3(activeAnchor)) return fail();
734
+ const canonicalAnchor = await realpath3(activeAnchor);
735
+ if (cliCandidate !== void 0) {
736
+ if (!isAbsolute3(cliCandidate) || await realpath3(cliCandidate) !== canonicalAnchor) return fail();
737
+ }
738
+ const shippedModule = await realpath3(fileURLToPath(import.meta.url));
739
+ const extensionPackageRoot = await owningPackageRoot(shippedModule, "@arbiterforge/ca-pi");
740
+ let cursor = dirname2(canonicalAnchor);
741
+ let manifest;
742
+ let manifestPath = "";
743
+ while (true) {
744
+ const candidate = resolve5(cursor, "package.json");
745
+ try {
746
+ manifest = JSON.parse(await readFile(candidate, "utf8"));
747
+ manifestPath = candidate;
748
+ break;
749
+ } catch (error) {
750
+ if (error.code !== "ENOENT") return fail(error);
751
+ }
752
+ const parent = dirname2(cursor);
753
+ if (parent === cursor) return fail();
754
+ cursor = parent;
755
+ }
756
+ if (manifest.name !== "@earendil-works/pi-coding-agent" || typeof manifest.version !== "string") return fail();
757
+ const packageRoot = await realpath3(cursor);
758
+ const canonicalManifest = await realpath3(manifestPath);
759
+ if (!lexicallyInside(canonicalAnchor, packageRoot) || !lexicallyInside(canonicalManifest, packageRoot)) return fail();
760
+ if (lexicallyInside(packageRoot, extensionPackageRoot)) return fail();
761
+ const declaredBin = resolve5(packageRoot, binTarget(manifest));
762
+ if (!lexicallyInside(declaredBin, packageRoot) || await realpath3(declaredBin) !== canonicalAnchor) return fail();
763
+ if (!(await lstat2(canonicalAnchor)).isFile()) return fail();
764
+ const declaredExport = importTarget(manifest);
765
+ if (!declaredExport.startsWith("./")) return fail();
766
+ const requireFromPi = createRequire(resolve5(packageRoot, "package.json"));
767
+ const moduleEntry = await realpath3(requireFromPi.resolve(declaredExport));
768
+ if (!lexicallyInside(moduleEntry, packageRoot)) return fail();
769
+ if (!(await lstat2(moduleEntry)).isFile()) return fail();
770
+ const identity = Object.freeze({
771
+ cliEntry: canonicalAnchor,
772
+ manifestPath: canonicalManifest,
773
+ moduleEntry,
774
+ packageRoot,
775
+ version: manifest.version
776
+ });
777
+ trustedIdentities.add(identity);
778
+ return identity;
779
+ } catch (error) {
780
+ if (error instanceof Error && error.message === PI_RUNTIME_DIAGNOSIS) throw error;
781
+ return fail(error);
782
+ }
783
+ }
784
+
785
+ // src/approval-entry.ts
786
+ function approvalEntry(pi) {
787
+ let generation;
788
+ let started = false;
789
+ pi.on("input", () => ({ action: "handled" }));
790
+ for (const event of ["session_before_switch", "session_shutdown"]) {
791
+ pi.on(event, () => {
792
+ generation = void 0;
793
+ });
794
+ }
795
+ pi.on("session_start", async (_event, context) => {
796
+ const lease = {};
797
+ generation = lease;
798
+ try {
799
+ if (started) throw new Error("one dialog per process");
800
+ started = true;
801
+ const entry = await realpath4(fileURLToPath2(import.meta.url));
802
+ if (!validApprovalLaunch(process.argv.slice(2), entry, process.execArgv, process.env.NODE_OPTIONS)) {
803
+ throw new Error("unsupported launch profile");
804
+ }
805
+ const runtime = await resolvePiRuntimeIdentity();
806
+ if (runtime.version !== "1.0.2") throw new Error("unqualified host version");
807
+ const packageRoot = resolve6(dirname3(entry), "..");
808
+ const result = await runApprovalDialog(context, {
809
+ isCurrent: () => generation === lease,
810
+ activeTools: () => pi.getActiveTools(),
811
+ bridge: (cwd) => {
812
+ const python = resolvePythonCommand(process.platform, void 0, packageRoot, cwd);
813
+ return new BridgeClient({
814
+ packageRoot,
815
+ bridgeScript: resolve6(packageRoot, "hooks", "pi-approval.py"),
816
+ pythonExecutable: python.executable,
817
+ pythonPrefixArgs: ["-B", ...python.prefixArgs],
818
+ gitExecutable: resolveGitExecutable(cwd),
819
+ toolClasses: {},
820
+ maxRequestBytes: 16384,
821
+ maxStreamBytes: 16384,
822
+ shouldAuditFailure: () => false
823
+ });
824
+ }
825
+ });
826
+ context.ui.notify(
827
+ result.status === "approved" ? `Approval recorded for ${result.artifact_id}.` : result.status === "outcome-unknown" ? "Approval outcome unknown. Read the artifact's durable state before any retry." : `Approval ${result.status}; no approval was recorded by this dialog.`,
828
+ result.status === "approved" ? "info" : "warning"
829
+ );
830
+ } catch {
831
+ context.ui.notify("Pi native approval unavailable for this launch. Ordinary workflow refusal remains in force.", "warning");
832
+ } finally {
833
+ generation = void 0;
834
+ context.shutdown();
835
+ }
836
+ });
837
+ }
838
+ export {
839
+ approvalEntry as default
840
+ };
@@ -37,11 +37,11 @@
37
37
  },
38
38
  "context_qualification": {
39
39
  "darwin/amd64": {
40
- "source_commit": "f6c49397838a03a5d742cbf3fb803da798ace73b",
40
+ "source_commit": "fc35117016f47de4bbfc15195057cf57c30a8a4a",
41
41
  "workflow": ".github/workflows/ci.yml",
42
- "run_id": "37846583992",
42
+ "run_id": "37873082145",
43
43
  "binary_sha256": "433780fa59913512346069f9e1891e306a8bec41ed6fcc7cd26b50748d03ae06",
44
- "observation_sha256": "ee00612966f27685b7949d6a5656585cc7243c9409bc0d780829c4bb7a8cc106",
44
+ "observation_sha256": "b33c522a6fd07896b7a3a97a024e534c3039ad1e5d615e83745dba051d5f65f6",
45
45
  "module_sha256": "421b4db5fa1afc9a8620c5538161d8b23f193d6a1042ad1c5aa004facdd77844",
46
46
  "native_cases": [
47
47
  "TestContextBoundedRender",
@@ -53,11 +53,11 @@
53
53
  ]
54
54
  },
55
55
  "darwin/arm64": {
56
- "source_commit": "f6c49397838a03a5d742cbf3fb803da798ace73b",
56
+ "source_commit": "fc35117016f47de4bbfc15195057cf57c30a8a4a",
57
57
  "workflow": ".github/workflows/ci.yml",
58
- "run_id": "37846583992",
58
+ "run_id": "37873082145",
59
59
  "binary_sha256": "b68192c7a1da2cd2f9031f71ab4c7e015673f060d6bfdfa3aa4b57d9f090bf34",
60
- "observation_sha256": "38b15beb06fb33b83d0447a245486ba9dc9cd671e70da0d4eec7aed7073041a4",
60
+ "observation_sha256": "e6fb8c16b74f1a058fa48fc3d68bda8e867b9bbe8219c8f527159c154dd56a4c",
61
61
  "module_sha256": "421b4db5fa1afc9a8620c5538161d8b23f193d6a1042ad1c5aa004facdd77844",
62
62
  "native_cases": [
63
63
  "TestContextBoundedRender",
@@ -69,11 +69,11 @@
69
69
  ]
70
70
  },
71
71
  "linux/amd64": {
72
- "source_commit": "f6c49397838a03a5d742cbf3fb803da798ace73b",
72
+ "source_commit": "fc35117016f47de4bbfc15195057cf57c30a8a4a",
73
73
  "workflow": ".github/workflows/ci.yml",
74
- "run_id": "37846583992",
74
+ "run_id": "37873082145",
75
75
  "binary_sha256": "09582eabdef3f828a12de21b85ccc7b52a188651c2a4f69f3f776a2e62c86036",
76
- "observation_sha256": "d76858bc4e8d55cf01eeb018647f29b6aa19e3191b7a510b0097cb459c55cc90",
76
+ "observation_sha256": "da082cbcf35fd28bb0fbd2d699c8e895e1e65e38c0331f22473620582711cc3e",
77
77
  "module_sha256": "421b4db5fa1afc9a8620c5538161d8b23f193d6a1042ad1c5aa004facdd77844",
78
78
  "native_cases": [
79
79
  "TestContextBoundedRender",
@@ -85,11 +85,11 @@
85
85
  ]
86
86
  },
87
87
  "linux/arm64": {
88
- "source_commit": "f6c49397838a03a5d742cbf3fb803da798ace73b",
88
+ "source_commit": "fc35117016f47de4bbfc15195057cf57c30a8a4a",
89
89
  "workflow": ".github/workflows/ci.yml",
90
- "run_id": "37846583992",
90
+ "run_id": "37873082145",
91
91
  "binary_sha256": "f7699ce8f2f07200ea8508f21a6d369506443392aa8897d84e9d5e7dd6469770",
92
- "observation_sha256": "9856d46d215e2e067feda40d69ac5faf3547a30a9417f5a0f115195a1ac6e138",
92
+ "observation_sha256": "71d41defe020a403fd6fe890587a18daf11a7a520fa24e7667447d4fbaca133c",
93
93
  "module_sha256": "421b4db5fa1afc9a8620c5538161d8b23f193d6a1042ad1c5aa004facdd77844",
94
94
  "native_cases": [
95
95
  "TestContextBoundedRender",
@@ -101,11 +101,11 @@
101
101
  ]
102
102
  },
103
103
  "windows/amd64": {
104
- "source_commit": "f6c49397838a03a5d742cbf3fb803da798ace73b",
104
+ "source_commit": "fc35117016f47de4bbfc15195057cf57c30a8a4a",
105
105
  "workflow": ".github/workflows/ci.yml",
106
- "run_id": "37846583992",
106
+ "run_id": "37873082145",
107
107
  "binary_sha256": "9a0f186e2a1bcfd92c8f9db5cb0aa2f6dfb128dd6f0dad2708a78596f0364e16",
108
- "observation_sha256": "22ed0133fa553db2c24feaf1f9ffc9893269e188f7f6465af18cbbe2f6af6902",
108
+ "observation_sha256": "c718eff474fc9860fc58ab130ac833c5cc87107ba3e6603b648f9252bd5bad63",
109
109
  "module_sha256": "421b4db5fa1afc9a8620c5538161d8b23f193d6a1042ad1c5aa004facdd77844",
110
110
  "native_cases": [
111
111
  "TestContextBoundedRender",
@@ -117,11 +117,11 @@
117
117
  ]
118
118
  },
119
119
  "windows/arm64": {
120
- "source_commit": "f6c49397838a03a5d742cbf3fb803da798ace73b",
120
+ "source_commit": "fc35117016f47de4bbfc15195057cf57c30a8a4a",
121
121
  "workflow": ".github/workflows/ci.yml",
122
- "run_id": "37846583992",
122
+ "run_id": "37873082145",
123
123
  "binary_sha256": "3f1bc7d00f9488b4a4a9f21e5ca414fd5c97031ad7b1c2b52c02f5d7f2956e3a",
124
- "observation_sha256": "8674479ef0663b3753bb21f2c1ce62c0b952d63efcdcb14098852d110189b04f",
124
+ "observation_sha256": "78f6ccf77cf840641372bf17481dc1dd6a0aa5062811455e3fd6be9a6bfa85d6",
125
125
  "module_sha256": "421b4db5fa1afc9a8620c5538161d8b23f193d6a1042ad1c5aa004facdd77844",
126
126
  "native_cases": [
127
127
  "TestContextBoundedRender",
@@ -26,6 +26,7 @@ TOKEN_RE = re.compile(r"[A-Za-z0-9_-]{12,128}")
26
26
  HOST_RE = re.compile(r"[A-Za-z0-9_-]{1,32}")
27
27
  SESSION_RE = re.compile(r"[A-Za-z0-9._:-]{1,256}")
28
28
  SHA256_RE = re.compile(r"[0-9a-f]{64}")
29
+ PI_DIALOG_SESSION_RE = re.compile(r"[0-9a-f]{8}(?:-[0-9a-f]{4}){3}-[0-9a-f]{12}:[0-9a-f]{8}(?:-[0-9a-f]{4}){3}-[0-9a-f]{12}")
29
30
 
30
31
 
31
32
  class ApprovalError(RuntimeError):
@@ -384,14 +385,29 @@ def consume_user_approval(
384
385
  host: str,
385
386
  session_id: str,
386
387
  seam: str = "UserPromptSubmit",
388
+ expected_pending_sha256: str | None = None,
387
389
  ) -> dict[str, Any]:
388
390
  root = Path(root).resolve(strict=True)
389
- if seam not in {"UserPromptSubmit", "AskUserQuestion"}:
391
+ if seam not in {"UserPromptSubmit", "AskUserQuestion", "PiNativeInput"}:
390
392
  raise ApprovalError("INVALID_HOST_CONTEXT", "approval seam is unsupported")
393
+ if host == "pi" or seam == "PiNativeInput":
394
+ # Only the isolated Pi dialog producer owns this seam. Its native session
395
+ # and fresh dialog generation travel together; ordinary Pi input events
396
+ # can contain transformed text and cannot use the prompt-hook route.
397
+ if (host != "pi" or seam != "PiNativeInput" or not isinstance(session_id, str)
398
+ or PI_DIALOG_SESSION_RE.fullmatch(session_id) is None
399
+ or not isinstance(expected_pending_sha256, str)
400
+ or SHA256_RE.fullmatch(expected_pending_sha256) is None):
401
+ raise ApprovalError("INVALID_HOST_CONTEXT", "Pi approval requires its native dialog session and generation")
391
402
  pending = _load_pending(root)
392
403
  if pending is None or not isinstance(prompt, str):
393
404
  return {"matched": False, "approved": False}
405
+ if expected_pending_sha256 is not None and not secrets.compare_digest(
406
+ hashlib.sha256(_canonical(pending)).hexdigest(), expected_pending_sha256):
407
+ raise ApprovalError("STALE_APPROVAL", "approval request changed while native input was pending")
394
408
  if pending["format"] == "codearbiter.pending-user-approval/0.2.0":
409
+ if host == "pi":
410
+ raise ApprovalError("INVALID_HOST_CONTEXT", "paired Pi approval is not qualified")
395
411
  import _sprintapprovallib
396
412
  return _sprintapprovallib.consume(root, client, pending, prompt, host=host, session_id=session_id, seam=seam)
397
413
  parts = prompt.split(" ")
@@ -386,7 +386,7 @@ def _installed_workflow_preflight(installation: Path) -> dict[str, object]:
386
386
  present = [row for row in manifests if (plugin / row[2]).exists()
387
387
  or (plugin / row[2]).is_symlink()]
388
388
  if not present:
389
- present = [("pi", "ca-pi", "package.json")]
389
+ present = [("pi", "@arbiterforge/ca-pi", "package.json")]
390
390
  if len(present) != 1:
391
391
  result["missing"] = ["one unambiguous installed host manifest"]
392
392
  return result
@@ -11,7 +11,7 @@ import hostapi # noqa: E402
11
11
  class PiHost(hostapi.Host):
12
12
  name = "pi"
13
13
  adapter_name = "@arbiterforge/ca-pi"
14
- adapter_version = "0.17.1"
14
+ adapter_version = "0.17.2"
15
15
  update_target = "ca-pi"
16
16
  update_tag_prefix = "ca-pi-v"
17
17
  update_command = "pi update npm:@arbiterforge/ca-pi"
@@ -313,7 +313,7 @@ class Host:
313
313
 
314
314
  name = "claude"
315
315
  adapter_name = "ca"
316
- adapter_version = "2.25.1"
316
+ adapter_version = "2.25.2"
317
317
 
318
318
  # Update-notifier descriptor. Each independently versioned host overrides
319
319
  # these three values in its per-plugin _host.py. Keeping the target,
@@ -0,0 +1,94 @@
1
+ #!/usr/bin/env python3
2
+ # codeArbiter — private bridge for the isolated native Pi approval dialog.
3
+ """Inspect one armed artifact or consume the exact native dialog return.
4
+
5
+ The sole-extension host controller owns input provenance, trust and lifecycle.
6
+ This bridge is not a public approval command or an attestation against arbitrary
7
+ same-user code. It preserves the shared one-shot identity and receipt controls.
8
+ handle(request, client) -> bounded Pi bridge response (client is a fixture seam).
9
+ """
10
+ from __future__ import annotations
11
+
12
+ import hashlib
13
+ import json
14
+ from pathlib import Path
15
+ import re
16
+ import sys
17
+
18
+ import _approvallib
19
+ import _artifactlib
20
+
21
+ UUID = re.compile(r"[0-9a-f]{8}(?:-[0-9a-f]{4}){3}-[0-9a-f]{12}")
22
+
23
+
24
+ def handle(request, client=None):
25
+ if not isinstance(request, dict) or request.get("version") != 1:
26
+ raise ValueError("invalid request")
27
+ event = request.get("event")
28
+ expected = {"version", "event", "cwd"}
29
+ if event == "native_approval_consume":
30
+ expected |= {"sessionId", "input"}
31
+ elif event != "native_approval_inspect":
32
+ raise ValueError("unsupported request")
33
+ if set(request) != expected or not isinstance(request["cwd"], str):
34
+ raise ValueError("invalid request shape")
35
+ root = _artifactlib._trusted_directory(Path(request["cwd"]), "UNSAFE_ROOT")
36
+ if client is None:
37
+ client = _artifactlib.ArtifactClient(root, _artifactlib.helper_installation(__file__))
38
+ if event == "native_approval_inspect":
39
+ pending = _approvallib._load_pending(root)
40
+ if (pending is None or pending["format"] != "codearbiter.pending-user-approval/0.1.0"
41
+ or pending["kind"] not in {"spec", "plan"}):
42
+ raise ValueError("no supported pending approval")
43
+ current = _approvallib._identity(client, pending["artifact_id"])
44
+ if any(current[field] != pending[field] for field in (
45
+ "kind", "revision", "model_sha256", "normative_sha256")):
46
+ raise ValueError("stale pending approval")
47
+ _approvallib._ready(client, current)
48
+ result = {key: pending[key] for key in (
49
+ "artifact_id", "kind", "revision", "model_sha256", "normative_sha256")}
50
+ result["pending_sha256"] = hashlib.sha256(_approvallib._canonical(pending)).hexdigest()
51
+ else:
52
+ value = request["input"]
53
+ if (not isinstance(value, dict) or set(value) != {"reply", "generation", "pending_sha256"}
54
+ or not isinstance(value["reply"], str) or len(value["reply"].encode("utf-8")) > 512
55
+ or not isinstance(request["sessionId"], str) or UUID.fullmatch(request["sessionId"]) is None
56
+ or not isinstance(value["generation"], str) or UUID.fullmatch(value["generation"]) is None
57
+ or not isinstance(value["pending_sha256"], str)
58
+ or _approvallib.SHA256_RE.fullmatch(value["pending_sha256"]) is None):
59
+ raise ValueError("invalid native input binding")
60
+ captured = _approvallib.consume_user_approval(
61
+ root, client, value["reply"], host="pi", seam="PiNativeInput",
62
+ session_id=request["sessionId"] + ":" + value["generation"],
63
+ expected_pending_sha256=value["pending_sha256"],
64
+ )
65
+ result = {key: captured[key] for key in ("matched", "approved", "artifact_id") if key in captured}
66
+ return {"version": 1, "outcome": "allow", "resultPatch": result}
67
+
68
+
69
+ def unique_object(pairs):
70
+ value = {}
71
+ for key, item in pairs:
72
+ if key in value:
73
+ raise ValueError("duplicate field")
74
+ value[key] = item
75
+ return value
76
+
77
+
78
+ def main():
79
+ try:
80
+ data = sys.stdin.buffer.read(16_385)
81
+ if len(sys.argv) != 1 or len(data) > 16_384:
82
+ raise ValueError("invalid request size")
83
+ request = json.loads(data.decode("utf-8"), object_pairs_hook=unique_object,
84
+ parse_constant=lambda _: (_ for _ in ()).throw(ValueError("invalid number")))
85
+ response = handle(request)
86
+ except (ValueError, OSError, _approvallib.ApprovalError, _artifactlib.ArtifactError):
87
+ # No source reply, token, path, or potentially sensitive host error is logged.
88
+ response = {"version": 1, "outcome": "block", "auditCode": "PI_NATIVE_APPROVAL_UNAVAILABLE"}
89
+ print(json.dumps(response, ensure_ascii=True, allow_nan=False))
90
+ return 0
91
+
92
+
93
+ if __name__ == "__main__":
94
+ raise SystemExit(main())
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@arbiterforge/ca-pi",
3
- "version": "0.17.1",
3
+ "version": "0.17.2",
4
4
  "private": true,
5
5
  "license": "AGPL-3.0-only",
6
6
  "type": "module",