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
17 changes: 12 additions & 5 deletions backlog/tasks/task-5 - Support-Antigravity-CLI.md
Original file line number Diff line number Diff line change
@@ -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:
Expand All @@ -22,8 +23,14 @@ Register PreToolUse, PostToolUse and SessionStart under a canaryhook key in .age

## Acceptance Criteria
<!-- AC:BEGIN -->
- [ ] #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
<!-- AC:END -->

## Implementation Notes

<!-- SECTION:NOTES:BEGIN -->
PostToolUse payloads carry no tool output (only error), so the output check is not registered for Antigravity; status shows it as not supported.
<!-- SECTION:NOTES:END -->
105 changes: 105 additions & 0 deletions src/agents/antigravity.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown> };
}

/**
* 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<string, unknown> = {}): Record<string, unknown> {
const out: Record<string, unknown> = { ...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<string, unknown> | 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;
}
},
};
5 changes: 3 additions & 2 deletions src/agents/index.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { antigravity } from "./antigravity.js";
import { claude } from "./claude.js";
import { codex } from "./codex.js";
import { cursor } from "./cursor.js";
Expand All @@ -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);
Expand All @@ -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 };
4 changes: 2 additions & 2 deletions src/agents/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down
5 changes: 3 additions & 2 deletions src/commands/status.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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`);
Expand Down
5 changes: 3 additions & 2 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,8 +28,9 @@ Commands

Options
--json Machine-readable output
--agents <list> init: agents to wire, comma-separated (claude, cursor, codex); default is
Claude Code plus any agent the project already uses
--agents <list> 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
Expand Down
89 changes: 89 additions & 0 deletions test/antigravity.test.js
Original file line number Diff line number Diff line change
@@ -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/);
});
Loading