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
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
---
id: TASK-1
title: Refactor the Claude Code wiring into an agent adapter
status: To Do
status: Done
assignee: []
created_date: '2026-10-24 05:41'
updated_date: '2026-10-24 05:44'
labels:
- multi-agent
dependencies: []
Expand All @@ -19,7 +20,7 @@ Move the Claude-specific parts (settings path, hook registration, payload parsin

## Acceptance Criteria
<!-- AC:BEGIN -->
- [ ] #1 src/agents/claude.ts holds everything Claude-specific
- [ ] #2 hook, session-start, init, status, doctor and clean go through the adapter
- [ ] #3 Existing tests pass unchanged
- [x] #1 src/agents/claude.ts holds everything Claude-specific
- [x] #2 hook, session-start, init, status, doctor and clean go through the adapter
- [x] #3 Existing tests pass unchanged
<!-- AC:END -->
66 changes: 66 additions & 0 deletions src/agents/claude.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
import { HOOK_COMMAND, registeredSpecs, registerSpecs, SESSION_COMMAND, SETTINGS_PATH, SPECS, unregisterSpecs } from "../settings.js";
import type { Agent, ToolCall } from "./types.js";

interface ClaudeInput {
session_id?: string;
cwd?: string;
hook_event_name?: string;
tool_name?: string;
tool_input?: unknown;
tool_response?: unknown;
}

export const claude: Agent = {
name: "claude",
label: "Claude Code",
configPath: SETTINGS_PATH,
hookCommand: HOOK_COMMAND,
sessionCommand: SESSION_COMMAND,

wiring(root) {
const [pre, session, post] = registeredSpecs(root, SETTINGS_PATH, SPECS);
return { pre, post, session };
},
register: (root) => registerSpecs(root, SETTINGS_PATH, SPECS),
unregister: (root) => unregisterSpecs(root, SETTINGS_PATH, SPECS),

parse(payload) {
if (!payload || typeof payload !== "object") return null;
const input = payload as ClaudeInput;
const call: ToolCall = {
phase: input.hook_event_name === "PostToolUse" ? "post" : "pre",
// The agent's cwd may be a subdirectory of the project; fall back to the
// directory Claude Code was started in.
dirs: [input.cwd, process.env.CLAUDE_PROJECT_DIR, process.cwd()],
tool: input.tool_name ?? "unknown",
input: input.tool_input,
output: input.tool_response,
sessionId: input.session_id,
};
return call;
},
pass: () => "",
deny: (reason) =>
JSON.stringify({
hookSpecificOutput: { hookEventName: "PreToolUse", permissionDecision: "deny", permissionDecisionReason: reason },
}),
flag: (reason) => JSON.stringify({ decision: "block", reason }),
report: (context) =>
JSON.stringify({ hookSpecificOutput: { hookEventName: "SessionStart", additionalContext: context } }),

probe: (root, path) => ({
session_id: "canaryhook-doctor",
cwd: root,
tool_name: "Read",
tool_input: { file_path: path },
}),
verdict(stdout) {
if (stdout.trim() === "") return "pass";
try {
const parsed = JSON.parse(stdout) as { hookSpecificOutput?: { permissionDecision?: string } };
return parsed.hookSpecificOutput?.permissionDecision ?? "pass";
} catch {
return null;
}
},
};
13 changes: 13 additions & 0 deletions src/agents/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import { claude } from "./claude.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];

export function agentNamed(name: string): Agent | undefined {
return AGENTS.find((agent) => agent.name === name);
}

export { claude };
56 changes: 56 additions & 0 deletions src/agents/types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
/** One tool call as an agent's hook reported it, in canaryhook's terms. */
export interface ToolCall {
/** Before the call runs, when it can still be denied, or after, with its output. */
phase: "pre" | "post";
/** Directories to look for the project in, nearest first. */
dirs: (string | undefined)[];
tool: string;
/** Tool input, with the shell command at `command` and the path at `file_path`. */
input: unknown;
output: unknown;
sessionId?: string;
}

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 reports past trips when a session starts. */
session: boolean;
}

/**
* Everything canaryhook needs to know about one coding agent: where its hooks
* are configured, what its hook payloads look like, and how it expects to be
* answered. The detection itself is the same for every agent.
*/
export interface Agent {
name: string;
label: string;
/** Project-relative file the hooks are registered in. */
configPath: string;
hookCommand: string;
sessionCommand: string;
/** Throws SettingsError when the config file cannot be read safely. */
wiring(root: string): Wiring;
register(root: string): boolean;
unregister(root: string): boolean;
/** The hook payload as JSON, or null when it is not one this agent sends. */
parse(payload: unknown): ToolCall | null;
/** Stdout for a call that tripped nothing, or "" to print nothing. */
pass(phase: ToolCall["phase"]): string;
/** Stdout denying a call before it runs. */
deny(reason: string): string;
/** Stdout telling the agent a call that already ran read a decoy. */
flag(reason: string): string;
/** Stdout handing past trips to the agent at the start of a session. */
report(context: string): string;
/** A payload for `doctor` to send through the hook, asking about a read of `path`. */
probe(root: string, path: string): unknown;
/**
* What the hook's stdout decided about a probe: "deny", "pass" for letting it
* through, another decision by name, or null when the output is unreadable.
*/
verdict(stdout: string): string | null;
}
5 changes: 3 additions & 2 deletions src/commands/clean.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { existsSync, rmSync, unlinkSync } from "node:fs";
import { join } from "node:path";
import { SettingsError, unregister } from "../settings.js";
import { claude } from "../agents/index.js";
import { SettingsError } from "../settings.js";
import { isDecoyFile, readState, STATE_DIR } from "../state.js";

export function clean(root: string, options: { json: boolean; keepAlerts: boolean }): number {
Expand All @@ -25,7 +26,7 @@ export function clean(root: string, options: { json: boolean; keepAlerts: boolea
let hookRemoved = false;
let settingsProblem: string | null = null;
try {
hookRemoved = unregister(root);
hookRemoved = claude.unregister(root);
} catch (error) {
if (!(error instanceof SettingsError)) throw error;
settingsProblem = error.message;
Expand Down
50 changes: 20 additions & 30 deletions src/commands/doctor.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { spawnSync } from "node:child_process";
import { HOOK_COMMAND, isRegistered, SettingsError, SETTINGS_PATH } from "../settings.js";
import { claude, type Agent } from "../agents/index.js";
import { SettingsError } from "../settings.js";
import { isDecoyFile, readState, type Decoy } from "../state.js";

interface Check {
Expand All @@ -10,19 +11,13 @@ interface Check {

/**
* Runs the hook command exactly as the agent would, with a payload on stdin,
* and returns the permission decision it printed. CANARYHOOK_DRY_RUN keeps the
* probe out of the alert record.
* and returns what the agent would make of its answer. CANARYHOOK_DRY_RUN
* keeps the probe out of the alert record.
*/
function askHook(root: string, toolInput: unknown): { decision: string | null; error: string | null } {
const payload = JSON.stringify({
session_id: "canaryhook-doctor",
function askHook(agent: Agent, root: string, path: string): { decision: string | null; error: string | null } {
const run = spawnSync(agent.hookCommand, {
cwd: root,
tool_name: "Read",
tool_input: toolInput,
});
const run = spawnSync(HOOK_COMMAND, {
cwd: root,
input: payload,
input: JSON.stringify(agent.probe(root, path)),
shell: true,
encoding: "utf8",
env: { ...process.env, CANARYHOOK_DRY_RUN: "1" },
Expand All @@ -32,13 +27,9 @@ function askHook(root: string, toolInput: unknown): { decision: string | null; e
return { decision: null, error: `exited ${run.status}: ${(run.stderr ?? "").trim()}` };
}
const out = (run.stdout ?? "").trim();
if (out.length === 0) return { decision: null, error: null };
try {
const parsed = JSON.parse(out) as { hookSpecificOutput?: { permissionDecision?: string } };
return { decision: parsed.hookSpecificOutput?.permissionDecision ?? null, error: null };
} catch {
return { decision: null, error: `hook printed something that is not JSON: ${out.slice(0, 120)}` };
}
const decision = agent.verdict(out);
if (decision === null) return { decision: null, error: `hook printed something it should not: ${out.slice(0, 120)}` };
return { decision, error: null };
}

function decoyChecks(root: string, decoys: Decoy[]): Check {
Expand All @@ -53,7 +44,7 @@ function decoyChecks(root: string, decoys: Decoy[]): Check {
};
}

export function doctor(root: string, options: { json: boolean }): number {
export function doctor(root: string, options: { json: boolean }, agent: Agent = claude): number {
const state = readState(root);
if (!state) {
const message = "canaryhook is not initialized here. Run: canaryhook init";
Expand All @@ -64,39 +55,38 @@ export function doctor(root: string, options: { json: boolean }): number {

const checks: Check[] = [decoyChecks(root, state.decoys)];

let registered = false;
try {
registered = isRegistered(root);
const registered = agent.wiring(root).pre;
checks.push({
name: "hook registered",
ok: registered,
detail: registered ? `${HOOK_COMMAND} in ${SETTINGS_PATH}` : `not found in ${SETTINGS_PATH}`,
detail: registered ? `${agent.hookCommand} in ${agent.configPath}` : `not found in ${agent.configPath}`,
});
} catch (error) {
if (!(error instanceof SettingsError)) throw error;
checks.push({ name: "hook registered", ok: false, detail: error.message });
}

const trip = askHook(root, { file_path: state.decoys[0]?.path ?? ".env.backup" });
const trip = askHook(agent, root, state.decoys[0]?.path ?? ".env.backup");
checks.push({
name: "hook denies a decoy read",
ok: trip.decision === "deny",
detail:
trip.error !== null
? `could not run ${HOOK_COMMAND}: ${trip.error}`
? `could not run ${agent.hookCommand}: ${trip.error}`
: trip.decision === "deny"
? "a planted read came back denied"
: "the hook ran but let a decoy read through",
});

const quiet = askHook(root, { file_path: "src/index.ts" });
const quiet = askHook(agent, root, "src/index.ts");
checks.push({
name: "hook passes normal calls",
ok: quiet.error === null && quiet.decision === null,
ok: quiet.error === null && quiet.decision === "pass",
detail:
quiet.error !== null
? `could not run ${HOOK_COMMAND}: ${quiet.error}`
: quiet.decision === null
? `could not run ${agent.hookCommand}: ${quiet.error}`
: quiet.decision === "pass"
? "an ordinary read was left alone"
: `an ordinary read came back ${quiet.decision}`,
});
Expand All @@ -114,7 +104,7 @@ export function doctor(root: string, options: { json: boolean }): number {
process.stdout.write(
ok
? "\nThe hook is wired and firing.\n"
: `\nSomething is wrong. If ${HOOK_COMMAND} cannot run, the hook fails open and\n` +
: `\nSomething is wrong. If ${agent.hookCommand} cannot run, the hook fails open and\n` +
"nothing is being watched. Reinstall canaryhook, or run canaryhook init --force.\n",
);
return ok ? 0 : 1;
Expand Down
13 changes: 7 additions & 6 deletions src/commands/init.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
import { existsSync, readFileSync, unlinkSync, writeFileSync } from "node:fs";
import { join } from "node:path";
import { buildDecoys } from "../canaries.js";
import { isRegistered, register, SettingsError, SETTINGS_PATH } from "../settings.js";
import { claude } from "../agents/index.js";
import { SettingsError } from "../settings.js";
import { isDecoyFile, readState, writeState, STATE_DIR, type Decoy } from "../state.js";

const IGNORE_MARKER = "# canaryhook decoys";
Expand Down Expand Up @@ -31,7 +32,7 @@ export function init(root: string, options: { json: boolean; commit: boolean; fo
// Read the settings before planting anything, so a settings file canaryhook
// cannot parse stops the run instead of leaving half an install behind.
try {
isRegistered(root);
claude.wiring(root);
} catch (error) {
if (error instanceof SettingsError) return fail(error.message, options.json);
throw error;
Expand Down Expand Up @@ -62,7 +63,7 @@ export function init(root: string, options: { json: boolean; commit: boolean; fo
}

const decoys = built.map((b) => b.decoy);
const hookAdded = register(root);
const hookAdded = claude.register(root);
writeState(root, {
version: 1,
created: new Date().toISOString(),
Expand All @@ -78,7 +79,7 @@ export function init(root: string, options: { json: boolean; commit: boolean; fo
ok: true,
planted: decoys.map((d) => d.path),
hookRegistered: hookAdded,
settings: SETTINGS_PATH,
settings: claude.configPath,
gitignoreUpdated: ignored,
})}\n`,
);
Expand All @@ -89,8 +90,8 @@ export function init(root: string, options: { json: boolean; commit: boolean; fo
for (const decoy of decoys) process.stdout.write(` ${decoy.path} (${decoy.kind})\n`);
process.stdout.write(
hookAdded
? `\nRegistered the PreToolUse, PostToolUse and SessionStart hooks in ${SETTINGS_PATH}.\n`
: `\nThe hooks were already registered in ${SETTINGS_PATH}.\n`,
? `\nRegistered the PreToolUse, PostToolUse and SessionStart hooks in ${claude.configPath}.\n`
: `\nThe hooks were already registered in ${claude.configPath}.\n`,
);
if (ignored) process.stdout.write("Added the decoys and .canaryhook/ to .gitignore.\n");
if (options.commit) process.stdout.write("Left .gitignore alone, as --commit was passed.\n");
Expand Down
12 changes: 3 additions & 9 deletions src/commands/session.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { claude, type Agent } from "../agents/index.js";
import { readAlerts, STATE_DIR, type Alert } from "../state.js";

const RECENT = 3;
Expand All @@ -21,18 +22,11 @@ function summarize(alerts: Alert[]): string {
* session. Silent when the record is clean, and never fails loudly: a reporting
* problem must not get in the way of a session.
*/
export function sessionStart(root: string): number {
export function sessionStart(root: string, agent: Agent = claude): number {
try {
const alerts = readAlerts(root);
if (alerts.length === 0) return 0;
process.stdout.write(
`${JSON.stringify({
hookSpecificOutput: {
hookEventName: "SessionStart",
additionalContext: summarize(alerts),
},
})}\n`,
);
process.stdout.write(`${agent.report(summarize(alerts))}\n`);
} catch {
return 0;
}
Expand Down
Loading
Loading