From 5feb3a6bba7a8a8e93a74c9da3197f7c52d5627a Mon Sep 17 00:00:00 2001 From: raj-khan Date: Sat, 24 Oct 2026 13:56:45 +0800 Subject: [PATCH] Add Gemini CLI support init registers BeforeTool, AfterTool and SessionStart in .gemini/settings.json. On AfterTool a trip hides the real result from the model, so the canary never reaches its context. --- backlog/tasks/task-6 - Support-Gemini-CLI.md | 9 +- src/agents/gemini.ts | 89 ++++++++++++++++++++ src/agents/index.ts | 5 +- src/index.ts | 4 +- test/gemini.test.js | 76 +++++++++++++++++ 5 files changed, 175 insertions(+), 8 deletions(-) create mode 100644 src/agents/gemini.ts create mode 100644 test/gemini.test.js diff --git a/backlog/tasks/task-6 - Support-Gemini-CLI.md b/backlog/tasks/task-6 - Support-Gemini-CLI.md index 7478fb1..f8c018b 100644 --- a/backlog/tasks/task-6 - Support-Gemini-CLI.md +++ b/backlog/tasks/task-6 - Support-Gemini-CLI.md @@ -1,9 +1,10 @@ --- id: TASK-6 title: Support Gemini CLI -status: To Do +status: Done assignee: [] created_date: '2026-10-24 05:41' +updated_date: '2026-10-24 05:56' labels: - multi-agent dependencies: @@ -22,7 +23,7 @@ Gemini CLI is now for paid API keys and enterprise only, but still in use. Regis ## Acceptance Criteria -- [ ] #1 A read_file of a decoy is denied and recorded -- [ ] #2 AfterTool catches a canary in tool_response.llmContent -- [ ] #3 Removing the hook from .gemini/settings.json trips tamper +- [x] #1 A read_file of a decoy is denied and recorded +- [x] #2 AfterTool catches a canary in tool_response.llmContent +- [x] #3 Removing the hook from .gemini/settings.json trips tamper diff --git a/src/agents/gemini.ts b/src/agents/gemini.ts new file mode 100644 index 0000000..88f278a --- /dev/null +++ b/src/agents/gemini.ts @@ -0,0 +1,89 @@ +import { existsSync } from "node:fs"; +import { join } from "node:path"; +import { registeredSpecs, registerSpecs, unregisterSpecs, type HookSpec } from "../settings.js"; +import type { Agent, ToolCall } from "./types.js"; + +const CONFIG = ".gemini/settings.json"; +const HOOK = "canaryhook hook --agent gemini"; +const SESSION = "canaryhook session-start --agent gemini"; + +/** Tool matchers are regexes; SessionStart matches on its source, so it has none to cover every one. */ +const SPECS: HookSpec[] = [ + { event: "BeforeTool", matcher: ".*", command: HOOK }, + { event: "AfterTool", matcher: ".*", command: HOOK }, + { event: "SessionStart", command: SESSION }, +]; + +interface GeminiInput { + session_id?: string; + cwd?: string; + hook_event_name?: string; + tool_name?: string; + tool_input?: unknown; + tool_response?: unknown; +} + +/** Older Gemini CLI releases name the read_file argument `absolute_path`. */ +function normalize(input: unknown): unknown { + if (!input || typeof input !== "object") return input; + const args = input as Record; + return typeof args.absolute_path === "string" && args.file_path === undefined + ? { ...args, file_path: args.absolute_path } + : input; +} + +export const gemini: Agent = { + name: "gemini", + label: "Gemini CLI", + configPath: CONFIG, + hookCommand: HOOK, + sessionCommand: SESSION, + detect: (root) => existsSync(join(root, ".gemini")), + + wiring(root) { + const [pre, post, session] = registeredSpecs(root, CONFIG, SPECS); + return { pre, post, session }; + }, + register: (root) => registerSpecs(root, CONFIG, SPECS), + unregister: (root) => (existsSync(join(root, CONFIG)) ? unregisterSpecs(root, CONFIG, SPECS) : false), + + parse(payload) { + if (!payload || typeof payload !== "object") return null; + const input = payload as GeminiInput; + const call: ToolCall = { + phase: input.hook_event_name === "AfterTool" ? "post" : "pre", + dirs: [input.cwd, process.env.GEMINI_PROJECT_DIR, process.cwd()], + tool: input.tool_name ?? "unknown", + input: normalize(input.tool_input), + output: input.tool_response, + sessionId: input.session_id, + }; + return call; + }, + // Stdout is always parsed as JSON; {} has nothing to say and leaves the + // user's own confirmation rules in charge. + pass: () => "{}", + deny: (reason) => JSON.stringify({ decision: "deny", reason }), + // On AfterTool, deny hides the real result from the model and sends the + // reason in its place, so the canary never reaches the agent's context. + flag: (reason) => JSON.stringify({ decision: "deny", reason }), + report: (context) => + JSON.stringify({ hookSpecificOutput: { hookEventName: "SessionStart", additionalContext: context } }), + + probe: (root, path) => ({ + session_id: "canaryhook-doctor", + hook_event_name: "BeforeTool", + cwd: root, + tool_name: "read_file", + tool_input: { file_path: path }, + }), + verdict(stdout) { + try { + const decision = (JSON.parse(stdout) as { decision?: string }).decision; + if (decision === undefined || decision === "allow") return "pass"; + return decision === "block" ? "deny" : decision; + } catch { + return null; + } + }, +}; diff --git a/src/agents/index.ts b/src/agents/index.ts index 03fb3ba..604877b 100644 --- a/src/agents/index.ts +++ b/src/agents/index.ts @@ -2,13 +2,14 @@ import { antigravity } from "./antigravity.js"; import { claude } from "./claude.js"; import { codex } from "./codex.js"; import { cursor } from "./cursor.js"; +import { gemini } from "./gemini.js"; import type { State } from "../state.js"; import type { Agent } from "./types.js"; export type { Agent, ToolCall, Wiring } from "./types.js"; /** Every agent canaryhook can wire, in the order they are reported. */ -export const AGENTS: Agent[] = [claude, cursor, codex, antigravity]; +export const AGENTS: Agent[] = [claude, cursor, codex, antigravity, gemini]; export function agentNamed(name: string): Agent | undefined { return AGENTS.find((agent) => agent.name === name); @@ -30,4 +31,4 @@ export function wiredAgents(state: State): Agent[] { return (state.agents ?? [claude.name]).flatMap((name) => agentNamed(name) ?? []); } -export { antigravity, claude, codex, cursor }; +export { antigravity, claude, codex, cursor, gemini }; diff --git a/src/index.ts b/src/index.ts index 1564de9..1c88bb9 100644 --- a/src/index.ts +++ b/src/index.ts @@ -29,8 +29,8 @@ Commands Options --json Machine-readable output --agents init: agents to wire, comma-separated: claude, cursor, - codex, antigravity. Default: Claude Code plus any agent - the project already uses + codex, antigravity, gemini. Default: Claude Code plus + any agent the project already uses --commit init: track the decoys in git instead of ignoring them --force init: replant over an existing setup --keep-alerts clean: keep .canaryhook/alerts.jsonl diff --git a/test/gemini.test.js b/test/gemini.test.js new file mode 100644 index 0000000..bacd3eb --- /dev/null +++ b/test/gemini.test.js @@ -0,0 +1,76 @@ +import assert from "node:assert/strict"; +import { spawnSync } from "node:child_process"; +import { mkdirSync, mkdtempSync, readFileSync, realpathSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import test from "node:test"; +import { fileURLToPath } from "node:url"; +import { inspect } from "../dist/hook.js"; + +const CLI = fileURLToPath(new URL("../dist/index.js", import.meta.url)); + +function run(args, cwd, input) { + return spawnSync(process.execPath, [CLI, ...args], { cwd, input, encoding: "utf8" }); +} + +function project() { + const root = realpathSync(mkdtempSync(join(tmpdir(), "canaryhook-"))); + assert.equal(run(["init", "--agents", "gemini"], root).status, 0); + return root; +} + +const settings = (root) => JSON.parse(readFileSync(join(root, ".gemini", "settings.json"), "utf8")); +const call = (root, event, fields) => JSON.stringify({ session_id: "s1", hook_event_name: event, cwd: root, timestamp: "t", ...fields }); +const hook = (root, event, fields) => JSON.parse(run(["hook", "--agent", "gemini"], root, call(root, event, fields)).stdout); + +test("init registers BeforeTool, AfterTool and SessionStart in .gemini/settings.json, keeping other settings", () => { + const root = realpathSync(mkdtempSync(join(tmpdir(), "canaryhook-"))); + mkdirSync(join(root, ".gemini")); + writeFileSync(join(root, ".gemini", "settings.json"), JSON.stringify({ theme: "dark" })); + run(["init"], root); + const { theme, hooks } = settings(root); + assert.equal(theme, "dark"); + assert.equal(hooks.BeforeTool[0].matcher, ".*"); + assert.equal(hooks.BeforeTool[0].hooks[0].command, "canaryhook hook --agent gemini"); + assert.equal(hooks.AfterTool[0].hooks[0].command, "canaryhook hook --agent gemini"); + assert.equal("matcher" in hooks.SessionStart[0], false); + run(["clean"], root); + assert.deepEqual(settings(root), { theme: "dark" }); +}); + +test("denies a read_file of a decoy, under either argument name, and records it", () => { + const root = project(); + assert.equal(hook(root, "BeforeTool", { tool_name: "read_file", tool_input: { file_path: ".env.backup" } }).decision, "deny"); + assert.equal(hook(root, "BeforeTool", { tool_name: "read_file", tool_input: { absolute_path: join(root, "deploy_key.old") } }).decision, "deny"); + assert.equal(run(["verify"], root).status, 1); +}); + +test("a clean call answers {}", () => { + const root = project(); + assert.deepEqual(hook(root, "BeforeTool", { tool_name: "run_shell_command", tool_input: { command: "ls src" } }), {}); +}); + +test("AfterTool hides a result holding a canary from the model", () => { + const root = project(); + const { decoys } = JSON.parse(readFileSync(join(root, ".canaryhook", "canaries.json"), "utf8")); + const out = hook(root, "AfterTool", { + tool_name: "run_shell_command", + tool_input: { command: "grep -r AKIA ." }, + tool_response: { llmContent: `./.env.backup:AWS_ACCESS_KEY_ID=${decoys[0].token}`, returnDisplay: "" }, + }); + assert.equal(out.decision, "deny"); + assert.match(out.reason, /fake/); +}); + +test("removing the hook from .gemini/settings.json trips tamper", () => { + assert.equal(inspect([], { file_path: ".gemini/settings.json", content: `{"theme":"dark"}` })?.reason, "tamper"); + assert.equal(inspect([], { file_path: ".gemini/settings.json", old_string: `"command": "canaryhook hook --agent gemini"`, new_string: "" })?.reason, "tamper"); + assert.equal(inspect([], { file_path: ".gemini/settings.json", old_string: `"theme": "dark"`, new_string: `"theme": "light"` }), null); +}); + +test("session start reports past trips as additional context", () => { + const root = project(); + hook(root, "BeforeTool", { tool_name: "read_file", tool_input: { file_path: ".env.backup" } }); + const out = JSON.parse(run(["session-start", "--agent", "gemini"], root).stdout); + assert.match(out.hookSpecificOutput.additionalContext, /1 canary trip/); +});