Skip to content

Automation Behavior

Reference for how commands and skills should adjust interaction ceremony based on the active automation level. The big-picture description of each level (and how to switch) is in project-orchestration-handbook.md §7.1; this doc is the per-decision behavior table. Warp-drive’s level-specific behavior is in the handbook §7.2 and warp-drive.md. For how the different loop archetypes (crons, retries, evaluator-gated loops, self-directing agents) map onto this trust scale, see the Loop Taxonomy & Trust Model.

Read .claude/settings.local.json in the project directory. Look for _automation.active_level (1, 2, 3, or 4). If not found or the file doesn’t exist, default to Level 1. An absent or unrecognised value never resolves to Level 4 — full-auto is only ever active when active_level is explicitly 4 and the full-auto profile is present.

  • Ask before every decision
  • Show all options with explanations
  • Wait for explicit confirmation before acting
  • This is identical to pre-automation behavior
  • Auto-proceed on safe, inferrable, or reversible decisions
  • Still confirm destructive operations (force push, delete, overwrite)
  • Still ask for subjective content (summaries, descriptions, priorities)
  • Show each auto-decision inline as it happens
  • Minimize prompts — only stop for destructive ops or subjective content
  • Auto-infer from git context, branch names, file paths, commit history
  • Batch auto-decisions into a summary rather than inline notifications
  • Ask for: rebase conflicts, subjective writing, destructive operations
  • A superset of Level 3: everything L3 auto-approves, L4 auto-approves too.
  • Decisions are resolved by reversibility, not escalated by default. A reversible (two-way-door) decision is auto-made — biased toward the most reversible option — and recorded as a decision issue. An irreversible (one-way-door) decision — spending money, a public contract, deleting data, external comms, prod deploy past the ceiling — is never auto-resolved: it notifies and waits, or stops.
  • The wait is bounded by decision_timeout_minutes (default 10), governed by the decision-timeout policy: on timeout with no reply a one-way door is deferred (skip it, keep working) or stops cleanly if it blocks everything — never a guess. 0 means never wait (“work around or stop”); with RDB off, “notify” degrades to a filed TODO with a defer default.
  • Inherits the L3 always-blocked floor and an irreversible-action floor, both carried by the full-auto profile (profiles/full-auto.json, delivered in #867).
  • Requires a cost ceiling to start (#879). A Level 4 run refuses to launch without a spend ceiling — --budget <usd> / --max-tokens <n> at kickoff, or _workflow.max_session_usd / max_session_tokens in config — so an unattended run is never uncapped. The existing cost breaker enforces it (no new breaker); a cost estimate is surfaced at kickoff. See Kickoff Flags.
  • Requires that profile to be present; until it is, /automation level 4 safely refuses and leaves the level unchanged.
  • The full-auto behavior (reversibility decision engine, in-session QA loop, session segmentation, decision budget) is built out under capability #865; this entry documents the level itself. The decision classification and promotion matrices below carry an L4 column so the full-auto behavior is documented where L1/L2/L3 are.

Commands must show what was auto-decided so the user stays informed.

Level 2 — inline per decision:

> Auto (L2): Branch type set to `tooling/` (inferred from .claude/ changes)
> Auto (L2): Stashed 2 uncommitted files

Level 3 — batched summary:

> Auto (L3) decisions:
> - Branch type: `tooling/` (inferred from .claude/ changes)
> - Stashed 2 uncommitted files
> - Branch name: `tooling/automation-behavior`
Category L1 L2 L3 L4 (full-auto)
Safe + inferrable Ask Auto, show Auto, show Auto, show
Reversible action Ask Auto, show Auto, show Auto-decide + record decision issue
Subjective content Ask Ask Ask Notify-and-wait (one-way; never auto-resolve)
Destructive operation Ask Ask Ask Blocked by the floor; escalate (one-way)
Push to remote Ask Confirm Auto (except main/master) Auto (prod past ceiling is a one-way door → blocked)
Conflict resolution Ask Ask Ask Auto if trivial; else escalate
Requirement/AC tracking Ask Auto-update, show Auto-update, show Auto-update, show
Documentation housekeeping Ask Auto-update, show Auto-update silently Auto-update silently
Warp-drive continue to next task Ask Ask Auto-continue, log Auto-continue (bounded), log
Warp-drive plan review Ask Ask Auto-proceed, log Auto-proceed, log

Level 4 formalizes the “Reversible action” row into an enforced engine. Where L2/L3 treat reversible actions as auto-and-show, L4 classifies every decision one-way (irreversible) vs two-way (reversible) via the reversibility decision engine (scripts/warp-drive/decision-engine.js, #871) and acts on the classification: two-way doors are auto-decided and recorded as decision issues (options considered + rationale); one-way doors notify-and-wait or stop and are never auto-resolved. An unrecognized decision defaults to one-way — the engine never guesses a one-way door. The irreversible categories are exactly the ones the full-auto floor denies at the tool level (money-spend, external comms, destructive data ops, prod deploy past the ceiling, history loss); the engine is the semantic layer above that floor. See the Reversibility Decision Engine reference.

Autonomous profile — auto-approved vs always-blocked (#420)

Section titled “Autonomous profile — auto-approved vs always-blocked (#420)”

Level 3 (the autonomous profile, profiles/autonomous.json) is the only level designed to run unattended — including overnight. Because nothing prompts, “what exactly is this allowed to do while I’m asleep?” needs a documented answer. This is it.

When to use it vs. interactive defaults. Use L3 only for a scoped, version-controlled repo where every change is recoverable from git and you have reviewed the work queue — e.g. an area:-scoped warp-drive batch of approved requirements. Stay at L1/L2 (interactive) for exploratory work, anything touching production credentials or external services by hand, or a repo whose state you are not ready to have advanced without review. L3 trades the per-action prompt for an after-the-fact audit trail (below) — that trade only pays off when the queue is trusted and the blast radius is git.

Auto-approved at L3 (no prompt): reads, search, edits/writes, Bash, the dev/test/commit/push/merge skills, Playwright, WebFetch/WebSearch, and push/merge to main/master (subject to the promotion ceiling — a pr ceiling still caps L3 at a pull request).

Always-blocked at L3 (denied even though everything else is auto-approved) — four layers, each entry carries a rationale in profiles/autonomous.json → _meta.deny_rationale:

Layer Blocks Why
Destructive-git floor git push --force, git push origin --force, git reset --hard, git branch -D, rm -rf Irrecoverable history/working-tree loss. This floor is a deliberate guardrail and is never loosened — see the autonomous-deny-destructive-git guardrail. It matches the start of the command only; the destructive-git guard reads the arguments and is the inner layer (#3621).
Secrets / credential reads .env / .env.*, *credential*, ~/.ssh, ~/.aws, ~/.config/gcloud, ~/.kube, ~/.npmrc, ~/.netrc, and since #3620 .dev.vars / .dev.vars.*, .mcp.json (a project’s and ~/.claude/’s — the one carve-out from the Read(~/.claude/**) allow), gh auth token, security find-generic-password / find-internet-password (via both Read and shell cat) An unattended run has no legitimate reason to read raw secret stores; blocking them shrinks the exfil surface. The secret-store guard enforces the #3620 set from the hooks too.
Network-exfil shapes curl -T / --upload-file, curl -d @file, pipe-into-curl/wget, wget --post-file The obvious “send a local file to a remote host” shapes.
The session’s own controls (#3618) Edit / Write of **/.claude/settings.json, **/.claude/settings.local.json, ~/.claude/settings.json, ~/.claude/hooks/**, ~/.claude/logs/autonomous-audit/** A run nobody approves must not rewrite its own permissions, hooks or audit log. The Bash routes to the same files are the self-modification guard’s.

Honest limitation. The secrets/exfil blocks are defense-in-depth, not a sandbox. Bash is broadly allowed, so a sufficiently creative shell command can still read a file or reach the network — the deny patterns only catch the obvious shapes. The real backstop for an unattended run is the audit log, not the deny-list.

What the deny layer covers, and what it does not (#3620).

Covered Not covered
A Read of a named store: .env*, *credential*, .dev.vars*, .mcp.json, the dotfiles under ~/.ssh, ~/.aws, ~/.config/gcloud, ~/.kube, ~/.npmrc, ~/.netrc. A copy of a store under another name, or a store this list does not name (a .pem in the repo, a .yaml with a token in it).
A shell command that names a store on its command line (cat, and for the #3620 set any command — jq, less, cp, base64 …). A store opened inside a script, a heredoc body, a -c string or a node -e program: the deny patterns and the guard read the command line only.
The readers that print a secret by design: gh auth token, security find-generic-password / find-internet-password. gh api with a token already in the environment, wrangler secret list (names only), printenv / env — the environment itself is readable, and a secret exported there is one echo away.
The obvious exfil shapes: curl -T / --upload-file / -d @file, pipe into curl / wget, wget --post-file. A curl -d "$(cat …)", a git push to a remote the session adds, a scp, a DNS lookup that carries data — outbound traffic is not allow-listed (the review’s suggestion; open as the network layer of this finding).

Every row on the left is pinned by tests/test-autonomous-profile.sh, tests/test-full-auto-profile.sh and tests/test-secret-store-guard.sh. Every row on the right is what the audit log is for: the call is recorded whether or not a pattern caught it, and audit-chain.sh verify shows whether the log was touched afterwards.

The floor above is five prefix patterns, so it never saw a force flag after the refspec (git push origin main --force), the +refspec form, a force push to a second remote, git -C <dir> or -c k=v before the subcommand, git clean -f, git stash drop or git stash clear. The destructive-git-guard.sh PreToolUse Bash hook reads git arguments with scripts/security/destructive-git-detect.js on the shared lexer (quoted prose, comments and heredoc bodies are not commands; global options are skipped) and refuses, at Level 2 and above:

Shape Blocked Passes
force push -f, --force, --force-with-lease[=…], --force-if-includes in any position, a short cluster holding f (-uf), a +refspec — to any remote git push, git push -u origin feature
clean git clean -f, -fd, -ffdx (no -n) git clean -n, -nd
stash git stash drop, git stash clear git stash list, push, pop, apply
branch git branch -D, -d with -f / --force, -fd git branch -d
reset git reset --hard git reset --soft, --mixed

--force-with-lease stays blocked: the floor’s git push --force* already denied it at the front, and the list is never loosened — the guard only closes the positions the prefix missed. The block says what would be lost and the safe form. At Level 1 a person approves each call and the guard does nothing; the level is the one the session started at where an audit log exists (#3618), else settings.local.json now. When the detector cannot run, an unattended run is blocked and an attended one let through, as the promotion and self-modification guards do. The floor stays as the outer layer — the profiles are unchanged by #3621 and tests/test-destructive-git-guard.sh pins every floor rule in both.

A profile is a JSON file, and switch-level.sh used to read its two permission lists with jq and hand them straight to the atomic settings write. null is valid JSON, so a profile with no deny list landed in settings.local.json as deny: null and the switch reported success. The script now validates the profile before anything is written or run — before the git-repo check and before any bob init — and refuses with exit 2 and nothing written when .permissions.allow or .permissions.deny is missing or not an array, or when a Level 2, 3 or 4 profile lacks any rule of the destructive-git floor (Bash(git push --force*), Bash(git push origin --force*), Bash(git reset --hard*), Bash(git branch -D *), Bash(rm -rf *)). The refusal names the missing list or each missing rule; an existing settings.local.json is left byte-identical and the level unchanged. Level 1 has no floor, so supervised’s empty deny list is accepted. tests/test-switch-level-validation.sh drives the real script against edited copies of the real profiles.

At Level 3 and 4 the secret-store-guard.sh PreToolUse hook (wired on Read and Bash) enforces the #3620 deny set from the hooks, because a profile is a settings file — a repo can commit one, a deploy can lag one. It blocks a Read of a file named .dev.vars, .dev.vars.<env> or .mcp.json, and a Bash command that runs gh auth token, security find-generic-password or security find-internet-password, or that names a .dev.vars* or .mcp.json file on its command line. The block names the route that still works: BoB’s own readers — bob notify, bob secrets check, scripts/security/cred-check.js — open the store inside the script and never print a value, so the command that runs them passes. The level is the one the session started at (as the self-modification guard); below Level 3 the guard does nothing, and like every guard it reads the command line only.

At Level 3 and 4 the profile allows plain Bash and Write(~/.claude/**), and no person approves a call. The files that govern the session would otherwise be its own to rewrite. The self-modification-guard.sh PreToolUse hook blocks a Bash, Edit or Write call that writes to:

  • a project’s .claude/settings.json or .claude/settings.local.json, and $BOB_HOME’s own settings files (the hook wiring lives there);
  • $BOB_HOME/hooks/**;
  • $BOB_HOME/logs/autonomous-audit/**;
  • a directory that holds them, when a command names it whole (rm -rf ~/.claude/logs).

What counts as a write. For Edit and Write, the file path. For Bash, scripts/security/self-write-detect.js reads the command with the shared lexer, so prose, comments and heredoc bodies are not commands. A protected path is written when it is the target of an output redirection, or an argument of any command that is not a known reader. The rule is deny by default: rm, mv, tee, sed -i, perl -i, truncate, chmod and a command the detector has never seen are writers; cat, jq, grep, ls and sed without -i are readers; cp writes only its destination. cd is followed, ~, $HOME, $BOB_HOME and variables the command assigns are expanded, and symlinks are followed.

What still works. Reads, always. The scripts that own these files run as before, because the command that runs them names no protected file: switch-level.sh (the level), the rdb toggle.sh, anything that writes through scripts/lib/settings-write.sh, and scripts/deploy.sh (the hooks). Any other change to these files is a person’s, from a terminal.

The level is fixed when the session starts. The guard and the audit log both take the level from the first line of the session’s audit log, not from settings.local.json at the time of the call. Lowering the level mid-session, or deleting the file, neither lifts the guard nor stops the log. A session that starts below Level 3 and is raised to it is guarded and logged from then on. A resumed session keeps the level it first ran at.

Below Level 3 the guard does nothing: a person approves the write there. When its detector cannot run, an unattended run is blocked and an attended one is let through — the rule the promotion guard follows (#3624).

Honest limitation. The guard reads the command line. It does not read code inside a script file, a heredoc body or a -c string, so a session that hides a write there is not stopped. The audit log records the call, and its chain shows whether the log was touched afterwards.

When a session is at automation Level 3 (autonomous) or 4 (full-auto), the autonomous-audit-log.sh PreToolUse hook records every tool call — tool, target, timestamp, session, cwd — as one JSONL line per call. A full-auto run is at least as autonomous as L3, so it is audited on the same trail (#882):

  • Which tools: all of them. The recorder is wired on the all-tools matcher (dispatch.sh PreToolUse '*', #3619), so a Bash, Edit, Write, NotebookEdit, WebFetch, WebSearch, browser or mcp__* call each leave one line. The target is the command for Bash, the file or notebook path for an edit, the URL for WebFetch, the query for WebSearch, and the tool_input key list for any other tool. Before #3619 only Bash and Edit/Write were logged.

  • Location: $BOB_HOME/logs/autonomous-audit/<session-id>.jsonl (override with $BOB_AUTONOMOUS_AUDIT_DIR).

  • Per session: one file per Claude Code session, so an overnight run is one reviewable trail.

  • Recorder, never a gate: it always exits 0 and never blocks — it only observes. It runs as its own hook process beside the per-tool guards, so an attempt the self-modification guard refuses is on the trail too.

  • Strict no-op below L3: at L1/L2 (and outside BoB projects) it reads one settings field and exits, so it cannot affect interactive or Trusted-Dev runs.

  • The level is the one the session started at (#3618). The first line of the log fixes it. A later change to settings.local.json does not stop the log; the line then carries both level (the session’s) and level_now.

To review an unattended run afterwards: jq -r '"\(.ts) \(.tool) \(.target)"' ~/.claude/logs/autonomous-audit/<session>.jsonl.

The log is a hash chain (#3618). Each line carries n, its position in the log, and prev, the sha256 of the line before it (genesis for the first). A head record beside the log, <session-id>.head, holds the number and hash of the last line, so a log cut short at the end shows as well. Check the chain with:

Terminal window
bash ~/.claude/scripts/security/audit-chain.sh verify # every log
bash ~/.claude/scripts/security/audit-chain.sh verify <session-id> # one session
bash ~/.claude/scripts/security/audit-chain.sh verify --json # audit-chain-verify/1
Exit Meaning
0 Every log is intact. A log written before the chain existed is listed as unchained and does not fail the run.
1 A line was deleted or edited, a head record is missing or wrong, or a named session has no log. The output names the session and the line.
2 Usage.

The chain is tamper evidence, not tamper proofing: whoever can rewrite a whole log and its head record can rebuild the chain. It makes a quiet edit or a deleted line visible.

So — is it safe to leave running overnight? For a scoped, git-backed repo with a reviewed queue: yes — irrecoverable git ops, secret reads, and obvious exfil are blocked, and everything auto-approved is captured in the audit log for morning review. It is not a substitute for a sandbox on an untrusted workload, and the promotion ceiling still governs how far work is actually promoted.

The audit log above is the tool layer — every auto-approved tool call (“what did it touch?”). Sitting above it is the decision layer: at Level 4 the reversibility decision engine records each auto-made two-way-door decision as a decision issue and an entry in the in-state decision log (state.decisions[]). At session_ending warp-drive aggregates that log into a decisions digest and includes it in the session-summary issue, so the whole autonomous run is reviewable — and cheaply reversible — from one place:

  • Each decision is listed with its reversibility class and a link to its decision issue (options considered + rationale) for one-place review/reversal — revert any that look wrong (revert the commit / re-open the choice).
  • The digest cross-links the tool-level audit log (this section) but is distinct from it: the audit log answers “what was touched?”, the digest answers “what was decided, and can I undo it?”.
  • Generated by state-machine.js decisions-digest (pure renderer: scripts/warp-drive/decisions-digest.js); on a run with no auto-decisions it still emits an explicit “nothing to review” block so the absence is recorded, not silent.

Full-auto profile — the irreversible-action floor (#867)

Section titled “Full-auto profile — the irreversible-action floor (#867)”

Level 4 (the full-auto profile, profiles/full-auto.json) is a superset of Level 3: it inherits the autonomous allow-list and the entire always-blocked floor above verbatim (the destructive-git floor is never loosened), then adds a fourth deny layer for irreversible, one-way-door actions — the things a full-auto run must never do unattended:

Layer Blocks (representative shapes) Why
Destructive data ops DROP DATABASE, DROP TABLE, TRUNCATE TABLE, dropdb Data loss is irreversible — a one-way door.
Prod deploy past ceiling wrangler deploy --env prod / --env production A production release is a one-way door; the promotion ceiling owns this, the deny is defense-in-depth.
External communications sendmail, mail, mailx An unattended run must not contact the outside world on its own.
Money-spend / paid resources stripe, gcloud billing, aws … purchase… Spending real money is irreversible.

Every deny entry carries a _meta.deny_rationale, and the layer is enforced by the same native permissions.deny mechanism as the L3 floor plus the existing promotion-ceiling-guard hook — there is no parallel enforcement mechanism.

Honest limitation (same as L3). These deny patterns are defense-in-depth, not a sandbox — they catch the obvious command shapes, not every semantic variant of “spend money” or “message the outside world.” The real backstops for full-auto are the reversibility decision engine (one-way-door decisions notify or stop; #871), the promotion ceiling, and the per-session audit log. The floor is the guardrail of last resort, not the primary control.

The automation level governs how much autonomy the agent has. It used to also decide how far work is promoted — the two were fused: Level 2 ≡ “open a PR”, Level 3 ≡ “merge to master”. That made “run fully autonomously, but never push past a PR” impossible to express.

A project’s promotion ceiling (dev.json → promotion.ceiling, default external) decouples those axes. The merge/deploy decision becomes f(automation_level, ceiling), computed by the shared library scripts/promotion/promotion.js and enforced everywhere — warp-drive’s merging phase, /finish-work, and the promotion-ceiling-guard PreToolUse hook. View or set it with /promotion.

Git action (merge) — f(level, ceiling):

Ceiling ↓ \ Level → L1 L2 L3 L4 (full-auto)
pr PR PR PR (capped — never auto-merges to main) PR (capped — same as L3)
external (default) PR PR direct merge to main direct merge to main
test PR PR direct merge to main direct merge to main
prod PR PR direct merge to main direct merge to main

Deploy action — f(level, ceiling):

Ceiling ↓ \ Level → L1 L2 L3 L4 (full-auto)
pr none none none none
external (default) none none none (git only — deployment is out-of-band) none (git only)
test none none deploy to test deploy to test
prod none none deploy to prod deploy to prod

The default external reproduces the legacy fused behavior exactly (L3 → direct merge + no deploy, L2 → PR), so a project with no promotion block is unchanged. Setting a ceiling only ever narrows (pr) or extends (test/prod) from that baseline. Deploy adapters that execute the test/prod deploy land in #450; until then a non-external deploy action is a recorded intent, not yet an executed deploy.

Unattended runs merge on green (#2053). A third input, _workflow.unattended: true (workflow-config.md; set by the farm tick, false on a laptop), forces the git column to PR at every level — the run opens a PR and scripts/promotion/merge-on-green.sh fast-forwards the main branch only when every required check is green (decide reports merge: pr, merge_via: merge-on-green). The deploy column is unchanged: a test/prod deploy still runs at L3+, but only after the green fast-forward has landed. The required checks are named on the base branch, never by the PR (#3625). Red, timeout, no CI at all, or a PR that changes .github/workflows/** leaves the PR open with a comment naming the check or the reason, and the session summary carries the PR number and the CI verdict. See How it merges.

L4 promotes exactly as far as L3 — the ceiling governs reach, not the automation level. Because Level 4 is a superset of Level 3, its git/deploy columns match L3 cell-for-cell: the promotion ceiling is the single axis that decides how far work is carried, and it caps L4 the same way it caps L3 (a pr ceiling still stops at a pull request). What L4 adds is enforcement above the matrix, not reach beyond it: a deploy or merge past the ceiling is an irreversible one-way door, refused by the reversibility engine and the full-auto floor (prod-deploy-past-ceiling), never auto-resolved. So L4 never promotes further than L3 — it only removes the human at the wheel within the ceiling.