Custom policies let you write rules for any agent behavior: enforce project conventions, prevent drift, gate destructive operations, detect stuck agents, or integrate with Slack, approval workflows, and more. They use the same hook event system and allow, deny, instruct decisions as built-in policies.
Quick example
// my-policies.js
import { customPolicies, allow, deny, instruct } from "failproofai";
customPolicies.add({
name: "no-production-writes",
description: "Block writes to paths containing 'production'",
match: { events: ["PreToolUse"] },
fn: async (ctx) => {
if (ctx.toolName !== "Write" && ctx.toolName !== "Edit") return allow();
const path = ctx.toolInput?.file_path ?? "";
if (path.includes("production")) {
return deny("Writes to production paths are blocked");
}
return allow();
},
});
Install it:
failproofai policies --install --custom ./my-policies.js
Installing and updating
# Install with a custom policies file
failproofai policies --install --custom ./my-policies.js
# Replace the policies file path
failproofai policies --install --custom ./new-policies.js
# Remove the custom policies path from config
failproofai policies --uninstall --custom
The resolved absolute path is stored in policies-config.json as customPoliciesPath. The file is loaded fresh on every hook event - there is no caching between events.
API
Import
import { customPolicies, allow, deny, instruct } from "failproofai";
customPolicies.add(hook)
Registers a policy. Call this as many times as needed for multiple policies in the same file.
customPolicies.add({
name: string; // required - unique identifier
description?: string; // shown in `failproofai policies` output
match?: { events?: HookEventType[] }; // filter by event type; omit to match all
fn: (ctx: PolicyContext) => PolicyResult | Promise<PolicyResult>;
});
Decision helpers
| Function | Effect | Use when |
|---|
allow() | Permit the operation silently | The action is safe, no message needed |
deny(message) | Block the operation | The agent should not take this action |
instruct(message) | Add context without blocking | Give the agent extra context to stay on track |
deny(message) - the message appears to Claude prefixed with "Blocked by failproofai:". A single deny short-circuits all further evaluation.
instruct(message) - the message is appended to Claude’s context for the current tool call. The first instruct wins — subsequent instruct returns from other policies are ignored.
allow(message) is a beta feature available since v0.0.2-beta.3. The API may change in future releases. Earlier versions only support allow() without arguments.
allow(message) permits the operation and sends an informational message back to Claude. The message is delivered as additionalContext in the hook handler’s stdout response — the same mechanism used by instruct, but semantically different: it’s a status update, not a warning.
| Function | Effect | Use when |
|---|
allow(message) | Permit and send context to Claude | Confirm a check passed, or explain why a check was skipped |
Use cases:
- Status confirmations:
allow("All CI checks passed.") — tells Claude everything is green
- Fail-open explanations:
allow("GitHub CLI not installed, skipping CI check.") — tells Claude why a check was skipped so it has full context
- Multiple messages accumulate: if several policies each return
allow(message), all messages are joined with newlines and delivered together
customPolicies.add({
name: "confirm-branch-status",
match: { events: ["Stop"] },
fn: async (ctx) => {
const cwd = ctx.session?.cwd;
if (!cwd) return allow("No working directory, skipping branch check.");
// ... check branch status ...
if (allPushed) {
return allow("Branch is up to date with remote.");
}
return deny("Unpushed changes detected.");
},
});
PolicyContext fields
| Field | Type | Description |
|---|
eventType | string | "PreToolUse", "PostToolUse", "Notification", "Stop" |
toolName | string | undefined | The tool being called (e.g. "Bash", "Write", "Read") |
toolInput | Record<string, unknown> | undefined | The tool’s input parameters |
payload | Record<string, unknown> | Full raw event payload from Claude Code |
session | SessionMetadata | undefined | Session context (see below) |
| Field | Type | Description |
|---|
sessionId | string | Claude Code session identifier |
cwd | string | Working directory of the Claude Code session |
transcriptPath | string | Path to the session’s JSONL transcript file |
Event types
| Event | When it fires | toolInput contents |
|---|
PreToolUse | Before Claude runs a tool | The tool’s input (e.g. { command: "..." } for Bash) |
PostToolUse | After a tool completes | The tool’s input + tool_result (the output) |
Notification | When Claude sends a notification | { message: "...", notification_type: "idle" | "permission_prompt" | ... } - hooks must always return allow(), they cannot block notifications |
Stop | When the Claude session ends | Empty |
Evaluation order
Policies are evaluated in this order:
- Built-in policies (in definition order)
- Custom policies (in
.add() order)
The first deny short-circuits all subsequent policies. The first instruct wins — subsequent instruct returns are ignored.
Transitive imports
Custom policy files can import local modules using relative paths:
// my-policies.js
import { isBlockedPath } from "./utils.js";
import { checkApproval } from "./approval-client.js";
customPolicies.add({
name: "approval-gate",
fn: async (ctx) => {
if (ctx.toolName !== "Bash") return allow();
const approved = await checkApproval(ctx.toolInput?.command, ctx.session?.sessionId);
return approved ? allow() : deny("Approval required for this command");
},
});
All relative imports reachable from the entry file are resolved. This is implemented by rewriting from "failproofai" imports to the actual dist path and creating temporary .mjs files to ensure ESM compatibility.
Event type filtering
Use match.events to limit when a policy fires:
customPolicies.add({
name: "require-summary-on-stop",
match: { events: ["Stop"] },
fn: async (ctx) => {
// Only fires when the session ends
// ctx.session.transcriptPath contains the full session log
return allow();
},
});
Omit match entirely to fire on every event type.
Error handling and failure modes
Custom policies are fail-open: errors never block built-in policies or crash the hook handler.
| Failure | Behavior |
|---|
customPoliciesPath not set | No custom policies run; built-ins continue normally |
| File not found | Warning logged to ~/.failproofai/hook.log; built-ins continue |
| Syntax/import error | Error logged to ~/.failproofai/hook.log; all custom policies skipped |
fn throws at runtime | Error logged; that hook treated as allow; other hooks continue |
fn takes longer than 10s | Timeout logged; treated as allow |
To debug custom policy errors, watch the log file:tail -f ~/.failproofai/hook.log
Full example: multiple policies
// my-policies.js
import { customPolicies, allow, deny, instruct } from "failproofai";
// Prevent agent from writing to secrets/ directory
customPolicies.add({
name: "block-secrets-dir",
description: "Prevent agent from writing to secrets/ directory",
match: { events: ["PreToolUse"] },
fn: async (ctx) => {
if (!["Write", "Edit"].includes(ctx.toolName ?? "")) return allow();
const path = ctx.toolInput?.file_path ?? "";
if (path.includes("secrets/")) return deny("Writing to secrets/ is not permitted");
return allow();
},
});
// Keep the agent on track: verify tests before committing
customPolicies.add({
name: "remind-test-before-commit",
description: "Keep the agent on track: verify tests pass before committing",
match: { events: ["PreToolUse"] },
fn: async (ctx) => {
if (ctx.toolName !== "Bash") return allow();
const cmd = ctx.toolInput?.command ?? "";
if (/git\s+commit/.test(cmd)) {
return instruct("Verify all tests pass before committing. Run `bun test` if you haven't already.");
}
return allow();
},
});
// Prevent unplanned dependency changes during freeze
customPolicies.add({
name: "dependency-freeze",
description: "Prevent unplanned dependency changes during freeze period",
match: { events: ["PreToolUse"] },
fn: async (ctx) => {
if (ctx.toolName !== "Bash") return allow();
const cmd = ctx.toolInput?.command ?? "";
const isInstall = /^(npm install|yarn add|bun add|pnpm add)\s+\S/.test(cmd);
if (isInstall && process.env.DEPENDENCY_FREEZE === "1") {
return deny("Package installs are frozen. Unset DEPENDENCY_FREEZE to allow.");
}
return allow();
},
});
export { customPolicies };
Examples
The examples/ directory contains ready-to-run policy files:
| File | Contents |
|---|
examples/policies-basic.js | Five starter policies covering common agent failure modes |
examples/policies-advanced/index.js | Advanced patterns: transitive imports, async calls, output scrubbing, and session-end hooks |
Install the basic examples:
failproofai policies --install --custom ./examples/policies-basic.js