| title | Architecture |
|---|---|
| description | How the hook handler, config loading, and policy evaluation work internally |
| icon | sitemap |
This document explains how failproofai works internally: how the hook system intercepts agent tool calls, how configuration is loaded and merged, how policies are evaluated, and how the dashboard monitors agent activity.
failproofai has two independent subsystems:
- Hook handler - A fast CLI subprocess that Claude Code invokes on every agent tool call. Evaluates policies and returns a decision.
- Agent Monitor (Dashboard) - A Next.js web application for monitoring agent sessions and managing policies.
Both subsystems share configuration files in ~/.failproofai/ and the project's .failproofai/ directory, but they run as separate processes and communicate only through the filesystem.
When you run failproofai policies --install, it writes entries like this into ~/.claude/settings.json:
{
"hooks": {
"PreToolUse": [
{
"matcher": "",
"hooks": [
{
"type": "command",
"command": "failproofai --hook PreToolUse"
}
]
}
],
"PostToolUse": [ ... ]
}
}Claude Code then invokes failproofai --hook PreToolUse as a subprocess before each tool call, passing a JSON payload on stdin.
{
"session_id": "abc123",
"transcript_path": "/home/user/.claude/projects/myproject/sessions/abc123.jsonl",
"cwd": "/home/user/myproject",
"permission_mode": "default",
"hook_event_name": "PreToolUse",
"tool_name": "Bash",
"tool_input": { "command": "sudo apt install nodejs" }
}For PostToolUse events, the payload also contains tool_result with the tool's output.
The handler enforces a 1 MB stdin limit. Payloads exceeding this are discarded and all policies implicitly allow.
Deny (PreToolUse):
{
"hookSpecificOutput": {
"permissionDecision": "deny",
"permissionDecisionReason": "Blocked by failproofai: sudo command blocked"
}
}Deny (PostToolUse):
{
"hookSpecificOutput": {
"additionalContext": "Blocked by failproofai because: API key detected in output"
}
}Instruct (any event except Stop):
{
"hookSpecificOutput": {
"additionalContext": "Instruction from failproofai: Verify tests pass before committing."
}
}Stop event instruct:
- Exit code:
2 - Reason written to stderr (not stdout)
Allow:
- Exit code:
0 - Empty stdout
Allow with message:
allow(message) lets a policy send informational context back to Claude even when the operation is permitted. The hook handler writes the following JSON to stdout (not a config file — this is the handler's response to Claude Code, just like deny and instruct responses above):
// Written to stdout by the hook handler process
{
"hookSpecificOutput": {
"additionalContext": "All CI checks passed on branch 'feat/my-feature'."
}
}- Exit code:
0(operation is allowed) - When multiple policies return
allowwith a message, their messages are joined with newlines into a singleadditionalContextstring - If no policy provides a message, stdout is empty (same as before)
src/hooks/handler.ts implements the full pipeline:
stdin JSON
→ parse payload (max 1 MB)
→ extract session metadata (session_id, cwd, tool_name, tool_input, etc.)
→ readMergedHooksConfig(cwd) ← merges project + local + global config
→ register enabled builtin policies with resolved params
→ load custom policies from customPoliciesPath (if set)
→ register custom policies into policy registry
→ evaluate all policies (builtins first, then custom)
→ first deny short-circuits
→ instruct decisions accumulate
→ allow messages accumulate
→ write JSON decision to stdout
→ persist event to ~/.failproofai/hook-activity.jsonl
→ exit
The entire process runs in under 100ms for typical payloads with no LLM calls.
src/hooks/hooks-config.ts implements three-scope config loading.
[1] {cwd}/.failproofai/policies-config.json ← project (highest priority)
[2] {cwd}/.failproofai/policies-config.local.json ← local
[3] ~/.failproofai/policies-config.json ← global (lowest priority)
Merge logic:
enabledPolicies- deduplicated union across all three filespolicyParams- per-policy key, first file that defines it wins entirelycustomPoliciesPath- first file that defines it winsllm- first file that defines it wins
The web dashboard uses readHooksConfig() (global only) for reading and writing, since it is not invoked with a project cwd.
src/hooks/policy-evaluator.ts runs policies in order.
For each policy:
- Look up the policy's
paramsschema (if it has one). - Read
policyParams[policy.name]from the merged config. - Merge user-provided values over schema defaults to produce
ctx.params. - Call
policy.fn(ctx)with the resolved context. - If the result is
deny, stop immediately and return that decision. - If the result is
instruct, accumulate the message and continue. - If the result is
allow, continue to the next policy.
After all policies run:
- If any
denywas returned, emit the deny response. - If any
instructreturns were collected, emit a single instruct response with all messages joined. - Otherwise, emit an allow response (empty stdout, exit 0).
src/hooks/builtin-policies.ts defines all 26 built-in policies as BuiltinPolicyDefinition objects:
interface BuiltinPolicyDefinition {
name: string;
description: string;
fn: (ctx: PolicyContext) => PolicyResult;
match: {
events: HookEventType[];
tools?: string[];
};
defaultEnabled: boolean;
category: string;
beta?: boolean;
params?: PolicyParamsSchema;
}Policies that accept params declare a PolicyParamsSchema with types and defaults for each parameter. The policy evaluator injects resolved values into ctx.params before calling fn. Policy functions read ctx.params without null-guarding because defaults are always applied first.
Pattern matching inside policies uses parsed command tokens (argv), not raw string matching. This prevents bypass via shell operator injection (e.g. a pattern for sudo systemctl status * cannot be bypassed by appending ; rm -rf / to the command).
src/hooks/custom-hooks-registry.ts implements a globalThis-backed registry:
const REGISTRY_KEY = "__failproofai_custom_hooks__";
export const customPolicies = {
add(hook: CustomHook): void { ... }
};
export function getCustomHooks(): CustomHook[] { ... }
export function clearCustomHooks(): void { ... } // used in testssrc/hooks/custom-hooks-loader.ts loads the user's policy file:
- Read
customPoliciesPathfrom config; skip if absent. - Resolve to absolute path; check file exists.
- Rewrite all
from "failproofai"imports to the actual dist path socustomPoliciesresolves to the sameglobalThisregistry. - Recursively rewrite transitive local imports to ensure ESM compatibility.
- Write temporary
.mjsfiles andimport()the entry file. - Call
getCustomHooks()to retrieve registered hooks. - Clean up all temp files in a
finallyblock.
On any error (file not found, syntax error, import failure), the error is logged to ~/.failproofai/hook.log and the loader returns an empty array. Built-in policies are unaffected.
Custom policies are evaluated after all built-in policies. A custom policy deny still short-circuits further custom policies (but all built-ins have already run by that point).
After each hook event, the handler appends a JSONL line to ~/.failproofai/hook-activity.jsonl:
{
"timestamp": "2026-04-06T12:34:56.789Z",
"sessionId": "abc123",
"eventType": "PreToolUse",
"toolName": "Bash",
"policyName": "block-sudo",
"decision": "deny",
"reason": "sudo command blocked by failproofai",
"durationMs": 12
}One line per policy that made a non-allow decision. Allow decisions are not logged (to keep the file small).
The dashboard is a Next.js 16 application using the App Router with React Server Components and Server Actions.
app/
layout.tsx ← Root layout (theme, telemetry, nav)
projects/page.tsx ← Server component: list all Claude projects
project/[name]/page.tsx ← Server component: list sessions in a project
project/[name]/session/
[sessionId]/page.tsx ← Server component: render session viewer
policies/page.tsx ← Client component: policy management + activity log
actions/
get-hooks-config.ts ← Read config + policy list
update-hooks-config.ts ← Toggle policy on/off
update-policy-params.ts ← Update policy parameters
get-hook-activity.ts ← Paginate/search activity log
install-hooks-web.ts ← Install/remove hooks from the browser
api/
download/[project]/[session]/route.ts ← Export session as ZIP/JSONL
Data flow:
- Page components call
lib/projects.tsandlib/log-entries.tsto read project/session data directly from the filesystem (no API layer for reads). - The Policies page uses Server Actions for all mutations (toggle, params update, install/remove).
- The session viewer parses Claude's JSONL transcript format and renders a timeline of messages and tool calls.
Key design decisions:
- No database - all persistent state is in plain files (
~/.failproofai/,~/.claude/projects/). - Server Actions for mutations - no REST API needed for CRUD operations.
- React Server Components for read pages - faster initial load, no client bundle for data fetching.
- Client components only where interactivity is needed (policy toggles, activity search, log viewer).
failproofai/
├── bin/
│ └── failproofai.mjs # CLI router (hook / dashboard / install / etc.)
├── src/hooks/
│ ├── handler.ts # Hook event pipeline
│ ├── builtin-policies.ts # 26 policy definitions
│ ├── policy-evaluator.ts # Policy execution engine
│ ├── policy-registry.ts # Policy registration and lookup
│ ├── policy-types.ts # TypeScript interfaces
│ ├── hooks-config.ts # Multi-scope config loading
│ ├── custom-hooks-registry.ts # globalThis-backed hook registry
│ ├── custom-hooks-loader.ts # ESM loader for user JS hooks
│ ├── manager.ts # install / remove / list operations
│ ├── install-prompt.ts # Interactive policy selection prompt
│ ├── hook-logger.ts # Logging to hook.log
│ ├── hook-activity-store.ts # Persist activity to hook-activity.jsonl
│ └── llm-client.ts # LLM API client (for AI-powered policies)
├── app/ # Next.js dashboard (pages + server actions)
├── lib/ # Shared utilities
│ ├── projects.ts # Enumerate Claude projects from filesystem
│ ├── log-entries.ts # Parse Claude transcript JSONL format
│ ├── paths.ts # Resolve system paths
│ └── ...
├── components/ # Shared React UI components
├── contexts/ # React context providers (theme, auto-refresh, telemetry)
├── examples/ # Example custom hook files
└── __tests__/ # Unit and E2E tests