|
| 1 | +// `agentlock hook gemini <event>` — bridge between Gemini CLI's command- |
| 2 | +// hook contract (stdin JSON + exit codes) and the daemon's HTTP hook |
| 3 | +// endpoints. Gemini spawns this binary, writes the hook payload to |
| 4 | +// stdin, and reads exit code + stdout JSON. |
| 5 | +// |
| 6 | +// Mapping: |
| 7 | +// allow → exit 0, EMPTY stdout. Critical: Gemini parses stdout as a |
| 8 | +// JSON envelope ({"decision":..., "reason":...}); writing |
| 9 | +// anything non-JSON would error the harness, and writing an |
| 10 | +// allow envelope is at best redundant noise. Stay silent. |
| 11 | +// deny → exit 2 with `reason` on stderr (Gemini surfaces stderr as |
| 12 | +// the system block reason on exit 2) AND the full daemon |
| 13 | +// response JSON on stdout (so future Gemini versions that |
| 14 | +// prefer the JSON path over the exit-code path still see it). |
| 15 | +// |
| 16 | +// Daemon URL comes from $AGENTLOCK_DAEMON_URL (set in the env stanza |
| 17 | +// the installer writes into ~/.gemini/settings.json) with a loopback |
| 18 | +// default. Any transport / parse failure exits 0 — fail-open at the |
| 19 | +// shim layer keeps a daemon outage from soft-bricking the user's coding |
| 20 | +// session. The daemon-side ledger is the source of truth. |
| 21 | +// |
| 22 | +// Response shape differs from Claude/Codex: Gemini uses flat |
| 23 | +// `{decision, reason}` (NOT Claude's nested `hookSpecificOutput. |
| 24 | +// permissionDecision`). The daemon emits the Gemini shape directly. |
| 25 | +// |
| 26 | +// Gemini has no statusLine analog (Claude's persistent UI element under |
| 27 | +// the chat). Even on the deny path we keep stderr to the bare reason — |
| 28 | +// no banner, no decoration — because Gemini will render it verbatim to |
| 29 | +// the model. |
| 30 | + |
| 31 | +const ALLOWED_EVENTS = new Set([ |
| 32 | + "session-start", |
| 33 | + "pre-tool-use", |
| 34 | + "post-tool-use", |
| 35 | + "stop", |
| 36 | +]); |
| 37 | + |
| 38 | +interface DaemonResponse { |
| 39 | + continue?: boolean; |
| 40 | + stopReason?: string; |
| 41 | + decision?: "allow" | "deny"; |
| 42 | + reason?: string; |
| 43 | +} |
| 44 | + |
| 45 | +function defaultDaemonUrl(): string { |
| 46 | + return ( |
| 47 | + process.env.AGENTLOCK_DAEMON_URL ?? |
| 48 | + process.env.AGENTLOCK_CONTROL_PLANE_URL ?? |
| 49 | + "http://127.0.0.1:7878" |
| 50 | + ); |
| 51 | +} |
| 52 | + |
| 53 | +async function readStdin(): Promise<string> { |
| 54 | + const chunks: Buffer[] = []; |
| 55 | + for await (const chunk of process.stdin) { |
| 56 | + chunks.push(chunk as Buffer); |
| 57 | + } |
| 58 | + return Buffer.concat(chunks).toString("utf8"); |
| 59 | +} |
| 60 | + |
| 61 | +export async function runHookGemini(argv: string[]): Promise<void> { |
| 62 | + const event = argv[0]; |
| 63 | + if (!event || !ALLOWED_EVENTS.has(event)) { |
| 64 | + process.stderr.write( |
| 65 | + `usage: agentlock hook gemini <session-start|pre-tool-use|post-tool-use|stop>\n`, |
| 66 | + ); |
| 67 | + process.exit(2); |
| 68 | + } |
| 69 | + |
| 70 | + const raw = await readStdin(); |
| 71 | + if (!raw.trim()) { |
| 72 | + // No payload — nothing to forward. Fail-open: let Gemini continue. |
| 73 | + process.exit(0); |
| 74 | + } |
| 75 | + |
| 76 | + try { |
| 77 | + JSON.parse(raw); |
| 78 | + } catch (e) { |
| 79 | + process.stderr.write( |
| 80 | + `agentlock hook gemini ${event}: invalid JSON on stdin: ${(e as Error).message}\n`, |
| 81 | + ); |
| 82 | + // Fail-open: invalid payload is the harness's bug, not policy. |
| 83 | + process.exit(0); |
| 84 | + } |
| 85 | + |
| 86 | + const url = defaultDaemonUrl().replace(/\/+$/, "") + `/v1/hooks/gemini/${event}`; |
| 87 | + let res: Response; |
| 88 | + try { |
| 89 | + res = await fetch(url, { |
| 90 | + method: "POST", |
| 91 | + headers: { "Content-Type": "application/json" }, |
| 92 | + body: raw, |
| 93 | + }); |
| 94 | + } catch { |
| 95 | + // Daemon unreachable — silent fail-open. No stdout (Gemini parses |
| 96 | + // stdout as JSON; any garbage would error the harness). No stderr |
| 97 | + // either: on exit 0 Gemini may still surface stderr text in some |
| 98 | + // builds, and an "offline" line would alarm without being |
| 99 | + // actionable from inside the model loop. |
| 100 | + process.exit(0); |
| 101 | + } |
| 102 | + |
| 103 | + if (!res.ok) { |
| 104 | + process.stderr.write( |
| 105 | + `agentlock hook gemini ${event}: daemon returned ${res.status}\n`, |
| 106 | + ); |
| 107 | + process.exit(0); // fail-open |
| 108 | + } |
| 109 | + |
| 110 | + let parsed: DaemonResponse; |
| 111 | + try { |
| 112 | + parsed = (await res.json()) as DaemonResponse; |
| 113 | + } catch (e) { |
| 114 | + process.stderr.write( |
| 115 | + `agentlock hook gemini ${event}: malformed daemon response: ${(e as Error).message}\n`, |
| 116 | + ); |
| 117 | + process.exit(0); |
| 118 | + } |
| 119 | + |
| 120 | + // PostToolUse / SessionStart / Stop are observability — the daemon |
| 121 | + // returns {continue: true} (and may include decision: "allow") |
| 122 | + // unconditionally. Only PreToolUse can deny. Treat any non-`deny` |
| 123 | + // value (including missing) as allow. |
| 124 | + if (parsed.decision === "deny") { |
| 125 | + const reason = |
| 126 | + parsed.reason ?? |
| 127 | + parsed.stopReason ?? |
| 128 | + "blocked by OpenAgentLock policy"; |
| 129 | + process.stderr.write(`${reason}\n`); |
| 130 | + // Emit the JSON body too so future Gemini versions that prefer |
| 131 | + // stdout-JSON over exit-code see a consistent deny envelope. |
| 132 | + process.stdout.write(JSON.stringify(parsed) + "\n"); |
| 133 | + process.exit(2); |
| 134 | + } |
| 135 | + |
| 136 | + // Allow path. Stay silent on stdout — Gemini parses stdout as JSON, |
| 137 | + // and a bare "allow" envelope is at best redundant noise. |
| 138 | + process.exit(0); |
| 139 | +} |
0 commit comments