Skip to main content
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

FunctionEffectUse when
allow()Permit the operation silentlyThe action is safe, no message needed
deny(message)Block the operationThe agent should not take this action
instruct(message)Add context without blockingGive 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.

Informational allow messages (beta)

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.
FunctionEffectUse when
allow(message)Permit and send context to ClaudeConfirm 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

FieldTypeDescription
eventTypestring"PreToolUse", "PostToolUse", "Notification", "Stop"
toolNamestring | undefinedThe tool being called (e.g. "Bash", "Write", "Read")
toolInputRecord<string, unknown> | undefinedThe tool’s input parameters
payloadRecord<string, unknown>Full raw event payload from Claude Code
sessionSessionMetadata | undefinedSession context (see below)

SessionMetadata fields

FieldTypeDescription
sessionIdstringClaude Code session identifier
cwdstringWorking directory of the Claude Code session
transcriptPathstringPath to the session’s JSONL transcript file

Event types

EventWhen it firestoolInput contents
PreToolUseBefore Claude runs a toolThe tool’s input (e.g. { command: "..." } for Bash)
PostToolUseAfter a tool completesThe tool’s input + tool_result (the output)
NotificationWhen Claude sends a notification{ message: "...", notification_type: "idle" | "permission_prompt" | ... } - hooks must always return allow(), they cannot block notifications
StopWhen the Claude session endsEmpty

Evaluation order

Policies are evaluated in this order:
  1. Built-in policies (in definition order)
  2. 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.
FailureBehavior
customPoliciesPath not setNo custom policies run; built-ins continue normally
File not foundWarning logged to ~/.failproofai/hook.log; built-ins continue
Syntax/import errorError logged to ~/.failproofai/hook.log; all custom policies skipped
fn throws at runtimeError logged; that hook treated as allow; other hooks continue
fn takes longer than 10sTimeout 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:
FileContents
examples/policies-basic.jsFive starter policies covering common agent failure modes
examples/policies-advanced/index.jsAdvanced patterns: transitive imports, async calls, output scrubbing, and session-end hooks
Install the basic examples:
failproofai policies --install --custom ./examples/policies-basic.js