Developer guide

Add a pre-action check to a Claude Agent SDK agent

A Claude Agent SDK tool call needs a PreToolUse hook if it must be checked before the tool runs.

What the check does

The check returns permit, deny, or escalate with the governing clause and a signed receipt. It is advisory, and your PreToolUse hook is what refuses execution. The hosted check evaluates the request in memory and keeps no request content.

Code

This is the audited example exactly as published under public/examples/frameworks/.

/** Claude Agent SDK PreToolUse hook that checks HANRIA before execution. */

import { query, type HookCallback, type HookCallbackMatcher, type Options } from "@anthropic-ai/claude-agent-sdk";
import { pathToFileURL } from "node:url";

const CHECK_URL = "https://check.hanria.ai/v1/check";
const MANDATE = {
  schema_version: "0.2-draft",
  mandate_id: "review-tool-calls",
  purpose: "Permit file reads under the example directory.",
  default: "deny",
  clauses: [{
    id: "permit-example-read",
    effect: "permit",
    match: { kind: ["file"], verb: ["read"], target_prefix: ["/tmp/example/"] },
  }],
};

type FetchLike = typeof globalThis.fetch;

export async function checkAction(
  action: Record<string, unknown>,
  checkUrl = CHECK_URL,
  fetchImpl: FetchLike = globalThis.fetch,
): Promise<Record<string, unknown>> {
  try {
    const response = await fetchImpl(checkUrl, {
      method: "POST",
      headers: { "content-type": "application/json" },
      body: JSON.stringify({ mandate: MANDATE, action }),
      signal: AbortSignal.timeout(10000),
    });
    if (!response.ok) return { outcome: "error", reason: `HANRIA returned HTTP ${response.status}` };
    const decision = await response.json() as Record<string, unknown>;
    if (!["permit", "deny", "escalate", "error"].includes(String(decision.outcome))) {
      return { outcome: "error", reason: "HANRIA returned an invalid decision" };
    }
    return decision;
  } catch (error) {
    return { outcome: "error", reason: `HANRIA check failed: ${String(error)}` };
  }
}

export function createHanriaHook({
  checkUrl = CHECK_URL,
  fetchImpl = globalThis.fetch,
}: { checkUrl?: string; fetchImpl?: FetchLike } = {}): HookCallback {
  return async (input) => {
    if (input.hook_event_name !== "PreToolUse") {
      return { continue: false, stopReason: "Expected a PreToolUse event" };
    }
    const toolInput = input.tool_input as Record<string, unknown>;
    const action = {
      schema_version: "0.1-draft",
      requested_by: { agent: "claude-agent-sdk-example" },
      operation: {
        kind: input.tool_name === "Read" ? "file" : "tool",
        verb: input.tool_name === "Read" ? "read" : input.tool_name,
        target: String(toolInput.file_path ?? toolInput.path ?? input.tool_name),
      },
      justification: `Run ${input.tool_name} through the Claude Agent SDK.`,
    };
    const decision = await checkAction(action, checkUrl, fetchImpl);
    if (decision.outcome === "permit") return {};
    return {
      hookSpecificOutput: {
        hookEventName: "PreToolUse",
        permissionDecision: "deny",
        permissionDecisionReason: `HANRIA returned ${String(decision.outcome ?? "error")}: ${String(decision.reason ?? "no reason returned")}`,
      },
    };
  };
}

export function createHookOptions(settings: { checkUrl?: string; fetchImpl?: FetchLike } = {}): Options {
  const matcher: HookCallbackMatcher = { hooks: [createHanriaHook(settings)] };
  return { hooks: { PreToolUse: [matcher] } };
}

export function constructQuery(prompt: string, options = createHookOptions()) {
  return query({ prompt, options });
}

if (process.argv[1] && pathToFileURL(process.argv[1]).href === import.meta.url) {
  const options = createHookOptions();
  console.log(`Constructed ${options.hooks?.PreToolUse?.length ?? 0} HANRIA PreToolUse hook matcher.`);
}

Run it

Save claude_agent_sdk.ts, then run these commands from the folder where you saved the file.

npm install --no-save --save-exact @anthropic-ai/[email protected] [email protected]
npx tsx claude_agent_sdk.ts

The command constructs one PreToolUse hook matcher and does not start a query. Pass createHookOptions() to the SDK query in your application, and map each tool input to the action fields your mandate checks.

Handle every outcome

A timeout, transport failure, non-success HTTP response, malformed response, or unknown outcome becomes error. Every result other than exact permit is refused by the hook.

References

Try the check · Verify signed receipts · OpenAPI document · Agent-facing summary