@estebanforge/pi-antigravity-bridge 1.4.8 → 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.
@@ -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
+ }
@@ -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
  }