Settings and hooks
Every settings.json file the harness writes, every JSON key path it touches, and every hook it wires into the Claude host, git, and the self-host sandbox. For operators debugging why a tool call was blocked, and for anyone auditing what the harness installed on their machine.
Three unrelated things are called “hooks” in this repo. This page covers two of them: host event hooks (hooks/claude/*.sh and the engine’s per-worktree scripts, fired by the Claude host) and git hooks (prepare-commit-msg / commit-msg, generated per worktree). The third — config step hooks, steps.<name>.hooks.before / .after — is a .ai-conductor/config.yml key and is documented in configuration. Config step hooks are not connected to any host event.
Settings files
Four settings.json files are written or merged by harness code.
| File | Owner | Written by | Tracked |
|---|---|---|---|
~/.claude/settings.json | operator, machine-wide | bin/install (configure_permissions, configure_hooks), merged in place | n/a |
<project>/.claude/settings.json | project | engine preflight ensureClaudeSettings(); the bootstrap skill | committed |
<worktree>/.claude/settings.local.json | engine | worktree-prepare.ts::wireSessionHookSettings() | untracked — .claude/ is added to the worktree’s info/exclude |
<throwaway CLAUDE_CONFIG_DIR>/settings.json | self-build sandbox | sandbox-build-env.ts + write-fence.ts::mergeFenceIntoSettings() | ephemeral |
All four are plain Claude Code settings files. The harness writes only a subset of the schema.
Key paths
Source-ordered by JSON path. Anything not listed here is never written by harness code.
| Key path | Value | Written by |
|---|---|---|
$schema | https://json.schemastore.org/claude-code-settings.json | preflight.ts::buildSettingsJson; templates/claude-settings.json.template |
permissions.allow[] | array of permission strings | bin/install::configure_permissions (global); preflight.ts and the template (project); the bootstrap skill |
permissions.deny | — | never written by any harness code path |
permissions.ask | — | never written by any harness code path |
env | — | never written; environment is injected at spawn time (see environment) |
hooks.PreToolUse[] | array of hook entries | bin/install::configure_hooks; wireSessionHookSettings(); mergeFenceIntoSettings(); the bootstrap skill |
hooks.PreToolUse[].matcher | tool-name pattern, e.g. Bash, Edit\|Write\|NotebookEdit, Task\|Agent | same |
hooks.PreToolUse[].hooks[] | array of commands for that matcher | same |
hooks.PreToolUse[].hooks[].type | always command | same |
hooks.PreToolUse[].hooks[].command | absolute path to a script | same |
hooks.PreToolUse[].hooks[].timeout | seconds | bin/install::configure_hooks; the bootstrap skill |
hooks.PreToolUse[].hooks[].if | Bash(gh pr create*) | the bootstrap skill only — the sole use of if in the harness |
hooks.PreToolUse[].hooks[].statusMessage | Running pre-PR lint gate | the bootstrap skill only — the sole use of statusMessage |
hooks.PostToolUse[] | array of hook entries | bin/install::configure_hooks; wireSessionHookSettings() |
hooks.PostToolUse[].matcher | Edit\|Write, Bash, Task\|Agent | same |
hooks.PostToolUse[].hooks[].type | always command | same |
hooks.PostToolUse[].hooks[].command | absolute path to a script | same |
hooks.PostToolUse[].hooks[].timeout | seconds | bin/install::configure_hooks |
hooks.SessionStart[].hooks[] | one entry, no matcher key | bin/install::configure_hooks |
hooks.Stop[].hooks[] | one entry, no matcher key | bin/install::configure_hooks |
hooks.StopFailure[].matcher | rate_limit | bin/install::configure_hooks |
hooks.StopFailure[].hooks[] | one entry | bin/install::configure_hooks |
hooks.PreToolUse[].hooks[].command is always an absolute path interpolated from the harness checkout (${HARNESS_DIR}/hooks/claude) or from the worktree (<worktree>/.pipeline/session-hooks). Moving or deleting either directory breaks every hook wired from it.
Precedence
Claude Code resolves settings highest to lowest: enterprise policy, CLI args, .claude/settings.local.json, .claude/settings.json, ~/.claude/settings.json. Mapped onto the four harness files:
~/.claude/settings.json— lowest precedence, but the only home of the harness’s eight wired lifecycle hook entries. Machine-wide, additively merged, never pruned.<project>/.claude/settings.json— committed. Permissions plus the bootstrap pre-PR lint hook. Created only if absent; the engine never merges it after creation.<worktree>/.claude/settings.local.json— highest of the three. Engine-owned and rewritten on every worktree provisioning pass. Do not hand-edit it inside a build worktree.- Sandbox
CLAUDE_CONFIG_DIR/settings.json— for a self-build, layer 1 is replaced entirely by{}plus the write-fence entry. Layers 2 and 3 still apply. See self-hosting.
Hooks from multiple layers accumulate; they do not override. A project running the daemon has docs-guard.sh wired twice — once from ~/.claude/settings.json (harness checkout path) and once from settings.local.json (worktree session-hooks/ path). The two script bodies are byte-identical and the guard is idempotent, so double-firing is harmless.
Permission sets
Three producers write a project permissions.allow, and they do not agree.
| Producer | Entries |
|---|---|
templates/claude-settings.json.template | Read(**), Edit(**), Write(**) |
preflight.ts::buildSettingsJson | those three, plus Bash(git:*), Bash(gh:*), Bash(rtk:*), Bash(npm:*), Bash(npx:*), Bash(node:*), Bash(mkdir:*), Bash(touch:*), Bash(chmod:*), Bash(ln:*), Bash(glow:*) |
this repo’s committed .claude/settings.json | the 11 Bash(...) allows only — no Read/Edit/Write |
The operator-global set HARNESS_PERMISSIONS in bin/install is 18 entries: Read(${HARNESS_DIR}/**) plus 17 Bash(...) allows covering bundler, Rails scaffolding, docker compose, non-destructive git (init, add, commit, status, log, diff, branch, checkout -b), and read-only gh (pr list, issue list). Read(${HARNESS_DIR}/**) is absolute, so reinstalling from a different checkout repoints it.
Destructive git operations — push --force, reset --hard, branch -D, clean -f — are deliberately absent from every allow list, and no permissions.deny rules ship at all. Denial is enforced mechanically by hooks/claude/block-destructive-git.sh instead.
Wired hook events
Eight event/matcher entries are written to ~/.claude/settings.json by bin/install::configure_hooks.
| Event | Matcher | Command(s) | Timeout (s) |
|---|---|---|---|
PreToolUse | Bash | block-destructive-git.sh | 10 |
PreToolUse | Bash | tdd-commit-gate.sh | 10 |
PreToolUse | Edit\|Write\|NotebookEdit | docs-guard.sh | 10 |
PostToolUse | Edit\|Write | lint-after-edit.sh (fires per edit; lints only at batch boundaries — see below), spec-coverage-check.sh, diagram-coverage-check.sh | 30, 10, 10 |
PostToolUse | Bash | post-commit-derive-feedback.sh | 15 |
SessionStart | (no matcher key) | session-start-context.sh | 15 |
Stop | (no matcher key) | stop-memory-reminder.sh | 10 |
StopFailure | rate_limit | rate-limit-wait.sh | 10 |
UserPromptSubmit, PreCompact, SubagentStop, SessionEnd, and Notification are never used.
The merge is python3-based: read, mutate in memory, json.dump(indent=2) plus a trailing newline. It is idempotent and non-destructive — the merge unit is the entry, keyed on its command-string set, and an entry is appended only if it contributes at least one new command. Operator entries are never removed. Without python3 on PATH the whole step is skipped with a warning.
Neither templates/claude-settings.json.template nor this repo’s own .claude/settings.json contains a hooks key. ~/.claude/settings.json is the only place the hooks/claude/*.sh suite is registered.
Operator hook scripts
Ten scripts live in hooks/claude/. Alphabetized.
| Script | Trigger | What it does | Can block? |
|---|---|---|---|
block-destructive-git.sh | PreToolUse / Bash | Strips quoted spans and heredoc bodies from the command, then pattern-matches destructive git. Early feedback only; the engine git guard is the enforcing control. Emits a non-blocking NOTE on ad-hoc git rebase. | Yes — exit 2 on git push --force/-f, git reset --hard, git branch -D of a not-provably-merged branch, git clean -f, and git checkout -- . / git restore .. --force-with-lease is explicitly allowed. |
diagram-coverage-check.sh | PostToolUse / Edit\|Write | Warns that diagrams may be stale when a structural file is edited (Rails app/{models,controllers,services,jobs}, config/routes.rb, db/migrate/, compose files, Procfile, or src/{models,controllers,services}) and .docs/architecture/ exists. | No |
docs-guard.sh | PreToolUse / Edit\|Write\|NotebookEdit | Inert (exits 0 without reading stdin) unless .pipeline/phase-active exists. Otherwise reads a bounded payload (timeout 3 head -c 1048576), extracts tool_input.file_path or tool_input.notebook_path via node -e, recognizes physical and symlinked project-root spellings, and checks both the requested path and its filesystem-resolved destination. A .docs/ candidate is default-denied unless its literal allow: prefix in the marker matches; both candidates must pass. | Yes — exit 2 on an undeterminable target (fail-closed) and on any unallowlisted .docs/ candidate. Block message names the phase and step. |
lint-after-edit.sh | PostToolUse / Edit\|Write | Invoked per edit, but does not lint per edit. Queues the edited path under $TMPDIR/ai-conductor-lint/<repo-hash>/ and stays silent until a batch boundary, then lints the whole queue and clears it. A boundary is .pipeline/current-task changing, or — outside a pipeline, where no task marker exists — LINT_DEBOUNCE_SECONDS (default 120) elapsing since the queue opened. Dispatches by type: .ts/.tsx → ESLint (one batched invocation), .sh and bash-shebang files → shellcheck --severity=error, .rb → bundle exec standardrb --no-fix. Each is skipped silently when its tool or project context is absent. Queue state is deliberately kept out of .pipeline/, which is engine-owned. | No — always exits 0 |
post-commit-derive-feedback.sh | PostToolUse / Bash | Despite the name, not a git post-commit hook. Returns immediately unless the executed Bash command uses git commit, merge, revert, cherry-pick, am, or rebase and HEAD is newly created. git pull is excluded, including pull-created merge commits; an absent, malformed, or command-less payload falls back to evaluating HEAD. It then invokes ai-conductor derive-feedback --sha <sha> for fast advisory feedback and falls back to a bash Task: trailer match. Never writes task-status.json. | No — exits 0 on every path |
rate-limit-wait.sh | StopFailure / rate_limit | Reads error_details and last_assistant_message from the host’s JSON stdin, with $CLAUDE_ERROR and .pipeline/conduct.log as compatibility fallbacks, then writes .pipeline/rate-limit-hit (line 1 epoch, line 2 wait seconds; default 300). | No |
session-start-context.sh | SessionStart | Prints the whole of HARNESS.md, a warning if the consumer CLAUDE.md is missing the HARNESS.md reference, the head of .memory/index.md, story and plan counts, and Pipeline: n/m steps done from .pipeline/conduct-state.json. Writes .pipeline/.memory-count-at-start. | No |
spec-coverage-check.sh | PostToolUse / Edit\|Write | Warns when a Rails app/{models,controllers,services,jobs} file has no counterpart under spec/. Skips application_* files. | No — warn only |
stop-memory-reminder.sh | Stop | Compares the current .memory/ entry count against .pipeline/.memory-count-at-start and reminds when work happened without a memory write. | No — no {"decision":"block"} output exists in any Stop hook in this repo |
tdd-commit-gate.sh | PreToolUse / Bash | Reads .pipeline/tdd-phase. Absent ⇒ exit 0. | Yes — exit 2 when the phase is anything other than COMMIT |
Exactly three of these can block a tool call with exit 2: block-destructive-git.sh, docs-guard.sh, and tdd-commit-gate.sh. Every other script is advisory and exits 0 regardless of what it finds.
Known limitation. Nothing in the engine, and no skill, writes
.pipeline/tdd-phase. Both TDD gates —hooks/claude/tdd-commit-gate.shandhooks/pre-commit-tdd-gate.sh— are therefore dormant unless an operator creates that file by hand. When it does exist,tdd-commit-gate.shnever reads stdin and never inspects the command, so while the phase is notCOMMITit blocks everyBashtool call, not onlygit commit. Remove.pipeline/tdd-phaseto restore normal operation. Tracked in #1009.
Generating docs-guard.sh
hooks/claude/docs-guard.sh is a generated artifact, not hand-written. Its source is the DOCS_GUARD_HOOK string constant in src/conductor/src/engine/session-hook-assets.ts; the checked-in file is byte-identical to what the engine writes into each worktree.
bin/generate-docs-guard-hook # regenerate hooks/claude/docs-guard.sh
bin/generate-docs-guard-hook --check # compare only
Exit codes: 0 no drift, 1 drift (--check), 2 environment error. Set GENERATE_DOCS_GUARD_HOOK_OUT to write elsewhere. Drift is CI-gated by test/test_harness_integrity.sh — see validation.
Per-worktree engine hooks
When the engine prepares a build or spec worktree it writes two scripts into <worktree>/.pipeline/session-hooks/ (mode 0755) and wires two entries into <worktree>/.claude/settings.local.json with absolute paths baked in.
| Script | Event / matcher | What it does | Can block? |
|---|---|---|---|
pre-dispatch.sh | PreToolUse / Task\|Agent | When line 1 matches ^Task: ([A-Za-z0-9._-]+\|none)$, appends advisory dispatch telemetry and flips that known task row to in_progress. Any missing, malformed, unknown, locked, or unwritable telemetry abstains. | No — attribution is telemetry only. |
docs-guard.sh | PreToolUse / Edit\|Write\|NotebookEdit | Byte-identical to hooks/claude/docs-guard.sh. | Yes — exit 2 |
Re-provisioning is idempotent: replaceSessionHookEntry removes only entries with the same matcher and a command containing the session-hooks/ marker, then appends. Operator-authored entries survive. The whole wiring path is fail-open — any error logs session hook settings: skipped and the worktree is used anyway.
Provisioning removes the retired post-dispatch.sh and mutation-gate.sh scripts and settings entries from previously prepared worktrees.
Git hooks
The engine generates three git hooks per worktree, into <worktree>/.pipeline/git-hooks/ (mode 0755), and points the worktree at them:
git -C <worktree> config extensions.worktreeConfig true
git -C <worktree> config --worktree extensions.worktreeConfig true
git -C <worktree> config --worktree core.hooksPath <worktree>/.pipeline/git-hooks
| Hook | Trigger | What it does | Can block? |
|---|---|---|---|
pre-commit | every commit in the worktree | While .pipeline/phase-active exists and its phase is BUILD or SHIP, rejects any staged path under a protected artifact directory (PROTECTED_ARTIFACT_DIRECTORIES in protected-artifact-seal.ts — .docs/architecture, .docs/decisions, .docs/plans, .docs/specs, .docs/stories) unless an allow: prefix in the marker matches it or its stem names the worktree’s own feature (from .pipeline/task-status.json’s plan_ref, date prefix ignored). A malformed or escaping staged path (empty, absolute, or containing ../.) is always rejected. Provider-agnostic — it fires for any commit in the worktree, regardless of which host or CLI created it, unlike the Claude-only docs-guard.sh PreToolUse hook. | Yes — exit 1 naming every offender and directing the amendment to DECIDE. |
prepare-commit-msg | every commit in the worktree | Stamps Task: <id> from <worktree>/.pipeline/current-task using git interpret-trailers --if-exists replace. Fires only when no explicit trailer is already present. Abstains on amend, on rebase replay, and on an empty staged diff. | No |
commit-msg | every commit in the worktree | Validates a supplied Task: trailer, checks its staged paths against the active task’s declared files, then emits non-blocking warnings for bundling and subject mismatch. An out-of-scope path is reported with a copy-pasteable Scope: <path> — <rationale> widening. Containment defaults to report-only; set build_review.scopeContainmentEnforced: true to refuse verified violations. | Yes — exit 1 (git-hook convention) when a supplied trailer uses the task-N form or names an id absent from .pipeline/task-status.json, or when enforced scope-check returns exit 2. |
All three hooks chain to $(git rev-parse --git-common-dir)/hooks/<name> when one exists and is executable. pre-commit and prepare-commit-msg are pure bash plus git and POSIX tools (pre-commit also uses sed; prepare-commit-msg uses node -e). commit-msg additionally invokes the installed ai-conductor scope-check <commit-message> command; none of the three hooks references dist/.
pre-commit and commit-msg both exit 0 immediately for any commit made with CONDUCT_ENGINE_COMMIT=1 — the environment the engine sets for its own bookkeeping commits (rebase mechanics, quarantine, shipped records, spec landing). commit-msg additionally exits 0 for merge commits (MERGE_HEAD present), --amend, and rebase replay.
The scope checker has three outcomes. Exit 0 means the commit is allowed: it covers both in-scope commits and an out-of-scope commit reported under the report-only default. Exit 2 is a positive refusal reserved for a future enforcement flip, which commit-msg converts to Git’s blocking exit 1. Any other exit is an abstention — for example, missing or malformed task state — and commit-msg logs that it allowed the commit. Only a task-attributed commit with a usable in-progress task row is checked. Add one Scope: trailer per out-of-scope staged path when the widening is intentional; the engine records accepted widenings for build_review, which remains the semantic scope authority.
hooks/pre-commit-tdd-gate.sh ships in the tree but is copy-it-yourself only; nothing installs it, and it is subject to the dormant-tdd-phase limitation above. It is unrelated to the engine-installed pre-commit hook above, which is generated fresh per worktree from PRE_COMMIT_HOOK in git-hook-assets.ts rather than copied from that file.
Hook installation itself is fail-closed for pre-commit/prepare-commit-msg/commit-msg: a worktree with a .git present but not writable, or any other failure while writing or wiring the three scripts, raises rather than silently continuing, so a worktree can never enter BUILD/SHIP without the preventive gate installed. A worktree with no .git at all (a plain temporary directory, as some unit-level setup tests use) has no commit surface to protect and is skipped as a no-op, not an error. This is a change from the historical behavior, where every git-hook failure was swallowed and logged.
Trailer semantics — which trailers are gates and which are telemetry — are documented in artifacts.
Engine git guard
The engine writes a git argv guard into every worktree it prepares and puts it first on the child PATH of every Claude and Codex dispatch into that worktree, in self-host and non-self-host runs. Pi dispatches are not guarded yet; Pi enforcement is tracked by #2895. It is the enforcing control for destructive git; the operator hook above is Claude-only early feedback (adr-2026-09-23-engine-git-guard-on-agent-path).
| Path | Contents |
|---|---|
<worktree>/.pipeline/bin/git | The static GIT_GUARD_SCRIPT asset from git-hook-assets.ts, a regular file with mode 0755. |
<worktree>/.pipeline/git-guard/real-git | The absolute path of the real git, resolved from the daemon’s PATH with every .pipeline/bin entry excluded. |
<worktree>/.pipeline/git-guard/common-dir | The feature repository’s absolute git common directory. |
A worktree counts as engine-prepared when its worktree-scoped core.hooksPath is its own .pipeline/git-hooks. Worktree preparation writes the three files fail-closed. Before each dispatch the engine re-verifies them and rewrites any that differ. If it cannot, the dispatch does not launch and fails naming the guard path. The daemon’s own process.env is never changed, so engine git (rebase, quarantine, shipped-record, spec landing, setup triage) runs the real git. There is no bypass variable. Runtime values live only in the .pipeline/git-guard sidecars; they are never baked into the static shim. build_review dispatches are read-only and intentionally exempt from the guard.
The guard refuses these forms when the target repository’s common directory is the feature repository’s (the feature worktree, its sibling worktrees and the root checkout). It exits 1, prints the refused operation, the reason and the safe alternative to stderr, and never passes the refused command to the real git.
| Refused | Safe alternative |
|---|---|
push with --force, -f or a + refspec, even beside --force-with-lease | git push --force-with-lease |
reset --hard | git reset --keep <target> |
branch -D or --delete --force when the tip is reachable from no other local branch or remote-tracking ref | git branch -d <branch> |
clean with -f or --force | git clean -n, then remove named paths |
checkout [<tree-ish>] -- <paths> and restore <paths> without --ours, --theirs or --merge (restore --staged alone passes) | Commit a WIP first, or use a temporary worktree |
A single-level non-shell alias is expanded before classification. Repositories with a different common directory, such as test fixtures, pass through untouched.
What the guard does NOT cover
- An absolute-path
gitinvocation, such as/usr/bin/git, bypassesPATH. - Shell startup files that put another
gitearlier onPATH. - Shell aliases and functions named
git. - Interactive and inline runs, and any dispatch whose working directory is not an engine-prepared worktree.
build_reviewdispatches, which run without the guard by design.- Custom providers, which build their own child environment.
- Non-canonical git spellings. The canonical spelling of each refused form is classified, plus
--config-envand single-level aliases, whose text is split the way git quotes it. Every other spelling git accepts, such asbranch -d -for-df, passes through unclassified until #2904 ships. - Pi provider dispatches, which run unguarded until #2895 ships.
Self-host sandbox write-fence
For a harness self-build the engine generates a fifth hook that is not a file in hooks/. generateFenceScript(worktreeRoot, harnessRoot) bakes both absolute roots into a standalone bash script written to the throwaway CLAUDE_CONFIG_DIR, and mergeFenceIntoSettings() appends it under hooks.PreToolUse:
{
"matcher": "Edit|Write|MultiEdit|NotebookEdit|Bash",
"hooks": [{ "type": "command", "command": "<sandbox>/write-fence.sh" }]
}
| Situation | Result |
|---|---|
| Target under the worktree root | Allow (exit 0) |
| Target under the harness root but outside the worktree | Block (exit 2) |
Read-only Bash (grep, cat, diff, …) | Allow (exit 0) |
| Unrelated path (other repos, OS temp) | Allow (exit 0) |
| Target could not be determined | Block (exit 2) |
| Malformed or empty payload | Allow (exit 0) |
The fence takes no environment variables — only the two baked-in roots. See self-hosting for when it is provisioned.
What the fence does NOT block
The fence has no rule for git push, gh, or any network operation, and the claude provider is dispatched with --dangerously-skip-permissions, no OS sandbox, and full environment inheritance. (codex is the sandboxed provider: unattended runs pass sandbox_mode="workspace-write".)
Because a claude self-build dispatch therefore cannot be fenced away from pushing or from gh, the engine refutes an operation-specific dispatch claim that says otherwise. environment-claim-audit.ts runs inside the self-host candidate-safety wrapper: when a line carries an environmental cause, a blocking assertion, and a named remote operation the generated fence provably cannot deny, the attempt is failed and the disproof becomes its retry reason, prefixed ENVIRONMENT_CLAIM_REFUTED. The deniable-operation set is derived from generateFenceScript itself, so teaching the fence a real git push rule retires the refutation automatically. Output that instead claims the environment blocks all, every, or any Bash, shell, tool, or terminal command (or blocks everything) passes through unchanged; that broader claim is not selectively refuted. Claims on a sandboxed or unrecognized provider are never refuted — the audit only rejects what the engine can positively disprove. See stalled or stuck feature for triage.
Malformed settings.json
Six writers touch a settings file, and each handles unparseable JSON differently.
| Writer | Behavior on malformed JSON |
|---|---|
bin/install::configure_permissions | json.load raises, nothing is written, the file is left intact, and the installer reports an incomplete-configuration warning while continuing. |
bin/install::configure_hooks | Prints ⚠ Could not configure hooks automatically; the file is left intact. Invoked as … \|\| warn, so set -e never aborts the install |
worktree-prepare.ts::wireSessionHookSettings | Renames the file to <path>.bak-<epoch>, logs the reason, and rebuilds from {}. Never discards content |
write-fence.ts::mergeFenceIntoSettings | Silently discards and restarts from {"hooks":{"PreToolUse":[]}} |
preflight.ts::ensureClaudeSettings | Never parses. A malformed project settings.json is left untouched forever, and the preflight reports nothing |
sandbox-build-env.ts::provisionTrustState | Malformed operator ~/.claude.json propagates no workspace trust — the sandbox never guesses trust |
Both installer helpers pass settings paths as data to fixed Python source and propagate processing failures to the existing warning-and-continue installer policy. Malformed settings are left unchanged.
Related
- configuration — every
.ai-conductor/config.ymlkey, including config step hooks. - environment — every environment variable the harness reads or injects.
- artifacts — the files hooks read and write, including
.pipeline/phase-active. - gates — why these blocks are fail-closed.
- extending — adding a hook.