Skip to content
Agentic Control Plane
Quick-start series · Part 1 of 3
Governance in Three Minutes →

Log and Control Every Claude Code Tool Call in 60 Seconds

David Crowe David Crowe · · 6 min read
claude-code governance tutorial hooks
Share X HN LinkedIn

Claude Code is an autonomous agent that reads your files, writes code, runs shell commands, and makes web requests. It’s also completely ungoverned by default — there’s no audit trail, no policy enforcement, no visibility into what it’s doing on behalf of your team.

We built a governance layer that fixes this. One command to install. Every tool call logged. Policies enforced by agent type. Here’s exactly how it works, with real code from the implementation.


Install in one command

# Free, on-device, no account — decisions run locally from ~/.acp/policy.json:
curl -sf https://agenticcontrolplane.com/install.sh | bash -s -- --local

This does three things:

  1. Writes a governance script to ~/.acp/govern.mjs
  2. Registers a PreToolUse hook in Claude Code’s ~/.claude/settings.json
  3. Writes the on-device decision engine (~/.acp/decide.mjs) and your policy (~/.acp/policy.json), and logs every call to ~/.acp/audit.jsonl

No account, no network, nothing leaves your machine. After a restart (claude --continue), every tool call flows through the hook.

Want team policy across everyone’s agents, verified identity, and the cost X-ray? Run it without --local and the installer opens your browser to provision a workspace instead:

curl -sf https://agenticcontrolplane.com/install.sh | bash

What the hook actually does

Claude Code has a hook system. Before every tool call — Bash, Read, Write, Edit, Grep, WebFetch, Agent, and all MCP tools — it can fire a shell command that receives the call details on stdin and can allow or deny it.

Our hook is a 60-line Node.js script. Here’s the core:

// ~/.acp/govern.mjs (simplified)
const input = JSON.parse(readFileSync("/dev/stdin", "utf8"));

const res = await fetch("https://api.agenticcontrolplane.com/govern/tool-use", {
  method: "POST",
  headers: {
    Authorization: `Bearer ${token}`,
    "Content-Type": "application/json",
  },
  body: JSON.stringify({
    tool_name: input.tool_name,
    tool_input: input.tool_input,
    session_id: input.session_id,
    agent_tier: resolveAgentTier(input.permission_mode),
  }),
});

const data = await res.json();

if (data.decision === "deny") {
  process.stdout.write(JSON.stringify({
    hookSpecificOutput: { permissionDecision: "deny" },
    systemMessage: `[ACP] Blocked: ${data.reason}`,
  }));
}

The script reads the hook payload from stdin, sends it to ACP’s governance API, and returns the decision. If denied, Claude Code sees the reason and adapts — it doesn’t retry the blocked call, it tries a different approach.


Agent tier mapping

Not all Claude Code calls carry the same risk. Interactive calls (user is watching) are different from subagent calls (spawned for a task) and background agents (fully autonomous).

The hook maps Claude Code’s permission_mode to an ACP agent tier:

function resolveAgentTier(mode) {
  if (mode === "auto") return "subagent";
  if (mode === "bypassPermissions") return "background";
  return "interactive";
}

In the ACP dashboard, you set different policies per tier:

Tier Claude Code mode Default policy
Interactive default (user approves) Allow everything
Subagent auto (autonomous) Allow, rate-limited
Background bypassPermissions Deny destructive ops
API / CI API key access Scoped by key

A background agent trying Write("/etc/hosts") gets denied. The same call from an interactive session goes through. Same tool, different risk, different policy.


What shows up in the dashboard

Every call appears in real-time at cloud.agenticcontrolplane.com/activity:

  • Tool name: Bash, Write, Read, WebFetch, etc.
  • Input preview: the actual arguments (file paths, commands, URLs)
  • Decision: Allowed, Denied, or Flagged
  • Client: Claude Code (or OpenClaw, Cursor, etc.)
  • Agent tier: which permission level was applied
  • Latency: how long the governance check took

Denied calls show up with red backgrounds and the denial reason. You can filter by tool, decision, client, or time range.


Audit mode vs enforce mode

Audit mode (default): the hook fires asynchronously. Every call is logged, nothing is blocked. Latency impact: near zero. This is how you start — see what’s happening before you set rules.

Enforce mode: the hook waits for the decision (local engine or API). Calls that violate your policies are denied before the tool executes. A policy deny always blocks. If the engine or API is unreachable, the hook fails open by default — the call runs, but every one carries a loud “this ran UNGOVERNED” message so you can’t miss it. We never brick your agent because our gateway had a bad moment. Compliance-sensitive teams opt into fail-closed (echo closed > ~/.acp/failmode), where an unreachable engine blocks instead.

You switch modes with one click in the dashboard.


The governance pipeline

When a tool call hits ACP’s /govern/tool-use endpoint, it runs through a pipeline:

  1. Identity resolution — the API key or JWT maps to a workspace and identity
  2. Policy lookup — reads the governance config from Firestore
  3. Tier evaluation — checks the agent tier against the policy matrix
  4. Tool-specific overrides — checks if this specific tool has custom rules
  5. Decision — returns allow, deny, or flag with a reason
  6. Audit log — writes the full call context to Firestore (fire-and-forget)

The entire pipeline runs in ~150ms. In audit mode, the hook returns before the API call completes — the log is written async.


Real examples from a live session

These are actual tool calls from a development session, as they appear in the ACP dashboard:

Tool Input Decision Tier
Bash git push origin main --force Allowed Subagent
Write /etc/hosts Denied Background
WebFetch https://api.stripe.com/v1/charges Allowed Interactive
Read /Users/dev/.ssh/id_rsa Denied Background
Bash docker run --privileged -v /:/host alpine Denied Background
Edit src/auth/middleware.ts Allowed Interactive

The denied calls are background agents trying to access system files, run privileged containers, or modify host configuration. Interactive calls to the same resources go through because the user is watching.


What this means for teams

Individual developers get visibility — see what Claude is doing, set basic rules.

Teams get shared governance — invite members, see everyone’s activity, set policies that apply to the whole team. When a new developer joins and starts using Claude Code, their tool calls are immediately governed by the team’s policies.

The progression is natural: start solo, invite colleagues when it’s useful, tighten policies as you scale. No upfront config, no complex setup, no behavior change required.


Try it

curl -sf https://agenticcontrolplane.com/install.sh | bash -s -- --local

Takes 30 seconds. Free, no account, on your machine. Every Claude Code tool call, governed. Drop --local when you want a team workspace.

Curious what those tool calls could be? A real Claude Code session declares 76 tools — the Tool Surface Index lists them all, grouped by blast radius, and which ones to deny out of the box argues the default posture.

View the dashboard →

Share X HN LinkedIn
Get the next data drop
What agents actually cost, new tool-surface captures, and the occasional incident post-mortem — sent when we publish something worth your inbox, not on a schedule. Unsubscribe anytime.
Share: Twitter LinkedIn
More in Governance in Three Minutes
  1. 1. Log and Control Every Claude Code Tool Call in 60 Seconds · you are here
  2. 2. Allow, Deny, and Audit Every Tool Call in the Anthropic Agent SDK
  3. 3. Add Per-User Permissions and Audit to LangGraph in 3 Minutes
Related posts

← back to blog