diff --git a/backlog/tasks/task-5 - Support-Antigravity-CLI.md b/backlog/tasks/task-5 - Support-Antigravity-CLI.md index cd3a97b..fe76771 100644 --- a/backlog/tasks/task-5 - Support-Antigravity-CLI.md +++ b/backlog/tasks/task-5 - Support-Antigravity-CLI.md @@ -1,9 +1,10 @@ --- id: TASK-5 title: Support Antigravity CLI -status: To Do +status: Done assignee: [] created_date: '2026-10-24 05:41' +updated_date: '2026-10-24 05:54' labels: - multi-agent dependencies: @@ -22,8 +23,14 @@ Register PreToolUse, PostToolUse and SessionStart under a canaryhook key in .age ## Acceptance Criteria -- [ ] #1 A view_file of a decoy is denied and recorded -- [ ] #2 run_command CommandLine is checked like a shell command, globs included -- [ ] #3 A pass prints decision allow -- [ ] #4 Removing or disabling the canaryhook entry trips tamper +- [x] #1 A view_file of a decoy is denied and recorded +- [x] #2 run_command CommandLine is checked like a shell command, globs included +- [x] #3 A pass prints decision allow +- [x] #4 Removing or disabling the canaryhook entry trips tamper + +## Implementation Notes + + +PostToolUse payloads carry no tool output (only error), so the output check is not registered for Antigravity; status shows it as not supported. + diff --git a/src/agents/antigravity.ts b/src/agents/antigravity.ts new file mode 100644 index 0000000..6a63c34 --- /dev/null +++ b/src/agents/antigravity.ts @@ -0,0 +1,105 @@ +import { existsSync } from "node:fs"; +import { join } from "node:path"; +import { load, save } from "../settings.js"; +import type { Agent, ToolCall } from "./types.js"; + +const CONFIG = ".agents/hooks.json"; +const HOOK = "canaryhook hook --agent antigravity"; +const SESSION = "canaryhook session-start --agent antigravity"; +/** Antigravity keys hooks.json by hook name; this is canaryhook's entry. */ +const KEY = "canaryhook"; + +const entry = () => ({ + PreToolUse: [{ matcher: "*", hooks: [{ type: "command", command: HOOK }] }], + SessionStart: [{ type: "command", command: SESSION }], +}); + +interface AntigravityInput { + conversationId?: string; + workspacePaths?: string[]; + toolCall?: { name?: string; args?: Record }; +} + +/** + * Antigravity's tool arguments under the names the checks look for: the shell + * command at `command`, the file at `file_path`, written text at `content`. + */ +function normalize(args: Record = {}): Record { + const out: Record = { ...args }; + if (typeof args.CommandLine === "string") out.command = args.CommandLine; + const path = args.AbsolutePath ?? args.TargetFile; + if (typeof path === "string") out.file_path = path; + if (typeof args.CodeContent === "string") out.content = args.CodeContent; + return out; +} + +const mentions = (value: unknown, command: string) => JSON.stringify(value ?? null).includes(JSON.stringify(command)); + +export const antigravity: Agent = { + name: "antigravity", + label: "Antigravity CLI", + configPath: CONFIG, + disables: /["']?enabled["']?\s*:\s*false/, + hookCommand: HOOK, + sessionCommand: SESSION, + detect: (root) => ["hooks.json", "rules", "workflows"].some((name) => existsSync(join(root, ".agents", name))), + + wiring(root) { + const ours = load(root, CONFIG)[KEY] as Record | undefined; + const on = !!ours && ours.enabled !== false; + // PostToolUse payloads carry no tool output, so there is nothing to check after a call. + return { pre: on && mentions(ours.PreToolUse, HOOK), post: null, session: on && mentions(ours.SessionStart, SESSION) }; + }, + register(root) { + const config = load(root, CONFIG); + if (JSON.stringify(config[KEY]) === JSON.stringify(entry())) return false; + config[KEY] = entry(); + save(root, CONFIG, config); + return true; + }, + unregister(root) { + if (!existsSync(join(root, CONFIG))) return false; + const config = load(root, CONFIG); + if (!(KEY in config)) return false; + delete config[KEY]; + save(root, CONFIG, config); + return true; + }, + + parse(payload) { + if (!payload || typeof payload !== "object") return null; + const input = payload as AntigravityInput; + if (!input.toolCall) return null; + const args = normalize(input.toolCall.args); + const call: ToolCall = { + phase: "pre", + dirs: [...(input.workspacePaths ?? []), typeof args.Cwd === "string" ? args.Cwd : undefined, process.cwd()], + tool: input.toolCall.name ?? "unknown", + input: args, + output: undefined, + sessionId: input.conversationId, + }; + return call; + }, + // `decision` is required on every PreToolUse answer; allow hands the call on + // to the user's own permission rules rather than approving it. + pass: (phase) => (phase === "pre" ? JSON.stringify({ decision: "allow" }) : ""), + deny: (reason) => JSON.stringify({ decision: "deny", reason }), + flag: () => "", + report: (context) => JSON.stringify({ injectSteps: [{ systemMessage: { systemMessage: context } }] }), + + probe: (root, path) => ({ + conversationId: "canaryhook-doctor", + workspacePaths: [root], + toolCall: { name: "view_file", args: { AbsolutePath: join(root, path) } }, + }), + verdict(stdout) { + try { + const decision = (JSON.parse(stdout) as { decision?: string }).decision; + if (decision === "allow") return "pass"; + return decision ?? null; + } catch { + return null; + } + }, +}; diff --git a/src/agents/index.ts b/src/agents/index.ts index 44c9281..03fb3ba 100644 --- a/src/agents/index.ts +++ b/src/agents/index.ts @@ -1,3 +1,4 @@ +import { antigravity } from "./antigravity.js"; import { claude } from "./claude.js"; import { codex } from "./codex.js"; import { cursor } from "./cursor.js"; @@ -7,7 +8,7 @@ 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]; +export const AGENTS: Agent[] = [claude, cursor, codex, antigravity]; export function agentNamed(name: string): Agent | undefined { return AGENTS.find((agent) => agent.name === name); @@ -29,4 +30,4 @@ export function wiredAgents(state: State): Agent[] { return (state.agents ?? [claude.name]).flatMap((name) => agentNamed(name) ?? []); } -export { claude, codex, cursor }; +export { antigravity, claude, codex, cursor }; diff --git a/src/agents/types.ts b/src/agents/types.ts index 73999b6..3e80260 100644 --- a/src/agents/types.ts +++ b/src/agents/types.ts @@ -14,8 +14,8 @@ export interface ToolCall { export interface Wiring { /** The hook that checks a call before it runs. */ pre: boolean; - /** The hook that checks what a call returned. */ - post: boolean; + /** The hook that checks what a call returned; null where the agent does not show hooks the output. */ + post: boolean | null; /** The hook that reports past trips when a session starts. */ session: boolean; } diff --git a/src/commands/status.ts b/src/commands/status.ts index e29190b..6a1c15e 100644 --- a/src/commands/status.ts +++ b/src/commands/status.ts @@ -26,7 +26,8 @@ export function status(root: string, options: { json: boolean }): number { return { agent, pre: false, post: false, session: false, problem: error.message }; } }); - const all = (key: "pre" | "post" | "session") => agents.every((a) => a[key]); + // An agent that cannot check output has nothing to register for it. + const all = (key: "pre" | "post" | "session") => agents.every((a) => a[key] !== false); const settingsProblem = agents.find((a) => a.problem !== null)?.problem ?? null; const alerts = readAlerts(root); const missing = decoys.filter((d) => !d.planted).length; @@ -58,7 +59,7 @@ export function status(root: string, options: { json: boolean }): number { return healthy ? 0 : 1; } - const mark = (ok: boolean) => (ok ? "registered" : "NOT registered"); + const mark = (ok: boolean | null) => (ok === null ? "not supported" : ok ? "registered" : "NOT registered"); process.stdout.write(`Initialized ${state.created}\n`); for (const a of agents) { process.stdout.write(`${a.agent.label} (${a.agent.configPath})\n`); diff --git a/src/index.ts b/src/index.ts index 29e0fe1..1564de9 100644 --- a/src/index.ts +++ b/src/index.ts @@ -28,8 +28,9 @@ Commands Options --json Machine-readable output - --agents init: agents to wire, comma-separated (claude, cursor, codex); default is - Claude Code plus any agent the project already uses + --agents init: agents to wire, comma-separated: claude, cursor, + codex, antigravity. 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/antigravity.test.js b/test/antigravity.test.js new file mode 100644 index 0000000..211f34e --- /dev/null +++ b/test/antigravity.test.js @@ -0,0 +1,89 @@ +import assert from "node:assert/strict"; +import { spawnSync } from "node:child_process"; +import { chmodSync, 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, env = process.env) { + return spawnSync(process.execPath, [CLI, ...args], { cwd, input, encoding: "utf8", env }); +} + +function project() { + const root = realpathSync(mkdtempSync(join(tmpdir(), "canaryhook-"))); + assert.equal(run(["init", "--agents", "antigravity"], root).status, 0); + return root; +} + +const config = (root) => JSON.parse(readFileSync(join(root, ".agents", "hooks.json"), "utf8")); +const call = (root, name, args) => JSON.stringify({ conversationId: "c1", workspacePaths: [root], toolCall: { name, args } }); +const hook = (root, name, args) => JSON.parse(run(["hook", "--agent", "antigravity"], root, call(root, name, args)).stdout); + +test("init adds a canaryhook entry to .agents/hooks.json, keeping other entries", () => { + const root = realpathSync(mkdtempSync(join(tmpdir(), "canaryhook-"))); + mkdirSync(join(root, ".agents")); + writeFileSync(join(root, ".agents", "hooks.json"), JSON.stringify({ lint: { PostToolUse: [] } })); + run(["init"], root); + const { canaryhook, lint } = config(root); + assert.deepEqual(lint, { PostToolUse: [] }); + assert.equal(canaryhook.PreToolUse[0].hooks[0].command, "canaryhook hook --agent antigravity"); + assert.equal(canaryhook.SessionStart[0].command, "canaryhook session-start --agent antigravity"); + run(["clean"], root); + assert.deepEqual(config(root), { lint: { PostToolUse: [] } }); +}); + +test("denies a view_file of a decoy and records it", () => { + const root = project(); + const out = hook(root, "view_file", { AbsolutePath: join(root, ".env.backup") }); + assert.equal(out.decision, "deny"); + assert.match(out.reason, /tripwire/); + assert.equal(run(["verify"], root).status, 1); +}); + +test("checks run_command like a shell command, globs included", () => { + const root = project(); + assert.equal(hook(root, "run_command", { CommandLine: "cat *.bak", Cwd: root }).decision, "deny"); + assert.equal(hook(root, "run_command", { CommandLine: "ls src", Cwd: root }).decision, "allow"); +}); + +test("a pass is an explicit allow, which defers to the user's own rules", () => { + const root = project(); + assert.deepEqual(hook(root, "view_file", { AbsolutePath: join(root, "README.md") }), { decision: "allow" }); + assert.deepEqual(JSON.parse(run(["hook", "--agent", "antigravity"], root, "{}").stdout), { decision: "allow" }); +}); + +test("removing or disabling the canaryhook entry trips tamper", () => { + for (const toolInput of [ + { TargetFile: ".agents/hooks.json", CodeContent: "{}", file_path: ".agents/hooks.json", content: "{}" }, + { file_path: ".agents/hooks.json", old_string: `"canaryhook": {`, new_string: `"canaryhook": { "enabled": false,` }, + { command: "rm .agents/hooks.json" }, + ]) { + assert.equal(inspect([], toolInput)?.reason, "tamper", JSON.stringify(toolInput)); + } + const root = project(); + assert.equal(hook(root, "write_to_file", { TargetFile: join(root, ".agents", "hooks.json"), CodeContent: "{}" }).decision, "deny"); +}); + +test("status marks the output hook as not supported and stays healthy", () => { + const root = project(); + const status = JSON.parse(run(["status", "--json"], root).stdout); + assert.equal(status.agents[0].outputHookRegistered, null); + assert.equal(status.healthy, true); + assert.match(run(["status"], root).stdout, /Output hook {2}not supported/); +}); + +test("doctor and session start work in Antigravity's format", () => { + const root = project(); + const bin = mkdtempSync(join(tmpdir(), "canaryhook-bin-")); + writeFileSync(join(bin, "canaryhook"), `#!/bin/sh\nexec ${process.execPath} ${CLI} "$@"\n`); + chmodSync(join(bin, "canaryhook"), 0o755); + const doctor = run(["doctor", "--json"], root, undefined, { ...process.env, PATH: `${bin}:${process.env.PATH}` }); + assert.equal(doctor.status, 0, doctor.stdout); + hook(root, "view_file", { AbsolutePath: join(root, ".env.backup") }); + const out = JSON.parse(run(["session-start", "--agent", "antigravity"], root).stdout); + assert.match(out.injectSteps[0].systemMessage.systemMessage, /1 canary trip/); +});