Skip to content

Commit 4fb0065

Browse files
authored
Merge pull request #54 from openagentlock/feat/gemini-cli-harness
Feat/gemini cli harness
2 parents a3740ee + 75a42fd commit 4fb0065

13 files changed

Lines changed: 1704 additions & 8 deletions

File tree

cli/src/commands/hook-gemini.ts

Lines changed: 139 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,139 @@
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+
}

cli/src/commands/install.ts

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -164,18 +164,20 @@ export async function runInstall(argv: string[] = []): Promise<void> {
164164
"claude-code": flags.configDirOverride,
165165
codex: flags.configDirOverride,
166166
cursor: flags.configDirOverride,
167+
gemini: flags.configDirOverride,
167168
}
168169
: {
169170
"claude-code": resolve(join(home(), ".claude")),
170171
codex: resolve(join(home(), ".codex")),
171172
cursor: resolve(join(home(), ".cursor")),
173+
gemini: resolve(join(home(), ".gemini")),
172174
};
173175

174176
// 1. Detection ---------------------------------------------------------
175177
const devMode = !!process.env.AGENTLOCK_DEV_HOME;
176178
const results = await detectAll();
177179
const isMvpEnabled = (id: HarnessId): boolean =>
178-
id === "claude-code" || id === "codex" || id === "cursor";
180+
id === "claude-code" || id === "codex" || id === "cursor" || id === "gemini";
179181
const options = results.map((r) => {
180182
const enabled = devMode || isMvpEnabled(r.id);
181183
let sub: string;
@@ -337,6 +339,10 @@ export async function runInstall(argv: string[] = []): Promise<void> {
337339
uninstallPaths.push(resolve(join(dir, "settings.json")));
338340
} else if (id === "codex" || id === "cursor") {
339341
uninstallPaths.push(resolve(join(dir, "hooks.json")));
342+
} else if (id === "gemini") {
343+
// Gemini stuffs hook entries into the same settings.json as
344+
// every other CLI setting — no separate hooks.json file.
345+
uninstallPaths.push(resolve(join(dir, "settings.json")));
340346
}
341347
}
342348
const uninstallExisting = await readExistingFiles(uninstallPaths);
@@ -385,11 +391,15 @@ export async function runInstall(argv: string[] = []): Promise<void> {
385391
const codexHooks = resolve(join(hostConfigDirs["codex"], "hooks.json"));
386392
const codexConfig = resolve(join(hostConfigDirs["codex"], "config.toml"));
387393
const cursorHooks = resolve(join(hostConfigDirs["cursor"], "hooks.json"));
394+
const geminiSettings = resolve(
395+
join(hostConfigDirs["gemini"], "settings.json"),
396+
);
388397
const existingFiles = await readExistingFiles([
389398
claudeSettings,
390399
codexHooks,
391400
codexConfig,
392401
cursorHooks,
402+
geminiSettings,
393403
]);
394404

395405
// Write the status-line script alongside the binary wrapper. Daemon
@@ -477,7 +487,7 @@ export async function runInstall(argv: string[] = []): Promise<void> {
477487
} catch (err) {
478488
process.stderr.write(`\n${(err as Error).message}\n`);
479489
process.stderr.write(
480-
"use --config-dir ./dev/.claude (or ./dev/.codex, ./dev/.cursor) for dev runs.\n",
490+
"use --config-dir ./dev/.claude (or ./dev/.codex, ./dev/.cursor, ./dev/.gemini) for dev runs.\n",
481491
);
482492
process.exitCode = 2;
483493
return;

cli/src/index.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ import { runFakeHook } from "./commands/fake-hook.ts";
2323
import { runHookClaudeCode } from "./commands/hook-claude-code.ts";
2424
import { runHookCodex } from "./commands/hook-codex.ts";
2525
import { runHookCursor } from "./commands/hook-cursor.ts";
26+
import { runHookGemini } from "./commands/hook-gemini.ts";
2627
import { runLedgerRoot, runLedgerVerify } from "./commands/ledger.ts";
2728
import { runSignerEnroll } from "./commands/signer-enroll.ts";
2829
import {
@@ -510,6 +511,17 @@ const hookCursor = hook
510511
});
511512
void hookCursor;
512513

514+
const hookGemini = hook
515+
.command("gemini <event>")
516+
.description(
517+
"Gemini CLI shim. Reads stdin hook payload, forwards to /v1/hooks/gemini/<event>, maps allow/deny → exit 0/2 (deny also writes {decision,reason} JSON to stdout).",
518+
)
519+
.allowUnknownOption()
520+
.action(async (event: string) => {
521+
await runHookGemini([event]);
522+
});
523+
void hookGemini;
524+
513525
const hookClaudeCode = hook
514526
.command("claude-code <event>")
515527
.description(

cli/src/util/install-fs.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -27,15 +27,15 @@ export function checkSafeTarget(
2727
): void {
2828
if (opts.bypass) return;
2929
const home = homedir();
30-
const allowed = [".claude", ".codex", ".cursor"].map((d) =>
30+
const allowed = [".claude", ".codex", ".cursor", ".gemini"].map((d) =>
3131
resolve(home, d),
3232
);
3333
const target = resolve(absPath);
3434
for (const root of allowed) {
3535
if (target === root || target.startsWith(root + sep)) return;
3636
}
3737
throw new Error(
38-
`unsafe target: ${absPath} does not resolve under ~/.claude, ~/.codex, or ~/.cursor`,
38+
`unsafe target: ${absPath} does not resolve under ~/.claude, ~/.codex, ~/.cursor, or ~/.gemini`,
3939
);
4040
}
4141

0 commit comments

Comments
 (0)