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
Two ways to load custom policies
Option 1: Convention-based (recommended)
Drop *policies.{js,mjs,ts} files into .failproofai/policies/ and they’re automatically loaded — no flags or config changes needed. This works like git hooks: drop a file, it just works.
# Project level — committed to git, shared with the team
.failproofai/policies/security-policies.mjs
.failproofai/policies/workflow-policies.mjs
# User level — personal, applies to all projects
~/.failproofai/policies/my-policies.mjs
How it works:
- Both project and user directories are scanned (union — not first-scope-wins)
- Files are loaded alphabetically within each directory. Prefix with
01-, 02- to control order
- Only files matching
*policies.{js,mjs,ts} are loaded; other files are ignored
- Each file is loaded independently (fail-open per file)
- Works alongside explicit
--custom and built-in policies
Convention policies are the easiest way to share policies across a team. Commit .failproofai/policies/ to git and every team member gets them automatically.
Option 2: Explicit file path
# 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.
Using both together
Convention policies and the explicit --custom file can coexist. Load order:
- Explicit
customPoliciesPath file (if configured)
- Project convention files (
{cwd}/.failproofai/policies/, alphabetical)
- User convention files (
~/.failproofai/policies/, alphabetical)
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. All instruct messages are accumulated and delivered together.
You can append extra guidance to any deny or instruct message by adding a hint field in policyParams — no code change needed. This works for custom (custom/), project convention (.failproofai-project/), and user convention (.failproofai-user/) policies too. See Configuration → hint for details.
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)
- Explicit custom policies from
customPoliciesPath (in .add() order)
- Convention policies from project
.failproofai/policies/ (files alphabetical, .add() order within)
- Convention policies from user
~/.failproofai/policies/ (files alphabetical, .add() order within)
The first deny short-circuits all subsequent policies. All instruct messages are accumulated and delivered together.
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 explicit custom policies run; convention policies and built-ins continue normally |
| File not found | Warning logged to ~/.failproofai/hook.log; built-ins continue |
| Syntax/import error (explicit) | Error logged to ~/.failproofai/hook.log; explicit custom policies skipped |
| Syntax/import error (convention) | Error logged; that file skipped, other convention files still load |
fn throws at runtime | Error logged; that hook treated as allow; other hooks continue |
fn takes longer than 10s | Timeout logged; treated as allow |
| Convention directory missing | No convention policies run; no error |
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 |
examples/convention-policies/security-policies.mjs | Convention-based security policies (block .env writes, prevent git history rewriting) |
examples/convention-policies/workflow-policies.mjs | Convention-based workflow policies (test reminders, audit file writes) |
Using explicit file examples
failproofai policies --install --custom ./examples/policies-basic.js
Using convention-based examples
# Copy to project level
mkdir -p .failproofai/policies
cp examples/convention-policies/*.mjs .failproofai/policies/
# Or copy to user level
mkdir -p ~/.failproofai/policies
cp examples/convention-policies/*.mjs ~/.failproofai/policies/
No install command needed — the files are picked up automatically on the next hook event.