@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
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
// Approval-gate shadow tools (docs/TODO.md section 2.5, design v2).
|
|
2
|
+
//
|
|
3
|
+
// When the approval gate is active, the bridge re-registers pi's mutating
|
|
4
|
+
// builtins (bash, write, edit) as SHADOW tools: same name, same schema plus
|
|
5
|
+
// internal __agy* marker fields. Two behaviors, keyed on the marker:
|
|
6
|
+
//
|
|
7
|
+
// - marker absent: delegate to the captured real builtin. Normal pi
|
|
8
|
+
// behavior (including the G9 path, where pi tools execute for real) is
|
|
9
|
+
// untouched.
|
|
10
|
+
// - marker present: the call is an approval round-trip for an agy NATIVE
|
|
11
|
+
// tool. NEVER execute locally. Ask the policy for a decision; agy runs
|
|
12
|
+
// the tool in its own loop either way.
|
|
13
|
+
//
|
|
14
|
+
// Decision mapping (consumed by the provider's /approval park):
|
|
15
|
+
// resolve (success result) -> {"decision":"allow"}
|
|
16
|
+
// throw -> {"decision":"deny","reason": message}
|
|
17
|
+
// pi's tool executor converts thrown errors into error tool results with
|
|
18
|
+
// the message as text, matching how builtins report failures (write.js,
|
|
19
|
+
// edit-diff.js). A tool_call handler that blocks the shadow call upstream
|
|
20
|
+
// (any third-party permission extension) produces the same error result
|
|
21
|
+
// without execute() running, so both paths land on the same deny mapping.
|
|
22
|
+
//
|
|
23
|
+
// Run: npm test
|
|
24
|
+
|
|
25
|
+
import type {
|
|
26
|
+
AgentToolResult,
|
|
27
|
+
AgentToolUpdateCallback,
|
|
28
|
+
ExtensionContext,
|
|
29
|
+
ToolDefinition,
|
|
30
|
+
} from "@earendil-works/pi-coding-agent";
|
|
31
|
+
|
|
32
|
+
/** Marker flag: this shadow-tool call is an approval round-trip, not a real
|
|
33
|
+
* invocation. The bridge's provider sets it when composing the toolUse. */
|
|
34
|
+
export const GATE_MARKER = "__agyGate";
|
|
35
|
+
|
|
36
|
+
/** Internal context fields the provider may attach next to the marker.
|
|
37
|
+
* Stripped before delegating to the real builtin. */
|
|
38
|
+
export const MARKER_FIELDS = [GATE_MARKER, "__agyTicket", "__agyTool"] as const;
|
|
39
|
+
|
|
40
|
+
export type AnyToolDefinition = ToolDefinition<any, any, any>;
|
|
41
|
+
|
|
42
|
+
export interface GateDecision {
|
|
43
|
+
allow: boolean;
|
|
44
|
+
reason?: string;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Fallback policy: consulted only when NO extension blocked the shadow
|
|
48
|
+
* tool_call. Implementations map approvals.mode: ask -> ctx.ui.confirm
|
|
49
|
+
* (guarded by ctx.hasUI), allow -> {allow:true}, deny -> {allow:false}. */
|
|
50
|
+
export type GatePolicy = (call: {
|
|
51
|
+
tool: string;
|
|
52
|
+
params: Record<string, unknown>;
|
|
53
|
+
ctx: unknown;
|
|
54
|
+
}) => Promise<GateDecision> | GateDecision;
|
|
55
|
+
|
|
56
|
+
/** Marker schemas injected into the shadow parameters. Optional, so the
|
|
57
|
+
* model's own calls stay valid; audited permission extensions match only
|
|
58
|
+
* their known fields (command/path) and ignore these. */
|
|
59
|
+
const MARKER_SCHEMAS: Record<string, unknown> = {
|
|
60
|
+
[GATE_MARKER]: {
|
|
61
|
+
type: "boolean",
|
|
62
|
+
description:
|
|
63
|
+
"Internal bridge approval marker. Never set this yourself; calls without local execution intent must not set it.",
|
|
64
|
+
},
|
|
65
|
+
__agyTicket: { type: "string" },
|
|
66
|
+
__agyTool: { type: "string", description: "Native agy tool this approval round-trip is for." },
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
/** Clone a tool's parameter schema with the marker fields added as optional
|
|
70
|
+
* properties. Field-exact for everything the permission extensions match on
|
|
71
|
+
* (command, path, edits, ...). Does not mutate the base schema. */
|
|
72
|
+
export function withGateMarkerSchema(base: AnyToolDefinition["parameters"]): AnyToolDefinition["parameters"] {
|
|
73
|
+
const src = base as { properties?: Record<string, unknown> };
|
|
74
|
+
return { ...base, properties: { ...src.properties, ...MARKER_SCHEMAS } } as AnyToolDefinition["parameters"];
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** Copy of params without the internal marker fields, for delegation. */
|
|
78
|
+
export function stripMarkerFields(params: Record<string, unknown>): Record<string, unknown> {
|
|
79
|
+
const out = { ...params };
|
|
80
|
+
for (const field of MARKER_FIELDS) delete out[field];
|
|
81
|
+
return out;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export interface ShadowMapping {
|
|
85
|
+
shadow: "bash" | "write" | "edit";
|
|
86
|
+
input: Record<string, unknown>;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** Map an agy native tool call (hook stdin payload) onto the shadow surface.
|
|
90
|
+
* Field names follow the TODO 2.5 table: create_file is live-captured (F1);
|
|
91
|
+
* the edit-class arg names are docs-attested and degrade gracefully - a
|
|
92
|
+
* wrong guess only weakens the confirm-dialog text, never the decision
|
|
93
|
+
* (the tool runs in agy's loop either way). Unknown names return null:
|
|
94
|
+
* read-only tools are not gated. */
|
|
95
|
+
export function mapNativeToShadow(name: string, args: Record<string, unknown>): ShadowMapping | null {
|
|
96
|
+
const a = args ?? {};
|
|
97
|
+
const str = (v: unknown): string => (typeof v === "string" ? v : v === undefined || v === null ? "" : String(v));
|
|
98
|
+
switch (name) {
|
|
99
|
+
case "run_command": {
|
|
100
|
+
const input: Record<string, unknown> = { command: str(a.CommandLine) };
|
|
101
|
+
if (a.Cwd !== undefined && a.Cwd !== null && a.Cwd !== "") input.cwd = str(a.Cwd);
|
|
102
|
+
return { shadow: "bash", input };
|
|
103
|
+
}
|
|
104
|
+
case "write_to_file":
|
|
105
|
+
case "create_file":
|
|
106
|
+
return { shadow: "write", input: { path: str(a.TargetFile), content: str(a.CodeContent) } };
|
|
107
|
+
case "replace_file_content":
|
|
108
|
+
case "edit_file":
|
|
109
|
+
return {
|
|
110
|
+
shadow: "edit",
|
|
111
|
+
input: {
|
|
112
|
+
path: str(a.TargetFile),
|
|
113
|
+
edits: [{ oldText: str(a.SearchText), newText: str(a.ReplacementContent) }],
|
|
114
|
+
},
|
|
115
|
+
};
|
|
116
|
+
case "multi_replace_file_content": {
|
|
117
|
+
const chunks = Array.isArray(a.ReplacementChunks) ? a.ReplacementChunks : [];
|
|
118
|
+
return {
|
|
119
|
+
shadow: "edit",
|
|
120
|
+
input: {
|
|
121
|
+
path: str(a.TargetFile),
|
|
122
|
+
edits: chunks.map((c) => {
|
|
123
|
+
const chunk = (c ?? {}) as Record<string, unknown>;
|
|
124
|
+
return { oldText: str(chunk.SearchText), newText: str(chunk.ReplacementContent) };
|
|
125
|
+
}),
|
|
126
|
+
},
|
|
127
|
+
};
|
|
128
|
+
}
|
|
129
|
+
default:
|
|
130
|
+
return null;
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** Options for the shadow factory. */
|
|
135
|
+
export interface ShadowOptions {
|
|
136
|
+
/** Registry lookup for the park's ticket. When set, a marker call whose
|
|
137
|
+
* __agyTicket is missing or unrecognized throws (deny) BEFORE the policy
|
|
138
|
+
* runs: a model that sets __agyGate:true itself can then never produce a
|
|
139
|
+
* fake-approved result, even under approvals.mode "allow". */
|
|
140
|
+
verifyTicket?: (ticket: string) => boolean;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/** Build the shadow definition for one builtin. `base` MUST be a definition
|
|
144
|
+
* of the real builtin - the extension passes factory twins created with
|
|
145
|
+
* pi's public createBashToolDefinition/createWriteToolDefinition/
|
|
146
|
+
* createEditToolDefinition (pi.getAllTools() returns ToolInfo, which strips
|
|
147
|
+
* execute, so the live definition cannot be captured). */
|
|
148
|
+
export function createShadowTool(base: AnyToolDefinition, policy: GatePolicy, opts: ShadowOptions = {}): AnyToolDefinition {
|
|
149
|
+
const execute = async (
|
|
150
|
+
toolCallId: string,
|
|
151
|
+
params: any,
|
|
152
|
+
signal: AbortSignal | undefined,
|
|
153
|
+
onUpdate: AgentToolUpdateCallback<any> | undefined,
|
|
154
|
+
ctx: ExtensionContext,
|
|
155
|
+
): Promise<AgentToolResult<unknown>> => {
|
|
156
|
+
const p = (params ?? {}) as Record<string, unknown>;
|
|
157
|
+
if (p[GATE_MARKER] !== true) {
|
|
158
|
+
return base.execute(toolCallId, stripMarkerFields(p), signal, onUpdate, ctx);
|
|
159
|
+
}
|
|
160
|
+
const native = typeof p.__agyTool === "string" && p.__agyTool.length > 0 ? p.__agyTool : base.name;
|
|
161
|
+
// Ticket binding (peer review 2026-09-07): only calls the provider parked
|
|
162
|
+
// carry a live ticket. Anything else with the marker set was forged by
|
|
163
|
+
// the model (or the park is gone); fail closed without consulting the
|
|
164
|
+
// policy, so approvals.mode "allow" can never bless it either.
|
|
165
|
+
const ticket = typeof p.__agyTicket === "string" ? p.__agyTicket : "";
|
|
166
|
+
if (!ticket || !opts.verifyTicket?.(ticket)) {
|
|
167
|
+
throw new Error(
|
|
168
|
+
`approval gate: unrecognized or stale approval ticket; refusing to decide (${native}).`,
|
|
169
|
+
);
|
|
170
|
+
}
|
|
171
|
+
if (signal?.aborted) {
|
|
172
|
+
throw new Error(`approval gate aborted before a decision was reached (${native}).`);
|
|
173
|
+
}
|
|
174
|
+
let decision: GateDecision;
|
|
175
|
+
// Race the policy against the abort signal: a cancelled turn must
|
|
176
|
+
// unblock execute() instead of hanging on a human decision.
|
|
177
|
+
const aborted = new Error(`approval gate aborted before a decision was reached (${native}).`);
|
|
178
|
+
const abortp = new Promise<never>((_, reject) => {
|
|
179
|
+
signal?.addEventListener("abort", () => reject(aborted), { once: true });
|
|
180
|
+
});
|
|
181
|
+
abortp.catch(() => {}); // late rejection must not become unhandled
|
|
182
|
+
try {
|
|
183
|
+
decision = await Promise.race([Promise.resolve(policy({ tool: native, params: p, ctx })), abortp]);
|
|
184
|
+
} catch (err) {
|
|
185
|
+
if (signal?.aborted) throw aborted;
|
|
186
|
+
// Fail closed: a broken policy must never look like an approval.
|
|
187
|
+
throw new Error(`approval gate policy failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
188
|
+
}
|
|
189
|
+
if (signal?.aborted) {
|
|
190
|
+
// Sync-abort inside the policy settles the policy promise BEFORE the
|
|
191
|
+
// race starts, so the rejection loses the ordering tie. Re-check.
|
|
192
|
+
throw aborted;
|
|
193
|
+
}
|
|
194
|
+
if (!decision.allow) {
|
|
195
|
+
throw new Error(decision.reason || `blocked by approval gate (${native}).`);
|
|
196
|
+
}
|
|
197
|
+
return {
|
|
198
|
+
content: [
|
|
199
|
+
{
|
|
200
|
+
type: "text",
|
|
201
|
+
text: `Approved by approval gate: ${native}. No local execution happened; the tool runs in the Antigravity agent loop.`,
|
|
202
|
+
},
|
|
203
|
+
],
|
|
204
|
+
details: { gate: "allow", native },
|
|
205
|
+
};
|
|
206
|
+
};
|
|
207
|
+
return { ...base, parameters: withGateMarkerSchema(base.parameters), execute } as AnyToolDefinition;
|
|
208
|
+
}
|
|
@@ -0,0 +1,252 @@
|
|
|
1
|
+
// Staging for the approval-gate hooks.json (docs/TODO.md 2.5).
|
|
2
|
+
//
|
|
3
|
+
// The ACP server and the agy CLI fire workspace `.agents/hooks.json`
|
|
4
|
+
// PreToolUse hooks (V2: deny honored, reason reaches the model; V3: hook
|
|
5
|
+
// TIMEOUT = soft-pass, agy proceeds ungated). Therefore:
|
|
6
|
+
// - the staged handler timeout must exceed the whole park budget with
|
|
7
|
+
// margin (never rely on timeout as a deny), and
|
|
8
|
+
// - the staged command delegates to the bundled poll script, which
|
|
9
|
+
// early-acks and polls the bridge for the terminal decision.
|
|
10
|
+
//
|
|
11
|
+
// Merge rules: never clobber a foreign hooks.json. Parse failures abort
|
|
12
|
+
// staging; first-time modification of an existing file writes a
|
|
13
|
+
// timestamped backup next to it (the 2026-09-05 incident rule: every
|
|
14
|
+
// destructive path gets a guard).
|
|
15
|
+
//
|
|
16
|
+
// Run: npm test
|
|
17
|
+
|
|
18
|
+
import fs from "node:fs";
|
|
19
|
+
import path from "node:path";
|
|
20
|
+
|
|
21
|
+
export const HOOK_GROUP = "pi-bridge-gate";
|
|
22
|
+
|
|
23
|
+
/** Group-key namespace. Each pi session stages its OWN group keyed per-pid
|
|
24
|
+
* (`pi-bridge-gate-<pid>`): hooks.json lives in the SHARED workspace, and
|
|
25
|
+
* two concurrent sessions must never remove or overwrite each other's gate
|
|
26
|
+
* (audit 2026-09-07: a single shared key let a gate-off session silently
|
|
27
|
+
* strip a gate-on session's PreToolUse matchers). */
|
|
28
|
+
export const GATE_GROUP_PREFIX = "pi-bridge-gate";
|
|
29
|
+
|
|
30
|
+
/** This session's group key. */
|
|
31
|
+
export function gateGroupKey(pid: number = process.pid): string {
|
|
32
|
+
return `${GATE_GROUP_PREFIX}-${pid}`;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** True if the process is running (EPERM counts: alive but not ours). */
|
|
36
|
+
function pidAlive(pid: number): boolean {
|
|
37
|
+
if (!Number.isInteger(pid) || pid <= 0) return false;
|
|
38
|
+
try {
|
|
39
|
+
process.kill(pid, 0);
|
|
40
|
+
return true;
|
|
41
|
+
} catch (e) {
|
|
42
|
+
return (e as NodeJS.ErrnoException).code === "EPERM";
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Which session a gate group belongs to, parsed from the script path its
|
|
47
|
+
* command embeds (`.../approval-hook-<pid>.js`). The pid is the ownership
|
|
48
|
+
* proof: the script is per-pid (0600, written by that session). Returns
|
|
49
|
+
* null for groups we cannot attribute (foreign/future formats - never
|
|
50
|
+
* touched). */
|
|
51
|
+
function gateGroupPid(group: unknown): number | null {
|
|
52
|
+
const m = /approval-hook-(\d+)\.js/.exec(JSON.stringify(group));
|
|
53
|
+
return m ? Number(m[1]) : null;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** agy native tools worth gating: everything that mutates the machine. */
|
|
57
|
+
export const GATED_AGY_TOOLS =
|
|
58
|
+
"create_file|write_to_file|replace_file_content|multi_replace_file_content|edit_file|run_command";
|
|
59
|
+
|
|
60
|
+
/** The same list as a set, for the bridge's POST /approval validation: a
|
|
61
|
+
* payload for anything else is answered with a direct deny (defense in
|
|
62
|
+
* depth - the hooks matcher should never let one through). */
|
|
63
|
+
export const GATED_AGY_TOOL_SET: ReadonlySet<string> = new Set(GATED_AGY_TOOLS.split("|"));
|
|
64
|
+
|
|
65
|
+
export interface StageOptions {
|
|
66
|
+
/** Bridge HTTP port (the approval endpoints live on the bridge server). */
|
|
67
|
+
port: number;
|
|
68
|
+
/** Bridge shared secret (x-bridge-token). */
|
|
69
|
+
token: string;
|
|
70
|
+
/** Path to the bundled poll script (written by the caller). */
|
|
71
|
+
scriptPath: string;
|
|
72
|
+
/** Full park budget in ms; the staged hook timeout exceeds it. */
|
|
73
|
+
parkBudgetMs: number;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** Source of the staged poll script. Written to disk by the caller (data
|
|
77
|
+
* dir), referenced by absolute path from the staged hooks.json. Early-acks
|
|
78
|
+
* via POST /approval, then polls GET /approval/<ticket> until a terminal
|
|
79
|
+
* decision or the deadline. Terminal: prints the JSON decision on stdout.
|
|
80
|
+
* Deadline hit: prints {"decision":"deny", ...} (fail closed) - agy may
|
|
81
|
+
* still soft-pass a timed-out hook, but a printed deny is honored (V2). */
|
|
82
|
+
export function hookScriptSource(opts: { port: number; token: string; deadlineMs: number }): string {
|
|
83
|
+
return `#!/usr/bin/env node
|
|
84
|
+
// Bridge approval hook (generated; do not edit). Polls the pi-antigravity-bridge.
|
|
85
|
+
const PORT = ${opts.port};
|
|
86
|
+
const TOKEN = ${JSON.stringify(opts.token)};
|
|
87
|
+
const DEADLINE = Date.now() + ${opts.deadlineMs};
|
|
88
|
+
let body = "";
|
|
89
|
+
process.stdin.setEncoding("utf8");
|
|
90
|
+
for await (const chunk of process.stdin) body += chunk;
|
|
91
|
+
let ticket = "";
|
|
92
|
+
try {
|
|
93
|
+
const res = await fetch(\`http://127.0.0.1:\${PORT}/approval\`, {
|
|
94
|
+
method: "POST",
|
|
95
|
+
headers: { "content-type": "application/json", "x-bridge-token": TOKEN },
|
|
96
|
+
body,
|
|
97
|
+
});
|
|
98
|
+
const json = await res.json();
|
|
99
|
+
// Ungated payloads get a terminal decision right on the POST (no park).
|
|
100
|
+
if (json && typeof json === "object" && "decision" in json) {
|
|
101
|
+
console.log(JSON.stringify(json.decision));
|
|
102
|
+
process.exit(0);
|
|
103
|
+
}
|
|
104
|
+
ticket = json.ticket ?? "";
|
|
105
|
+
} catch {}
|
|
106
|
+
if (!ticket) {
|
|
107
|
+
console.log(JSON.stringify({ decision: "deny", reason: "approval gate unreachable (bridge down?)" }));
|
|
108
|
+
process.exit(0);
|
|
109
|
+
}
|
|
110
|
+
while (Date.now() < DEADLINE) {
|
|
111
|
+
await new Promise((r) => setTimeout(r, 500));
|
|
112
|
+
try {
|
|
113
|
+
const res = await fetch(\`http://127.0.0.1:\${PORT}/approval/\${encodeURIComponent(ticket)}\`, {
|
|
114
|
+
headers: { "x-bridge-token": TOKEN },
|
|
115
|
+
});
|
|
116
|
+
const json = await res.json();
|
|
117
|
+
if (json.status !== "pending") {
|
|
118
|
+
console.log(JSON.stringify(json.decision ?? { decision: "deny", reason: "gate returned no decision" }));
|
|
119
|
+
process.exit(0);
|
|
120
|
+
}
|
|
121
|
+
} catch {}
|
|
122
|
+
}
|
|
123
|
+
console.log(JSON.stringify({ decision: "deny", reason: "approval gate deadline exceeded" }));
|
|
124
|
+
`;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** Hook timeout (seconds) staged for a given park budget: the budget plus a
|
|
128
|
+
* 60s margin, minimum 60s. V3: a timed-out hook soft-passes, so this must
|
|
129
|
+
* never be smaller than the human can plausibly need. */
|
|
130
|
+
export function stagedTimeoutSeconds(parkBudgetMs: number): number {
|
|
131
|
+
return Math.max(60, Math.ceil(parkBudgetMs / 1000) + 60);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** Build our hooks.json group for one workspace staging. */
|
|
135
|
+
export function buildGateGroup(opts: StageOptions): Record<string, unknown> {
|
|
136
|
+
const command = `node ${JSON.stringify(opts.scriptPath)}`;
|
|
137
|
+
return {
|
|
138
|
+
enabled: true,
|
|
139
|
+
PreToolUse: [
|
|
140
|
+
{
|
|
141
|
+
matcher: GATED_AGY_TOOLS,
|
|
142
|
+
hooks: [
|
|
143
|
+
{
|
|
144
|
+
type: "command",
|
|
145
|
+
command,
|
|
146
|
+
timeout: stagedTimeoutSeconds(opts.parkBudgetMs),
|
|
147
|
+
},
|
|
148
|
+
],
|
|
149
|
+
},
|
|
150
|
+
],
|
|
151
|
+
};
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
export interface StageResult {
|
|
155
|
+
wrote: boolean;
|
|
156
|
+
/** Backup file written before first modification of a foreign file. */
|
|
157
|
+
backup?: string;
|
|
158
|
+
/** Why nothing was written (parse failure, already current, ...). */
|
|
159
|
+
reason?: string;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/** Stage the gate group into <workspaceDir>/.agents/hooks.json under THIS
|
|
163
|
+
* session's per-pid key. Merge-safe:
|
|
164
|
+
* - foreign groups are preserved;
|
|
165
|
+
* - gate groups of DEAD sessions are swept (their bridge is gone; the hook
|
|
166
|
+
* would fail closed forever), groups of live sessions never touched;
|
|
167
|
+
* - a foreign file is backed up before its first modification;
|
|
168
|
+
* - unparseable files are never touched. */
|
|
169
|
+
export function stageGateHooks(workspaceDir: string, opts: StageOptions): StageResult {
|
|
170
|
+
const dir = path.join(workspaceDir, ".agents");
|
|
171
|
+
const file = path.join(dir, "hooks.json");
|
|
172
|
+
const group = buildGateGroup(opts);
|
|
173
|
+
const ownKey = gateGroupKey();
|
|
174
|
+
let current: Record<string, unknown> = {};
|
|
175
|
+
const existed = fs.existsSync(file);
|
|
176
|
+
if (existed) {
|
|
177
|
+
try {
|
|
178
|
+
if (fs.lstatSync(file).isSymbolicLink()) {
|
|
179
|
+
return { wrote: false, reason: "hooks.json is a symlink; refusing to follow it" };
|
|
180
|
+
}
|
|
181
|
+
} catch {
|
|
182
|
+
return { wrote: false, reason: "hooks.json vanished while staging" };
|
|
183
|
+
}
|
|
184
|
+
try {
|
|
185
|
+
const parsed = JSON.parse(fs.readFileSync(file, "utf8"));
|
|
186
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
187
|
+
return { wrote: false, reason: "hooks.json is not an object; refusing to touch it" };
|
|
188
|
+
}
|
|
189
|
+
current = parsed as Record<string, unknown>;
|
|
190
|
+
} catch {
|
|
191
|
+
return { wrote: false, reason: "hooks.json is not valid JSON; refusing to touch it" };
|
|
192
|
+
}
|
|
193
|
+
// Sweep gate groups whose owning session is gone. Never touch groups of
|
|
194
|
+
// live sessions (concurrent pi sessions share this workspace) or groups
|
|
195
|
+
// we cannot attribute.
|
|
196
|
+
let swept = 0;
|
|
197
|
+
for (const key of Object.keys(current)) {
|
|
198
|
+
if (key === ownKey) continue;
|
|
199
|
+
const isGateGroup = key === GATE_GROUP_PREFIX || key.startsWith(`${GATE_GROUP_PREFIX}-`);
|
|
200
|
+
if (!isGateGroup) continue;
|
|
201
|
+
const pid = gateGroupPid(current[key]);
|
|
202
|
+
if (pid === null || pidAlive(pid)) continue;
|
|
203
|
+
delete current[key];
|
|
204
|
+
swept += 1;
|
|
205
|
+
}
|
|
206
|
+
if (swept > 0 && JSON.stringify(current[ownKey]) === JSON.stringify(group)) {
|
|
207
|
+
// Own group already current; the pass only swept dead peers.
|
|
208
|
+
fs.writeFileSync(file, JSON.stringify(current, null, 2) + "\n");
|
|
209
|
+
return { wrote: true, reason: `swept ${swept} dead gate group(s)` };
|
|
210
|
+
}
|
|
211
|
+
if (JSON.stringify(current[ownKey]) === JSON.stringify(group)) {
|
|
212
|
+
return { wrote: false, reason: "already staged" };
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
const backup =
|
|
216
|
+
existed && current[ownKey] === undefined
|
|
217
|
+
? `${file}.backup-${new Date().toISOString().replace(/[:.]/g, "-")}`
|
|
218
|
+
: undefined;
|
|
219
|
+
if (backup) fs.copyFileSync(file, backup);
|
|
220
|
+
current[ownKey] = group;
|
|
221
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
222
|
+
fs.writeFileSync(file, JSON.stringify(current, null, 2) + "\n");
|
|
223
|
+
return { wrote: true, backup };
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/** Remove ONLY this session's gate group (or the given pid's). Other
|
|
227
|
+
* sessions' groups - including live ones in a shared workspace - are never
|
|
228
|
+
* touched: a gate-off session must not strip a gate-on session's matchers
|
|
229
|
+
* (audit 2026-09-07). Foreign content stays; an object file is left in
|
|
230
|
+
* place (harmless). */
|
|
231
|
+
export function removeGateHooks(workspaceDir: string, pid: number = process.pid): StageResult {
|
|
232
|
+
const file = path.join(workspaceDir, ".agents", "hooks.json");
|
|
233
|
+
if (!fs.existsSync(file)) return { wrote: false, reason: "no hooks.json" };
|
|
234
|
+
try {
|
|
235
|
+
if (fs.lstatSync(file).isSymbolicLink()) {
|
|
236
|
+
return { wrote: false, reason: "hooks.json is a symlink; refusing to follow it" };
|
|
237
|
+
}
|
|
238
|
+
} catch {
|
|
239
|
+
return { wrote: false, reason: "hooks.json vanished while removing" };
|
|
240
|
+
}
|
|
241
|
+
try {
|
|
242
|
+
const parsed = JSON.parse(fs.readFileSync(file, "utf8"));
|
|
243
|
+
if (!parsed || typeof parsed !== "object") return { wrote: false, reason: "not an object; refusing" };
|
|
244
|
+
const ownKey = gateGroupKey(pid);
|
|
245
|
+
if (parsed[ownKey] === undefined) return { wrote: false, reason: "not staged" };
|
|
246
|
+
delete parsed[ownKey];
|
|
247
|
+
fs.writeFileSync(file, JSON.stringify(parsed, null, 2) + "\n");
|
|
248
|
+
return { wrote: true };
|
|
249
|
+
} catch {
|
|
250
|
+
return { wrote: false, reason: "not valid JSON; refusing" };
|
|
251
|
+
}
|
|
252
|
+
}
|
package/src/config.ts
CHANGED
|
@@ -44,6 +44,29 @@ export type AgyMode = "accept-edits" | "plan";
|
|
|
44
44
|
export type ThinkingTier = "low" | "medium" | "high";
|
|
45
45
|
export type BridgeTools = "none" | "mcp" | "all";
|
|
46
46
|
|
|
47
|
+
/** How the approval gate activates (docs/TODO.md section 2.5).
|
|
48
|
+
*
|
|
49
|
+
* "auto" (default): OFF until a third-party pi permission extension is
|
|
50
|
+
* detected (src/approval-detect.ts). When one is found, the gate runs in
|
|
51
|
+
* "shadow" mode so that extension gates agy's native tool calls with zero
|
|
52
|
+
* configuration on its side.
|
|
53
|
+
*
|
|
54
|
+
* "shadow" / "dedicated": force the gate on in that shape. "off": never
|
|
55
|
+
* gate, even when a permission extension is installed. NOTE: "dedicated"
|
|
56
|
+
* currently stages the same shadow tools as "shadow" (warn-logged remap);
|
|
57
|
+
* the explicit antigravity_approve variant is planned - see docs/TODO.md. */
|
|
58
|
+
export type GateMode = "auto" | "shadow" | "dedicated" | "off";
|
|
59
|
+
|
|
60
|
+
/** Fallback decision when no extension blocked a gated call: "ask" uses
|
|
61
|
+
* ctx.ui.confirm (headless = deny, fail-closed), "allow" auto-approves,
|
|
62
|
+
* "deny" auto-rejects. */
|
|
63
|
+
export type GateAskMode = "ask" | "allow" | "deny";
|
|
64
|
+
|
|
65
|
+
export interface GateConfig {
|
|
66
|
+
gateMode: GateMode;
|
|
67
|
+
mode: GateAskMode;
|
|
68
|
+
}
|
|
69
|
+
|
|
47
70
|
export interface AcpConfig {
|
|
48
71
|
/** Path to agy_acp_server.par. Empty = env AGY_ACP_BIN > PATH. */
|
|
49
72
|
bin: string;
|
|
@@ -108,6 +131,11 @@ export interface AgyConfig {
|
|
|
108
131
|
* digest (per-turn) is not. Turn off for agy-native behavior (agy's own
|
|
109
132
|
* system prompt only). */
|
|
110
133
|
systemPrompt: boolean;
|
|
134
|
+
/** Approval gate over agy NATIVE tool calls (create_file, run_command,
|
|
135
|
+
* ...). pi tools agy calls already pass through pi's gates via the G9
|
|
136
|
+
* round-trip; this covers the rest. Default auto (off until a
|
|
137
|
+
* third-party permission extension is detected). See docs/TODO.md 2.5. */
|
|
138
|
+
approvals: GateConfig;
|
|
111
139
|
}
|
|
112
140
|
|
|
113
141
|
const DEFAULTS: AgyConfig = {
|
|
@@ -120,6 +148,7 @@ const DEFAULTS: AgyConfig = {
|
|
|
120
148
|
bridgeTools: "all",
|
|
121
149
|
digest: false,
|
|
122
150
|
systemPrompt: true,
|
|
151
|
+
approvals: { gateMode: "auto", mode: "ask" },
|
|
123
152
|
acp: { bin: "", permissions: "auto" },
|
|
124
153
|
};
|
|
125
154
|
|
|
@@ -189,6 +218,19 @@ export function loadConfig(configPath: string = CONFIG_PATH): AgyConfig {
|
|
|
189
218
|
? ["1", "true", "on"].includes(envSys.toLowerCase())
|
|
190
219
|
: file.systemPrompt ?? DEFAULTS.systemPrompt;
|
|
191
220
|
|
|
221
|
+
// Approval gate (docs/TODO.md 2.5). Unknown values fall back to "auto"
|
|
222
|
+
// so a typo can never silently force the gate on.
|
|
223
|
+
const gateRaw = (process.env.AGY_APPROVALS ?? file.approvals?.gateMode ?? DEFAULTS.approvals.gateMode).toLowerCase();
|
|
224
|
+
const gateMode: GateMode =
|
|
225
|
+
gateRaw === "shadow" || gateRaw === "dedicated" || gateRaw === "off"
|
|
226
|
+
? gateRaw
|
|
227
|
+
: "auto";
|
|
228
|
+
const askRaw = (process.env.AGY_APPROVALS_MODE ?? file.approvals?.mode ?? DEFAULTS.approvals.mode).toLowerCase();
|
|
229
|
+
const gateAskMode: GateAskMode =
|
|
230
|
+
askRaw === "allow" || askRaw === "deny"
|
|
231
|
+
? askRaw
|
|
232
|
+
: "ask";
|
|
233
|
+
|
|
192
234
|
const fileAcp = (typeof file.acp === "object" && file.acp !== null ? file.acp : {}) as Partial<AcpConfig>;
|
|
193
235
|
const acp: AcpConfig = {
|
|
194
236
|
bin:
|
|
@@ -208,6 +250,7 @@ export function loadConfig(configPath: string = CONFIG_PATH): AgyConfig {
|
|
|
208
250
|
bridgeTools,
|
|
209
251
|
digest,
|
|
210
252
|
systemPrompt,
|
|
253
|
+
approvals: { gateMode, mode: gateAskMode },
|
|
211
254
|
patchCleanupNotified: file.patchCleanupNotified === true,
|
|
212
255
|
};
|
|
213
256
|
}
|
package/src/driver-types.ts
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
// Engine-agnostic turn-driver contract.
|
|
2
2
|
//
|
|
3
|
-
// Both turn engines (
|
|
3
|
+
// Both turn engines (stream-json driver in `driver.ts`, ACP driver in
|
|
4
4
|
// `acp/driver.ts`) implement `TurnDriver`, and everything above them — the
|
|
5
5
|
// provider's stream loop, the G9 round-trip store, the extension wiring —
|
|
6
|
-
// depends on this interface only. Types live here so the
|
|
6
|
+
// depends on this interface only. Types live here so the stream module can be
|
|
7
7
|
// deleted (phase 4) without breaking imports.
|
|
8
8
|
//
|
|
9
9
|
// The ACP driver implements the same surface with protocol-native mechanics:
|
|
@@ -21,17 +21,17 @@ export interface DriverProfile {
|
|
|
21
21
|
}
|
|
22
22
|
|
|
23
23
|
export interface DriverTurnRequest extends DriverProfile {
|
|
24
|
-
/** Existing conversation/session to resume.
|
|
24
|
+
/** Existing conversation/session to resume. Stream-json: agy conversation id via
|
|
25
25
|
* `--conversation`. ACP: sessionId via `session/load` (falls back to
|
|
26
26
|
* `session/new` when the server no longer knows it). */
|
|
27
27
|
conversationId?: string | null;
|
|
28
28
|
prompt: string;
|
|
29
29
|
/** Image blocks riding with the prompt. ACP forwards them as typed
|
|
30
30
|
* content blocks (probe 2026-09-03: 64x64 two-tone PNG answered
|
|
31
|
-
* correctly); the
|
|
31
|
+
* correctly); the stream-json CLI prompt is text-only and ignores them. */
|
|
32
32
|
images?: Array<{ data: string; mimeType: string }>;
|
|
33
33
|
/** ACP only: pi-side context delivered as a native `embeddedContext`
|
|
34
|
-
* resource block instead of inline prompt text (G1 on ACP).
|
|
34
|
+
* resource block instead of inline prompt text (G1 on ACP). Stream-json
|
|
35
35
|
* embeds the digest in the prompt string and ignores this. */
|
|
36
36
|
contextBlock?: { uri: string; text: string };
|
|
37
37
|
signal?: AbortSignal;
|
|
@@ -52,7 +52,7 @@ export type AgyUsage = {
|
|
|
52
52
|
|
|
53
53
|
export type DriverActivity =
|
|
54
54
|
| { type: "text"; delta: string }
|
|
55
|
-
/**
|
|
55
|
+
/** Stream-json emits a token count only; ACP carries the actual thought text in
|
|
56
56
|
* `delta`. The provider renders whichever is present. */
|
|
57
57
|
| { type: "thought"; tokens?: number; delta?: string }
|
|
58
58
|
| { type: "tool_start"; stepId?: number; name: string; args: Record<string, unknown> }
|
|
@@ -64,7 +64,7 @@ export type DriverActivity =
|
|
|
64
64
|
output?: string;
|
|
65
65
|
durationSeconds?: number;
|
|
66
66
|
/** ACP only: the server's native edit diff from `tool_call`
|
|
67
|
-
* content[] ({type:"diff", path, oldText?, newText}).
|
|
67
|
+
* content[] ({type:"diff", path, oldText?, newText}). Stream-json never
|
|
68
68
|
* sets it; the provider renders it without any git subprocess. */
|
|
69
69
|
diff?: { path: string; oldText?: string; newText: string };
|
|
70
70
|
}
|
|
@@ -108,7 +108,7 @@ export interface DriverSnapshot {
|
|
|
108
108
|
recycleReasons: Record<string, number>;
|
|
109
109
|
};
|
|
110
110
|
lifecycle: string[];
|
|
111
|
-
/** Present on ACP snapshots; absent on
|
|
111
|
+
/** Present on ACP snapshots; absent on stream-json. */
|
|
112
112
|
engine?: "acp";
|
|
113
113
|
acp?: {
|
|
114
114
|
sessionId?: string;
|
|
@@ -133,7 +133,7 @@ export interface DriverSnapshot {
|
|
|
133
133
|
}
|
|
134
134
|
|
|
135
135
|
/** The engine contract. Everything above the driver depends on this interface
|
|
136
|
-
* only; `
|
|
136
|
+
* only; `StreamDriver` and `AcpDriver` both implement it. */
|
|
137
137
|
export interface TurnDriver {
|
|
138
138
|
readonly state: DriverState;
|
|
139
139
|
readonly activeHandle: TurnHandle | null;
|
package/src/driver.ts
CHANGED
|
@@ -122,7 +122,7 @@ export function shouldFlipToCumulative(accumulated: string, next: string): boole
|
|
|
122
122
|
return accumulated.length >= CUMULATIVE_FLIP_MIN_CHARS && isCumulativeResend(accumulated, next);
|
|
123
123
|
}
|
|
124
124
|
|
|
125
|
-
export class
|
|
125
|
+
export class StreamDriver implements TurnDriver {
|
|
126
126
|
#state: DriverState = "idle";
|
|
127
127
|
#child: ChildProcess | undefined;
|
|
128
128
|
#generation = 0;
|
|
@@ -130,7 +130,6 @@ export class AgyDriver implements TurnDriver {
|
|
|
130
130
|
#boundConversation: string | undefined;
|
|
131
131
|
#active: ActiveTurn | undefined;
|
|
132
132
|
#queueTail: Promise<void> = Promise.resolve();
|
|
133
|
-
#shutdown = false;
|
|
134
133
|
#stderrTail = "";
|
|
135
134
|
// Frames can split across pipe chunks; the trailing partial line lives here
|
|
136
135
|
// until its newline arrives (same scheme as JsonRpcSession.feed). Dropping
|
|
@@ -218,7 +217,10 @@ export class AgyDriver implements TurnDriver {
|
|
|
218
217
|
}
|
|
219
218
|
|
|
220
219
|
async #runExclusive(request: DriverTurnRequest): Promise<TurnHandle> {
|
|
221
|
-
|
|
220
|
+
// No shutdown latch: pi fires session_shutdown on /new, /resume and
|
|
221
|
+
// /fork (not only process exit), so a closed driver must respawn on the
|
|
222
|
+
// next turn instead of rejecting forever. Parity with the ACP driver
|
|
223
|
+
// fix (regression 2026-09-07).
|
|
222
224
|
if (request.signal?.aborted) throw new Error("aborted before start");
|
|
223
225
|
|
|
224
226
|
const cause = this.#recycleCause(request);
|
|
@@ -588,7 +590,6 @@ export class AgyDriver implements TurnDriver {
|
|
|
588
590
|
}
|
|
589
591
|
|
|
590
592
|
async close(reason: "recycle" | "shutdown", cause?: string): Promise<void> {
|
|
591
|
-
if (reason === "shutdown") this.#shutdown = true;
|
|
592
593
|
const child = this.#child;
|
|
593
594
|
if (!child) {
|
|
594
595
|
this.#state = reason === "shutdown" ? "dead" : "idle";
|
|
@@ -602,7 +603,7 @@ export class AgyDriver implements TurnDriver {
|
|
|
602
603
|
this.#log(`close:${reason}${cause ? `:${cause}` : ""}`);
|
|
603
604
|
const turn = this.#active;
|
|
604
605
|
if (turn && !turn.closed) {
|
|
605
|
-
this.#failTurn(turn, `agy driver ${reason}
|
|
606
|
+
this.#failTurn(turn, `agy driver ${reason === "recycle" ? "recycled" : "shut down"} mid-turn${cause ? ` (${cause})` : ""}`);
|
|
606
607
|
}
|
|
607
608
|
this.#killChild();
|
|
608
609
|
this.#state = reason === "shutdown" ? "dead" : "idle";
|