@estebanforge/pi-antigravity-bridge 1.4.9 → 1.4.10
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 +13 -0
- package/README.md +36 -1
- package/docs/ACP-ADOPTION-PLAN.md +83 -18
- package/docs/ACP-PROTOCOL-REFERENCE.md +4 -3
- package/docs/ARCHITECTURE.md +12 -5
- package/docs/DEVELOPMENT.md +16 -0
- package/docs/PI-BRIDGE-GAPS.md +26 -5
- package/docs/TODO.md +21 -0
- package/extensions/index.ts +169 -14
- package/package.json +1 -1
- package/src/acp/driver.ts +9 -8
- package/src/approval-detect.ts +146 -0
- package/src/approval-gate.ts +208 -0
- package/src/approval-hook.ts +252 -0
- package/src/config.ts +43 -0
- package/src/driver-types.ts +9 -9
- package/src/driver.ts +6 -5
- package/src/mcp-registration.ts +127 -0
- package/src/mcp-server.ts +192 -10
- package/src/models.ts +2 -2
- package/src/provider.ts +209 -26
package/extensions/index.ts
CHANGED
|
@@ -17,10 +17,15 @@
|
|
|
17
17
|
// ~/.pi/agent/antigravity-bridge/config.json so toggles survive restarts.
|
|
18
18
|
|
|
19
19
|
import os from "node:os";
|
|
20
|
+
import fs from "node:fs";
|
|
21
|
+
import path from "node:path";
|
|
20
22
|
import {
|
|
21
23
|
type ExtensionAPI,
|
|
22
24
|
type ExtensionCommandContext,
|
|
23
25
|
type ExtensionUIContext,
|
|
26
|
+
createBashToolDefinition,
|
|
27
|
+
createEditToolDefinition,
|
|
28
|
+
createWriteToolDefinition,
|
|
24
29
|
getSettingsListTheme,
|
|
25
30
|
} from "@earendil-works/pi-coding-agent";
|
|
26
31
|
import {
|
|
@@ -38,6 +43,7 @@ import {
|
|
|
38
43
|
} from "../src/models.js";
|
|
39
44
|
import { SessionStore } from "../src/sessions.js";
|
|
40
45
|
import {
|
|
46
|
+
APPROVAL_PARK_MS,
|
|
41
47
|
POLL_TOOL_NAME,
|
|
42
48
|
ToolRoundTrips,
|
|
43
49
|
WrapperReplay,
|
|
@@ -45,7 +51,15 @@ import {
|
|
|
45
51
|
formatEscalatedAck,
|
|
46
52
|
formatPollAnswer,
|
|
47
53
|
} from "../src/provider.js";
|
|
48
|
-
import {
|
|
54
|
+
import {
|
|
55
|
+
createShadowTool,
|
|
56
|
+
stripMarkerFields,
|
|
57
|
+
type AnyToolDefinition,
|
|
58
|
+
type GatePolicy,
|
|
59
|
+
} from "../src/approval-gate.js";
|
|
60
|
+
import { detectPermissionGateExtensions, resolveGateMode } from "../src/approval-detect.js";
|
|
61
|
+
import { hookScriptSource, removeGateHooks, stageGateHooks } from "../src/approval-hook.js";
|
|
62
|
+
import { StreamDriver } from "../src/driver.js";
|
|
49
63
|
import { AcpDriver } from "../src/acp/driver.js";
|
|
50
64
|
import { runAcpAuth } from "../src/acp/auth.js";
|
|
51
65
|
import { setupAuthUrlCapture } from "../src/acp/browser-capture.js";
|
|
@@ -55,6 +69,11 @@ import { CONFIG_PATH, loadConfig, logsDir, saveConfig, type AgyMode, type Bridge
|
|
|
55
69
|
import { createDailyLogger, type DailyLogger } from "../src/daily-log.js";
|
|
56
70
|
import { registerAskAntigravityTool, toolModelsFromRaw } from "../src/ask-tool.js";
|
|
57
71
|
import { startMcpServer, TOKEN_HEADER, type McpServerHandle } from "../src/mcp-server.js";
|
|
72
|
+
import {
|
|
73
|
+
registerBridgeServer,
|
|
74
|
+
sweepStaleBridgeServers,
|
|
75
|
+
unregisterBridgeServer,
|
|
76
|
+
} from "../src/mcp-registration.js";
|
|
58
77
|
import {
|
|
59
78
|
ACTIVATE_SKILL_TOOL_NAME,
|
|
60
79
|
activateSkillSchema,
|
|
@@ -107,7 +126,7 @@ export default async function (pi: ExtensionAPI): Promise<void> {
|
|
|
107
126
|
const engine: Engine = loadConfig().engine;
|
|
108
127
|
// Engine switching requires a restart, so the catalog-time engine read is
|
|
109
128
|
// authoritative for input advertising: image attach rides only when turns
|
|
110
|
-
// will run on the ACP engine (the
|
|
129
|
+
// will run on the ACP engine (the stream-json CLI prompt is text-only).
|
|
111
130
|
const modelInput: Array<"text" | "image"> = engine === "acp" ? ["text", "image"] : ["text"];
|
|
112
131
|
const models = entries.map((e) => toPiModel(e, modelInput));
|
|
113
132
|
|
|
@@ -156,6 +175,9 @@ export default async function (pi: ExtensionAPI): Promise<void> {
|
|
|
156
175
|
// MCP bridge handle, declared early: the ACP engine reads the bridge port
|
|
157
176
|
// at session/new / session/load time.
|
|
158
177
|
let mcpHandle: McpServerHandle | null = null;
|
|
178
|
+
// Approval-gate hook script (per-pid, token embedded). Written at session
|
|
179
|
+
// start when the gate is active; removed at session_shutdown.
|
|
180
|
+
let gateScriptPath: string | null = null;
|
|
159
181
|
// ACP self-heal runs once per process (session_start re-fires on /reload;
|
|
160
182
|
// a ready setup is two file stats, so re-running is harmless anyway).
|
|
161
183
|
let acpSelfHealRan = false;
|
|
@@ -184,11 +206,11 @@ export default async function (pi: ExtensionAPI): Promise<void> {
|
|
|
184
206
|
"session-load-failed-creating-fresh", "connection-exited", "cancel-failed",
|
|
185
207
|
"unsupported-server-request",
|
|
186
208
|
]);
|
|
187
|
-
const
|
|
188
|
-
// Mirror the
|
|
209
|
+
const streamDriver = new StreamDriver();
|
|
210
|
+
// Mirror the stream driver's lifecycle ring into the daily file log
|
|
189
211
|
// (spawn/exit/abort/stall/recycle). The ACP driver reaches the same file
|
|
190
212
|
// through acpLog below.
|
|
191
|
-
|
|
213
|
+
streamDriver.log = (msg, data) => {
|
|
192
214
|
// Level classification mirrors acpLog's failure set: stalls, aborts,
|
|
193
215
|
// timeouts and nonzero exits are the "what broke" greps (warn);
|
|
194
216
|
// turn-start is the per-turn skeleton (info); everything else is
|
|
@@ -260,7 +282,7 @@ export default async function (pi: ExtensionAPI): Promise<void> {
|
|
|
260
282
|
const handle = mcpHandle;
|
|
261
283
|
if (!handle) return [];
|
|
262
284
|
// The bridge 403s any request without the shared-secret header; the
|
|
263
|
-
//
|
|
285
|
+
// stream engine carries it via mcp_config.json, ACP via headers[].
|
|
264
286
|
return [
|
|
265
287
|
{
|
|
266
288
|
name: "pi-bridge",
|
|
@@ -272,15 +294,23 @@ export default async function (pi: ExtensionAPI): Promise<void> {
|
|
|
272
294
|
},
|
|
273
295
|
});
|
|
274
296
|
// The active engine is resolved from the latched load-time value.
|
|
275
|
-
const activeDriver = (): TurnDriver => (engine === "acp" ? acpDriver :
|
|
276
|
-
// The provider's stream-json slot gets the
|
|
297
|
+
const activeDriver = (): TurnDriver => (engine === "acp" ? acpDriver : streamDriver);
|
|
298
|
+
// The provider's stream-json slot gets the STREAM driver explicitly - never
|
|
277
299
|
// activeDriver(), or a load-time acp engine would make deps.driver and
|
|
278
300
|
// deps.acpDriver the same object and break the engine identity check.
|
|
279
|
-
const driver =
|
|
301
|
+
const driver = streamDriver;
|
|
280
302
|
// The no-patch pi-tool round-trip store: the MCP bridge parks calls here;
|
|
281
303
|
// the provider emits them as real pi toolUse turns and completes them from
|
|
282
304
|
// the next call's toolResult.
|
|
283
|
-
const roundTrips = new ToolRoundTrips(
|
|
305
|
+
const roundTrips = new ToolRoundTrips(
|
|
306
|
+
activeDriver,
|
|
307
|
+
(s, d, level) =>
|
|
308
|
+
fileLog.log(
|
|
309
|
+
s,
|
|
310
|
+
d,
|
|
311
|
+
level ?? (s === "round-trip-fail" ? "warn" : "debug"),
|
|
312
|
+
),
|
|
313
|
+
);
|
|
284
314
|
const replay = new WrapperReplay();
|
|
285
315
|
// Native re-exec only emits for builtins actually active in the session;
|
|
286
316
|
// anything else (or an unknown name) falls back to the wrapper card.
|
|
@@ -306,7 +336,7 @@ export default async function (pi: ExtensionAPI): Promise<void> {
|
|
|
306
336
|
);
|
|
307
337
|
roundTrips.failAll("antigravity turn ended with an unresolved pi tool call");
|
|
308
338
|
};
|
|
309
|
-
|
|
339
|
+
streamDriver.onTurnEnd = onTurnEnd;
|
|
310
340
|
acpDriver.onTurnEnd = onTurnEnd;
|
|
311
341
|
const streamSimple = createStreamSimple({
|
|
312
342
|
entries,
|
|
@@ -557,9 +587,113 @@ export default async function (pi: ExtensionAPI): Promise<void> {
|
|
|
557
587
|
isError: !skill,
|
|
558
588
|
});
|
|
559
589
|
};
|
|
560
|
-
const r = await startMcpServer(
|
|
590
|
+
const r = await startMcpServer(
|
|
591
|
+
{
|
|
592
|
+
listTools,
|
|
593
|
+
onToolCall: bridgeOnToolCall,
|
|
594
|
+
onApproval: (ticket, payload) => roundTrips.onApproval(ticket, payload),
|
|
595
|
+
},
|
|
596
|
+
{ log: mcpLog },
|
|
597
|
+
);
|
|
561
598
|
if (r.ok && r.handle) {
|
|
562
599
|
mcpHandle = r.handle;
|
|
600
|
+
// Stream-json engine registration: the agy CLI discovers MCP servers
|
|
601
|
+
// from ~/.gemini/config/mcp_config.json (ACP uses session/new
|
|
602
|
+
// mcpServers instead; verified live 2026-09-07). Per-pid entry,
|
|
603
|
+
// removed at session_shutdown; stale entries swept at start.
|
|
604
|
+
sweepStaleBridgeServers();
|
|
605
|
+
registerBridgeServer({
|
|
606
|
+
pid: process.pid,
|
|
607
|
+
port: r.handle.port,
|
|
608
|
+
token: r.handle.token,
|
|
609
|
+
tokenHeader: TOKEN_HEADER,
|
|
610
|
+
});
|
|
611
|
+
// --- Approval gate (docs/TODO.md 2.5) ------------------------------
|
|
612
|
+
// agy native tool calls pass through a pi-side approval: a PreToolUse
|
|
613
|
+
// hook parks in the bridge, the provider emits a shadow toolUse, and
|
|
614
|
+
// pi's permission extensions gate it like any native call. Off until
|
|
615
|
+
// enabled (auto = on only when a third-party gate extension exists).
|
|
616
|
+
{
|
|
617
|
+
const cfg = loadConfig();
|
|
618
|
+
const mode = resolveGateMode(cfg.approvals.gateMode, detectPermissionGateExtensions());
|
|
619
|
+
if (mode === "dedicated") {
|
|
620
|
+
// The explicit antigravity_approve variant is planned; until it
|
|
621
|
+
// ships, dedicated stages the same shadow tools. Say so, so the
|
|
622
|
+
// config value never lies silently.
|
|
623
|
+
fileLog.log("approval-dedicated-as-shadow", {}, "warn");
|
|
624
|
+
}
|
|
625
|
+
if (mode === "off") {
|
|
626
|
+
// Gate off: remove ONLY this session's group. Other sessions'
|
|
627
|
+
// groups in a shared workspace are never touched - a gate-off
|
|
628
|
+
// session must not strip a gate-on session's matchers.
|
|
629
|
+
const unstaged = removeGateHooks(process.cwd());
|
|
630
|
+
if (unstaged.wrote) fileLog.log("approval-unstaged", unstaged, "info");
|
|
631
|
+
} else {
|
|
632
|
+
// Script: per-pid file; 0600 because the bridge token is
|
|
633
|
+
// embedded (peer review 2026-09-07).
|
|
634
|
+
const scriptPath = path.join(logsDir(), `approval-hook-${process.pid}.js`);
|
|
635
|
+
fs.mkdirSync(path.dirname(scriptPath), { recursive: true, mode: 0o700 });
|
|
636
|
+
fs.writeFileSync(
|
|
637
|
+
scriptPath,
|
|
638
|
+
hookScriptSource({
|
|
639
|
+
port: r.handle.port,
|
|
640
|
+
token: r.handle.token,
|
|
641
|
+
deadlineMs: APPROVAL_PARK_MS,
|
|
642
|
+
}),
|
|
643
|
+
{ mode: 0o600 },
|
|
644
|
+
);
|
|
645
|
+
gateScriptPath = scriptPath;
|
|
646
|
+
const staged = stageGateHooks(process.cwd(), {
|
|
647
|
+
port: r.handle.port,
|
|
648
|
+
token: r.handle.token,
|
|
649
|
+
scriptPath,
|
|
650
|
+
parkBudgetMs: APPROVAL_PARK_MS,
|
|
651
|
+
});
|
|
652
|
+
fileLog.log("approval-staged", { mode, script: scriptPath, ...staged }, staged.wrote ? "info" : "debug");
|
|
653
|
+
|
|
654
|
+
// Shadow bases: factory twins of pi's own builtins (public API).
|
|
655
|
+
// pi.getAllTools() is unusable here: it returns ToolInfo, which
|
|
656
|
+
// strips execute. Marker calls never execute; non-marker calls
|
|
657
|
+
// delegate to the twins, so behavior matches the standard
|
|
658
|
+
// builtins (session-level bash-operations overrides are not
|
|
659
|
+
// inherited; documented in README).
|
|
660
|
+
const gateCwd = process.cwd();
|
|
661
|
+
const bases: Record<string, AnyToolDefinition> = {
|
|
662
|
+
bash: createBashToolDefinition(gateCwd) as unknown as AnyToolDefinition,
|
|
663
|
+
write: createWriteToolDefinition(gateCwd) as unknown as AnyToolDefinition,
|
|
664
|
+
edit: createEditToolDefinition(gateCwd) as unknown as AnyToolDefinition,
|
|
665
|
+
};
|
|
666
|
+
const askMode = cfg.approvals.mode;
|
|
667
|
+
const policy: GatePolicy = async ({ tool, params, ctx }) => {
|
|
668
|
+
if (askMode === "allow") return { allow: true };
|
|
669
|
+
if (askMode === "deny") {
|
|
670
|
+
return { allow: false, reason: `blocked by approval gate (mode: deny): ${tool}` };
|
|
671
|
+
}
|
|
672
|
+
const extCtx = ctx as { hasUI?: boolean; ui?: Pick<ExtensionUIContext, "confirm"> } | undefined;
|
|
673
|
+
if (!extCtx?.hasUI || typeof extCtx.ui?.confirm !== "function") {
|
|
674
|
+
return { allow: false, reason: `approval gate: no UI to approve ${tool} (headless)` };
|
|
675
|
+
}
|
|
676
|
+
const what =
|
|
677
|
+
typeof params.command === "string"
|
|
678
|
+
? params.command
|
|
679
|
+
: typeof params.path === "string"
|
|
680
|
+
? params.path
|
|
681
|
+
: JSON.stringify(stripMarkerFields(params)).slice(0, 200);
|
|
682
|
+
const ok = await extCtx.ui.confirm(`agy ${tool}?`, what, { timeout: APPROVAL_PARK_MS });
|
|
683
|
+
return ok ? { allow: true } : { allow: false, reason: `declined in pi (agy ${tool})` };
|
|
684
|
+
};
|
|
685
|
+
const handle = r.handle;
|
|
686
|
+
for (const [name, base] of Object.entries(bases)) {
|
|
687
|
+
pi.registerTool(
|
|
688
|
+
createShadowTool(base, policy, { verifyTicket: (t) => handle.approvals.has(t) }),
|
|
689
|
+
);
|
|
690
|
+
}
|
|
691
|
+
// The park is wired ONLY after the shadows are registered: an
|
|
692
|
+
// approval toolUse must never dispatch to the REAL builtin and
|
|
693
|
+
// execute locally.
|
|
694
|
+
roundTrips.approvalPark = handle.approvals;
|
|
695
|
+
}
|
|
696
|
+
}
|
|
563
697
|
} else {
|
|
564
698
|
console.error(`[antigravity-bridge] MCP tool bridge disabled: ${r.reason}`);
|
|
565
699
|
}
|
|
@@ -572,8 +706,29 @@ export default async function (pi: ExtensionAPI): Promise<void> {
|
|
|
572
706
|
mcpHandle = null;
|
|
573
707
|
await h?.close();
|
|
574
708
|
roundTrips.failAll("antigravity session shut down");
|
|
575
|
-
|
|
576
|
-
|
|
709
|
+
// "recycle", NOT "shutdown": pi fires session_shutdown on /new, /resume
|
|
710
|
+
// and /fork (docs/extensions.md session lifecycle), not only on process
|
|
711
|
+
// exit. The drivers are process-lifetime singletons; closing them with
|
|
712
|
+
// "shutdown" latched them permanently and every later turn failed with
|
|
713
|
+
// "ACP driver is shut down." (regression 2026-09-07). Recycle kills the
|
|
714
|
+
// connection now; the next turn respawns it. event.reason is
|
|
715
|
+
// deliberately ignored: recycle is correct even on real process exit
|
|
716
|
+
// ("quit") - the connection kill is identical and nothing runs after.
|
|
717
|
+
await streamDriver.close("recycle", "session shutdown");
|
|
718
|
+
await acpDriver.close("recycle", "session shutdown");
|
|
719
|
+
unregisterBridgeServer(process.pid);
|
|
720
|
+
// Approval gate: unstage hooks and remove the per-pid script. Pending
|
|
721
|
+
// approvals already failed closed via handle close (bridge shutdown deny).
|
|
722
|
+
const unstaged = removeGateHooks(process.cwd());
|
|
723
|
+
if (unstaged.wrote) fileLog.log("approval-unstaged", unstaged, "info");
|
|
724
|
+
if (gateScriptPath) {
|
|
725
|
+
try {
|
|
726
|
+
fs.rmSync(gateScriptPath, { force: true });
|
|
727
|
+
} catch {
|
|
728
|
+
/* best effort */
|
|
729
|
+
}
|
|
730
|
+
gateScriptPath = null;
|
|
731
|
+
}
|
|
577
732
|
});
|
|
578
733
|
}
|
|
579
734
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@estebanforge/pi-antigravity-bridge",
|
|
3
|
-
"version": "1.4.
|
|
3
|
+
"version": "1.4.10",
|
|
4
4
|
"description": "Gemini provider for Pi on the Antigravity ACP server (official Google ACP) or the stream-json agy CLI. antigravity/* models in Pi's /model picker, no-patch MCP bridge: agy runs Pi's tools. ToS safe to use.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"pi-package",
|
package/src/acp/driver.ts
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
// AcpDriver: the ACP turn engine. Implements the same TurnDriver surface as
|
|
2
|
-
// the
|
|
2
|
+
// the stream-json driver (see src/driver-types.ts) so provider.ts and
|
|
3
3
|
// the G9 round-trip store work unchanged.
|
|
4
4
|
//
|
|
5
|
-
// Engine differences vs
|
|
5
|
+
// Engine differences vs stream-json, all verified live (docs/ACP-PROTOCOL-REFERENCE.md):
|
|
6
6
|
// - no process recycle on profile drift: one server process, sessions
|
|
7
7
|
// selected per turn via session/new / session/load
|
|
8
8
|
// - model/effort via session/set_config_option (configId "model", FULL slug
|
|
@@ -110,7 +110,6 @@ export class AcpDriver implements TurnDriver {
|
|
|
110
110
|
#generation = 0;
|
|
111
111
|
#active: ActiveTurn | undefined;
|
|
112
112
|
#queueTail: Promise<void> = Promise.resolve();
|
|
113
|
-
#shutdown = false;
|
|
114
113
|
#lifecycle: string[] = [];
|
|
115
114
|
#onTurnEnd: ((outcome: TurnOutcome) => void) | undefined;
|
|
116
115
|
#stats = {
|
|
@@ -168,7 +167,7 @@ export class AcpDriver implements TurnDriver {
|
|
|
168
167
|
}
|
|
169
168
|
|
|
170
169
|
/** Turns are serialized; a parked turn stays open and the continuation
|
|
171
|
-
* path uses reentry() (same contract as the
|
|
170
|
+
* path uses reentry() (same contract as the stream-json driver). */
|
|
172
171
|
run(request: DriverTurnRequest): Promise<TurnHandle> {
|
|
173
172
|
let release!: () => void;
|
|
174
173
|
const prev = this.#queueTail;
|
|
@@ -190,7 +189,10 @@ export class AcpDriver implements TurnDriver {
|
|
|
190
189
|
}
|
|
191
190
|
|
|
192
191
|
#runExclusive(request: DriverTurnRequest): Promise<TurnHandle> {
|
|
193
|
-
|
|
192
|
+
// No shutdown latch here: pi fires session_shutdown on /new, /resume and
|
|
193
|
+
// /fork (not only process exit), so a closed driver must respawn on the
|
|
194
|
+
// next turn instead of rejecting forever. Regression 2026-09-07:
|
|
195
|
+
// /compact after a model switch failed with "ACP driver is shut down."
|
|
194
196
|
if (request.signal?.aborted) return Promise.reject(new Error("aborted before start"));
|
|
195
197
|
|
|
196
198
|
const turn = this.#createTurn(request);
|
|
@@ -218,7 +220,7 @@ export class AcpDriver implements TurnDriver {
|
|
|
218
220
|
}
|
|
219
221
|
|
|
220
222
|
// Execute asynchronously: the handle returns as soon as the prompt is
|
|
221
|
-
// dispatched, and activities stream through next() (
|
|
223
|
+
// dispatched, and activities stream through next() (stream-json contract).
|
|
222
224
|
void this.#executeTurn(turn).catch((err: unknown) => {
|
|
223
225
|
this.#failTurn(turn, `ACP turn failed: ${describe(err)}`);
|
|
224
226
|
});
|
|
@@ -679,7 +681,6 @@ export class AcpDriver implements TurnDriver {
|
|
|
679
681
|
// --- TurnDriver surface ----------------------------------------------------
|
|
680
682
|
|
|
681
683
|
async close(reason: "recycle" | "shutdown", cause?: string): Promise<void> {
|
|
682
|
-
if (reason === "shutdown") this.#shutdown = true;
|
|
683
684
|
this.#log(`close:${reason}${cause ? `:${cause}` : ""}`);
|
|
684
685
|
const turn = this.#active;
|
|
685
686
|
if (turn && !turn.closed) {
|
|
@@ -687,7 +688,7 @@ export class AcpDriver implements TurnDriver {
|
|
|
687
688
|
conversationId: turn.sessionId,
|
|
688
689
|
status: "ERROR",
|
|
689
690
|
response: turn.response.text,
|
|
690
|
-
error: `ACP driver ${reason}
|
|
691
|
+
error: `ACP driver ${reason === "recycle" ? "recycled" : "shut down"} mid-turn${cause ? ` (${cause})` : ""}`,
|
|
691
692
|
finished: true,
|
|
692
693
|
aborted: false,
|
|
693
694
|
});
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
// Third-party pi permission-extension detection for the approval gate
|
|
2
|
+
// (docs/TODO.md 2.5). The gate defaults to "auto": OFF until one of these
|
|
3
|
+
// extensions is present. Detection reads pi's settings (the packages array
|
|
4
|
+
// is the primary, name-exact signal) plus known on-disk config markers for
|
|
5
|
+
// the audited permission packages (sources: ~/tmp/pi-perm-research/, audit
|
|
6
|
+
// 2026-09-07). Best effort by design: a miss only means the user enables
|
|
7
|
+
// the gate manually; a false positive stages hooks.json, which is inert
|
|
8
|
+
// unless the ACP/CLI server loads it, and observation-only hooks are safe.
|
|
9
|
+
//
|
|
10
|
+
// Run: npm test
|
|
11
|
+
|
|
12
|
+
import fs from "node:fs";
|
|
13
|
+
import os from "node:os";
|
|
14
|
+
import path from "node:path";
|
|
15
|
+
|
|
16
|
+
/** npm names of the audited pi permission packages (docs/TODO.md 2.4). */
|
|
17
|
+
export const KNOWN_GATE_PACKAGES = [
|
|
18
|
+
"@gotgenes/pi-permission-system",
|
|
19
|
+
"@zhushanwen/pi-permission",
|
|
20
|
+
"pi-permission-system",
|
|
21
|
+
"@xzzpig/pi-permission-system",
|
|
22
|
+
"@diegopetrucci/pi-permission-gate",
|
|
23
|
+
"pi-permission-modes",
|
|
24
|
+
"@inobit/pi-permission",
|
|
25
|
+
"@thurstonsand/pi-permissions",
|
|
26
|
+
"@monroewilliams/pi-permission-system",
|
|
27
|
+
"@rhedbull/pi-permissions",
|
|
28
|
+
] as const;
|
|
29
|
+
|
|
30
|
+
export interface GateExtensionHit {
|
|
31
|
+
/** The known package name matched. */
|
|
32
|
+
name: string;
|
|
33
|
+
/** How it was detected: "settings:<path>" or "config:<path>". */
|
|
34
|
+
evidence: string;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function readJsonIfPresent(file: string): { packages?: unknown } | undefined {
|
|
38
|
+
try {
|
|
39
|
+
const parsed = JSON.parse(fs.readFileSync(file, "utf8"));
|
|
40
|
+
return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : undefined;
|
|
41
|
+
} catch {
|
|
42
|
+
return undefined;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function packagesFrom(settingsFile: string): string[] {
|
|
47
|
+
const parsed = readJsonIfPresent(settingsFile);
|
|
48
|
+
const raw = parsed?.packages;
|
|
49
|
+
if (!Array.isArray(raw)) return [];
|
|
50
|
+
return raw.filter((entry): entry is string => typeof entry === "string");
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function nameMatchesPackage(entry: string): string | undefined {
|
|
54
|
+
// entries look like "npm:@scope/name@1.2.3", "git:...", or a bare path.
|
|
55
|
+
// Boundary-aware match: the known name must start after :/@ (or the
|
|
56
|
+
// string start) and must not be a prefix of a longer package name
|
|
57
|
+
// ("pi-permission-system-clone" must NOT match). Version suffix @x.y is
|
|
58
|
+
// fine. Peer review 2026-09-07 finding 4.
|
|
59
|
+
const lower = entry.toLowerCase();
|
|
60
|
+
let best: string | undefined;
|
|
61
|
+
for (const name of KNOWN_GATE_PACKAGES) {
|
|
62
|
+
if (containsName(lower, name) && (best === undefined || name.length > best.length)) best = name;
|
|
63
|
+
}
|
|
64
|
+
// Longest match wins: "@xzzpig/pi-permission-system" must resolve to the
|
|
65
|
+
// scoped name, not to the shorter unscoped fork "pi-permission-system".
|
|
66
|
+
return best;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function containsName(lower: string, name: string): boolean {
|
|
70
|
+
let idx = lower.indexOf(name);
|
|
71
|
+
while (idx >= 0) {
|
|
72
|
+
const before = idx === 0 ? "" : lower[idx - 1];
|
|
73
|
+
const after = lower[idx + name.length] ?? "";
|
|
74
|
+
const okBefore = before === "" || ":/@".includes(before);
|
|
75
|
+
const okAfter = after === "" || after === "@" || !/[a-z0-9_-]/.test(after);
|
|
76
|
+
if (okBefore && okAfter) return true;
|
|
77
|
+
idx = lower.indexOf(name, idx + 1);
|
|
78
|
+
}
|
|
79
|
+
return false;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Detect installed third-party permission extensions.
|
|
84
|
+
*
|
|
85
|
+
* @param opts.home HOME override for tests.
|
|
86
|
+
* @param opts.cwd project dir override for tests (project settings).
|
|
87
|
+
* @param opts.settingsFiles extra settings files to scan (tests).
|
|
88
|
+
*/
|
|
89
|
+
export function detectPermissionGateExtensions(
|
|
90
|
+
opts: { home?: string; cwd?: string; settingsFiles?: string[] } = {},
|
|
91
|
+
): GateExtensionHit[] {
|
|
92
|
+
const home = opts.home ?? os.homedir();
|
|
93
|
+
const cwd = opts.cwd ?? process.cwd();
|
|
94
|
+
const hits: GateExtensionHit[] = [];
|
|
95
|
+
const seen = new Set<string>();
|
|
96
|
+
|
|
97
|
+
const settingsCandidates = [
|
|
98
|
+
...(opts.settingsFiles ?? []),
|
|
99
|
+
path.join(home, ".pi", "agent", "settings.json"),
|
|
100
|
+
path.join(cwd, ".pi", "settings.json"),
|
|
101
|
+
];
|
|
102
|
+
for (const file of settingsCandidates) {
|
|
103
|
+
for (const entry of packagesFrom(file)) {
|
|
104
|
+
const name = nameMatchesPackage(entry);
|
|
105
|
+
if (name && !seen.has(name)) {
|
|
106
|
+
seen.add(name);
|
|
107
|
+
hits.push({ name, evidence: `settings:${file}` });
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// Known config-file markers from the audit. Presence of the config does
|
|
113
|
+
// not prove the extension is installed, but every audited package writes
|
|
114
|
+
// its config only after install + first run, which is evidence enough for
|
|
115
|
+
// an opt-in default.
|
|
116
|
+
const markers: Array<{ file: string; name: string }> = [
|
|
117
|
+
{ file: path.join(home, ".pi", "agent", "extensions", "pi-permission-system"), name: "@gotgenes/pi-permission-system" },
|
|
118
|
+
{ file: path.join(home, ".agent", "pi-permissions.jsonc"), name: "@monroewilliams/pi-permission-system" },
|
|
119
|
+
{ file: path.join(home, ".pi", "agent", "extensions", "permissions.json"), name: "@rhedbull/pi-permissions" },
|
|
120
|
+
{ file: path.join(cwd, ".pi", "agent", "pi-permissions.jsonc"), name: "@gotgenes/pi-permission-system" },
|
|
121
|
+
];
|
|
122
|
+
for (const marker of markers) {
|
|
123
|
+
if (!seen.has(marker.name)) {
|
|
124
|
+
try {
|
|
125
|
+
fs.statSync(marker.file);
|
|
126
|
+
seen.add(marker.name);
|
|
127
|
+
hits.push({ name: marker.name, evidence: `config:${marker.file}` });
|
|
128
|
+
} catch {
|
|
129
|
+
/* absent - fine */
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
return hits;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** Resolve the effective gate shape from config + detection. "auto" defers
|
|
138
|
+
* to detection (shadow when any gate extension is present, else off). */
|
|
139
|
+
export function resolveGateMode(
|
|
140
|
+
gateMode: "auto" | "shadow" | "dedicated" | "off",
|
|
141
|
+
hits: GateExtensionHit[],
|
|
142
|
+
): "shadow" | "dedicated" | "off" {
|
|
143
|
+
if (gateMode === "off") return "off";
|
|
144
|
+
if (gateMode === "shadow" || gateMode === "dedicated") return gateMode;
|
|
145
|
+
return hits.length > 0 ? "shadow" : "off";
|
|
146
|
+
}
|