Skip to content

Repository files navigation

Ctrl-Loop

A programmable policy engine for Claude Code. Ctrl-Loop intercepts agent lifecycle events, evaluates them against TypeScript rules, and returns decisions that control what the agent is allowed to do.

Claude Code → hook event (stdin) → Ctrl-Loop adapter → engine evaluates rules → decision (stdout) → Claude Code

Rules are plain TypeScript functions — they can call APIs, read files, inspect session history, or query fact providers. There is no DSL or config schema to learn.


Installation

bun install

Requires Bun 1.2+.


How it works

Claude Code fires hook events at key points in the agent lifecycle. Ctrl-Loop runs as the hook handler: it reads the event from stdin, evaluates your rules, and writes a hookSpecificOutput JSON decision to stdout.

Supported events

Event When
SessionStart New session begins
UserPromptSubmit User submits a prompt
PreToolUse Before a tool call executes
PostToolUse After a tool call succeeds
PostToolUseFailure After a tool call fails
Stop Agent signals completion
SubagentStart / SubagentStop Subagent lifecycle
TaskCreated / TaskCompleted Task lifecycle

Decisions a rule can return

Decision Effect
allow Action proceeds
deny Action blocked outright
ask Pause and ask the user to approve
defer Defer to external approval
block Halt the agent (Stop events only)
modifyInput Rewrite tool parameters before execution
context Inject guidance text without blocking
noop / undefined No opinion; pass through

When multiple rules disagree, the engine resolves by precedence:

deny > ask > defer > block > modifyInput > context > allow > noop

Writing rules

Fluent builder (recommended)

The rule() builder is the preferred way to write rules. It handles event scoping, tool filtering, mode gating, path matching, and async predicates declaratively, then calls your handler only when all conditions match.

import { rule } from "./src/index.js";

const denyDangerousCommands = rule("deny-dangerous-commands")
  .on("PreToolUse")
  .tool("Bash")
  .when(({ event }) => /rm\s+-rf/.test(String(event.tool?.input.command ?? "")))
  .then(({ deny }) => deny("Destructive rm command is blocked."));

Builder methods

Method Effect
.on(...events) Scope to specific event kinds
.tool(...names) Match only named tools
.mode(...modes) Match only when session is in these SDLC modes
.paths(...globs) Match file_path / path tool input against glob patterns
.excludePaths(...globs) Skip when path matches any of these globs
.when(predicate) Arbitrary sync/async boolean gate
.timeout(ms) Abort handler after ms milliseconds
.onTimeout(policy) "noop" | "deny" | "allow" on timeout
.then(handler) Compile to RuleDefinition (required, terminates the chain)

The fluent handler receives a context object with all decision helpers pre-bound:

rule("example")
  .on("PreToolUse")
  .then(({ event, session, facts, deny, ask, allow, context, modifyInput, noop }) => {
    // return deny("reason") | ask("reason") | allow() | context("msg") | modifyInput({...}) | noop() | undefined
  });

Low-level RuleDefinition

You can also construct a RuleDefinition directly when you need full control:

import type { RuleDefinition } from "./src/index.js";

const denyDangerousCommands: RuleDefinition = {
  id: "deny-dangerous-commands",
  events: ["PreToolUse"],
  handler: (event, session, facts) => {
    const command = event.tool?.input.command;
    if (typeof command === "string" && /rm\s+-rf/.test(command)) {
      return { kind: "deny", reason: "rm -rf is not allowed" };
    }
    return undefined;
  },
};

Handler signature

type RuleHandler = (
  event: NormalizedEvent,   // normalized event with typed fields
  session: SessionState,    // mutable state persisted across this session
  facts: FactAccessor,      // named fact providers (async, cached)
) => Promise<Decision | undefined> | Decision | undefined;

Event fields

event.kind                   // "PreToolUse" | "PostToolUse" | "Stop" | ...
event.sessionId              // session identifier
event.tool?.name             // "Bash" | "Write" | "Edit" | ...
event.tool?.input            // typed tool parameters
event.tool?.response         // tool output (PostToolUse only)
event.tool?.phase            // "pre" | "post" | "postFailure"
event.lastAssistantMessage   // last message the agent sent (Stop events)
event.isSubagent             // true when event originates from a subagent
event.raw                    // original unmodified Claude hook payload

Session state

Session state is mutable and persists for the lifetime of one session.

session.codeEdited           // true if any source file was written this session
session.editedFiles          // string[] of modified file paths
session.testsRan             // true if a test command ran
session.testsPassed          // true if the last test run passed
session.activeMode           // current SDLC mode (see below)
session.modeLocked           // true if mode switching is locked
session.subagentHistory      // SubagentEntry[]
session.taskHistory          // TaskEntry[]
session.extra                // Record<string, unknown> for rule-managed state

Context injection

Return context to inject guidance without blocking:

return {
  kind: "context",
  contextLines: [
    "You are in implementation mode.",
    "Write a failing test before editing the source.",
  ],
};

Input modification

Return modifyInput to rewrite tool parameters before execution:

return {
  kind: "modifyInput",
  updatedInput: { ...event.tool!.input, timeout: 30_000 },
};

Async rules and fact providers

Rules can be async and can query named fact providers:

import type { FactProviderRegistration, RuleDefinition } from "./src/index.js";

const gitBranchFact: FactProviderRegistration = {
  name: "currentBranch",
  scope: "session",            // "event" re-evaluates each event; "session" caches
  provider: () => execSync("git rev-parse --abbrev-ref HEAD", { encoding: "utf8" }).trim(),
};

const blockForcePushOnMain: RuleDefinition = {
  id: "block-force-push-on-main",
  events: ["PreToolUse"],
  handler: async (event, _session, facts) => {
    const command = event.tool?.input.command;
    if (typeof command !== "string" || !/git push.*--force/.test(command)) return undefined;

    const branch = await facts.get("currentBranch");
    if (branch === "main") {
      return { kind: "deny", reason: "Force-pushing to main is not allowed." };
    }
    return undefined;
  },
};

Running the adapter

The adapter reads one Claude Code hook event from stdin and writes hookSpecificOutput JSON to stdout.

# pass a real hook payload
echo '{"hook_event_name":"PreToolUse","session_id":"abc","tool_name":"Bash","tool_input":{"command":"rm -rf /"}}' \
  | bun .ctrl-loop/index.ts

# exit codes
# 0 — allow / deny / ask / defer / noop (all structured decisions)
# 2 — policy block (Stop event blocked by a block decision)
# 1 — runtime error (invalid JSON, engine crash)

Use runAdapter to wire your rules into the adapter:

import { runAdapter } from "./src/index.js";
import { myRule, anotherRule } from "./rules.js";

await runAdapter([myRule, anotherRule], {
  stateDir: ".ctrl-loop/sessions",   // where session state is persisted
});

Claude Code integration

Add hooks to .claude/settings.json for each event type you want to intercept:

{
  "hooks": {
    "PreToolUse": [
      {
        "matcher": "*",
        "hooks": [
          { "type": "command", "command": "CTRL_LOOP_LOG=log bun ./.ctrl-loop/index.ts" }
        ]
      }
    ],
    "Stop": [
      {
        "matcher": "*",
        "hooks": [
          { "type": "command", "command": "CTRL_LOOP_LOG=log bun ./.ctrl-loop/index.ts" }
        ]
      }
    ]
  }
}

SDLC modes

Ctrl-Loop supports seven SDLC modes that scope what the agent is allowed to do. Only one mode is active at a time. The agent switches modes via /ctrl-loop mode <name>.

Mode What it allows What it blocks
planning Reads, git inspection All writes and mutations
design BDR/ADR/spec writes Source file edits
implementation Source edits (+ TDD guidance)
testing Test runs, source edits Test file edits without approval
verification Diagnostics, git inspection All writes
review Reads, diagnostics All writes, git mutations
release Build, test Publish without passing tests; dirty worktree
/ctrl-loop mode planning       → switch to planning mode
/ctrl-loop mode implementation → switch to implementation mode
/ctrl-loop mode lock           → prevent further mode switches
/ctrl-loop mode unlock         → re-enable mode switching

Active mode is stored in session.activeMode and available to all rule handlers.


Logging

Control logging via the CTRL_LOOP_LOG environment variable:

Value Output File
off Silent
info One line per event .ctrl-loop/debug.log
debug Event + session state + raw payload .ctrl-loop/debug.log
log Structured NDJSON record per event .ctrl-loop/events.ndjson
CTRL_LOOP_LOG=log bun .ctrl-loop/index.ts

# query the structured log
jq 'select(.tool == "Bash")' .ctrl-loop/events.ndjson
jq 'select(.session.codeEdited)' .ctrl-loop/events.ndjson

Testing

Rules are pure functions. Test them by passing normalized events and a session object directly to Engine.process:

import { describe, expect, test } from "bun:test";
import { Engine, createEmptySession } from "./src/index.js";
import { denyDangerousCommands } from "./rules.js";

const engine = new Engine({ rules: [denyDangerousCommands] });

describe("denyDangerousCommands", () => {
  test("denies rm -rf", async () => {
    const session = createEmptySession("test-session");
    const event = {
      kind: "PreToolUse",
      sessionId: "test-session",
      tool: { name: "Bash", input: { command: "rm -rf /tmp" }, response: undefined, phase: "pre" },
      lastAssistantMessage: undefined,
      isSubagent: false,
      agentId: undefined,
      agentType: undefined,
      agentTranscriptPath: undefined,
      raw: {},
    };

    const decision = await engine.process(event, session);
    expect(decision.kind).toBe("deny");
  });
});

Use real captured payloads as fixtures for integration tests:

import fixture from "./test/fixtures/pre-tool-use.json";

const event = normalizeEvent(fixture);
const decision = await engine.process(event, createEmptySession(event.sessionId));
bun test
bun test --coverage

Project layout

src/
  index.ts          public API
  engine.ts         rule evaluation and precedence resolution
  adapter.ts        stdin → engine → stdout wiring
  normalizer.ts     Claude hook JSON → NormalizedEvent
  rule.ts           fluent rule builder and matchesGlob
  session.ts        SessionState factories and file/memory stores
  precedence.ts     conflict resolution
  types.ts          all exported types

.ctrl-loop/
  index.ts          adapter entry point (your rules live here)
  rules/            rule modules by feature area
  sessions/         persisted session state (auto-created)
  events.ndjson     structured event log (CTRL_LOOP_LOG=log)
  debug.log         text event log (CTRL_LOOP_LOG=info|debug)

test/
  fixtures/         captured Claude hook payloads
  integration/      fixture-based engine tests

docs/
  SPEC.md           full behavioral specification
  adr/              architecture decision records
  bdr/              behavior decision records (one per feature)
  specs/            layered stack specs

Development

bun test              # run all tests
bun run typecheck     # TypeScript type check
bun run lint          # Biome lint
bun run format        # Biome format (auto-fix)

All changes must pass typecheck and lint before commit. Commit messages follow Conventional Commits. Tests use the red-green-refactor TDD cycle — write a failing test first.


Reference

  • Specification — full behavioral spec (46 BDRs)
  • BDRs — one behavior decision record per feature
  • ADRs — architecture and toolchain decisions
  • Examples — annotated rule examples covering all decision types

About

A policy engine for governing Claude Code AI agent behavior

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages