Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 5 additions & 4 deletions backlog/tasks/task-6 - Support-Gemini-CLI.md
Original file line number Diff line number Diff line change
@@ -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:
Expand All @@ -22,7 +23,7 @@ Gemini CLI is now for paid API keys and enterprise only, but still in use. Regis

## Acceptance Criteria
<!-- AC:BEGIN -->
- [ ] #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
<!-- AC:END -->
89 changes: 89 additions & 0 deletions src/agents/gemini.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown>;
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;
}
},
};
5 changes: 3 additions & 2 deletions src/agents/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -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 };
4 changes: 2 additions & 2 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,8 +29,8 @@ Commands
Options
--json Machine-readable output
--agents <list> 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
Expand Down
76 changes: 76 additions & 0 deletions test/gemini.test.js
Original file line number Diff line number Diff line change
@@ -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/);
});
Loading