Permission System

Every tool call is allowed by a rule or read-only analysis, or denied with a reason. Nothing prompts.

The tool-policy gate (cmd/toolpolicy) hooks every tool call, permission request, prompt submit, Skill result and mid-session directory add, in every host (terminal, Desktop, headless, subagents) and mode. Its settings: Settings § Permission gate.

The closed world

RuleWhat it means
No dialogA denial stands until a rule is added; retries never help.
Mode autoGate denies stand, gate allows are withheld: Claude Code's own flow (settings rules, read-only auto-approval, classifier) decides admitted calls. toolpolicy allow, revert, check, freeze answers still emit. Other modes get the gate's allow; the template disables bypass.
Any argument shapeargs may be any JSON value; a Workflow stage launch's object decodes to its text, so no launch form fails open.
Fails open

Gate binary missing or failing: Claude Code decides alone, prompts reappear. Fix: bin/configserver -apply.

When something is denied

The deny reason is the model's only channel at failure: the cause, the rule that would have allowed the call, what works instead.

tool-policy: denied Bash — no allow rule matches. candidate rule: Bash(mdfind *).
Nothing prompts in this environment; a denied capability stays denied until a rule is added.
Use an allowlisted form or a read-only alternative; do not retry the same call.
Denied asCauseWhat to do
Read-only probeCompound with a state-changing part.One command per call; read-only chains and one pipe of two allowed sides run.
Command, tool, domainNo rule matches; candidate rule named, logged to ~/.claude/tool-policy/denials.jsonl.Add it in its home (next section). /smine-permissions proposes rules denied in two or more sessions.
Not read-only, ungated, readable, or in a tool classLists are gate.* settings.Config server Smine tab, or smine-rules settings set gate.read_only_commands '["cat","git status",…]' (a list takes the whole JSON array); apply renders them into ~/.claude/hooks/config.json, section toolpolicy.

Where a rule belongs

One home per rule.

HomeHoldsWritten by
settings/claude_code/components/gate.jsonGlobal template: repo-agnostic, non-destructive mechanics, plus Skill(<name>) per skill whose invocation adds no permission. No reset, branch-deletion or push verbs.You; bin/configserver -apply deploys it.
<worktree>/.claude/settings.local.jsonCommands only one repo has. Read from the session anchor's worktree, never the main checkout. Desktop seeds a new worktree from the main checkout's file; a CLI worktree starts without.#set, #add, #reset (your grants, no shape check); toolpolicy allow after your #allow settings.local.json.
allowed-tools in <leaf>.cueA skill run's needs, e.g. Workflow (below).The skill's author.
plans/…/permissions.jsonOne plan's implementation needs: Plan rules.Frozen by its design run, applied by the gate.
Planning

toolpolicy check [--skill <leaf>]… '<target>' … reports the gate's verdict per target; no ledger line.

Skill grants

RuleDetail
AccumulateAn active skill's grants hold for the rest of the session, subagents included.
ActiveTyped slash prompt: activates. Agent's Skill call: only if a settings Skill(<name>) allow rule or an active skill's Skill(<name>) grant admits it, or your newest message names the skill. Denied: no grant. Already active: admitted, no grant.
NamedA /<skill> word anywhere in your typed message, mid-turn too (later line, mid-sentence, pasted block), until your next message. Second slash: a path. Tool results, hooks, relays, headless prompts name nothing.
ChainsA skill invoking a permission-adding skill (itself or in a spawned subagent) lists it as a Skill(<name>) grant; make skills-check fails on a grant no routing edge reaches.
Two readersClaude Code reads the field for the invoking turn only; the gate's reading counts.
Repo-scoped valuesDeploys as text (Bash($SKILL_PACKAGECOMMIT_PROJECT_STRIP project -strip)); the gate fills it per session from the session repo, as skillenv resolves: registry row env over skill default over global default, global defaults bound via the active skills' env.json; a command value of several lines fills one rule per line (Bash($SKILL_CLOSE_CLEAN)). Dropped if the repo sets nothing, or in a render without the gate component (the bundle). Bash(awk *), run by invocation placeholders: in the global template and every grant list rendering one.
WorkflowClaude Code ignores a hook allow here and prompts, so the global template grants it. A launch works iff a settings rule (settings.json or settings.local.json) admits it and the caller is root (confirmed 2026-10-03); no skill grant or --allowedTools substitutes.

Self-service

toolpolicy allow '<rule>' … appends to the anchor's settings.local.json. The agent never edits that file directly.

CaseAnswer
Rule not yet in the fileNeeds your last #allow settings.local.json; a last #deny settings.local.json refuses.
Rule already presentAPPLIED without either.
Undotoolpolicy revert undoes the last apply.
Headless sessionNever self-serves.
Never self-serviceable

Wrappers, the gate's own binary, non-literal program heads, wildcard fetch domains, MCP rules other than one exact tool, bare tool names, Skill(…), Agent(…), path patterns, Tool(param:value) rules.

Plan rules

A plan's rules live in plans/<feature folder>/<plan dir>/permissions.json (git-ignored): rules (each rule, target, needed_for) and checked. Never in, or read from, the plan file. Outside plan mode, smine-rules plan-display <plan file> <session plan file> writes plan plus rules into the session plan file under ~/.claude/plans/, the display copy. That directory is global, not a repo's plans/: it works across sessions, so a plan and its rules survive a hand-off to another session.

  1. The design run freezes the file

    toolpolicy freeze plans/<feature folder>/<plan dir> records the file, even an empty set, in ~/.claude/tool-policy/frozen.jsonl and applies its rules to that worktree. Each rule passes the self-service shape check and equals the gate's narrowest rule for its target.

    toolpolicy freeze plans/2026-10-02-x-7ea30b5/2026-10-02-x-wip-7ea30b5
  2. One freeze per typed design prompt

    One per /fdesign prompt you typed, one per unattended design run. Refreezing needs such a prompt naming the plan file. The rules file is write-protected except in a session that may still freeze its plan.

  3. A typed /fimplement is the code gate's approval

    Plan at its code gate, no record, session may still freeze it: the hook freezes it on that prompt (same checks), noted in the report's approval: line. The word counts anywhere in the prompt (later line, mid-sentence, pasted block); the freeze lands at prompt submit, before the agent acts. Never: a prompt opening with another harness tag (cross-session message), a Skill(fimplement) call.

  4. Every /fimplement applies the record

    /fimplement <plan file name> applies it to the anchor's settings.local.json, any worktree. Plan: the first argument word naming a plan file (arguments: all after the command, line breaks included, as the harness splits them); else the plan this session last ran smine-rules plan-sections on (a design run's one plan record, forced by fdesign's enforce block before the code gate's turn ends); neither: best-effort. A master plan applies every derived plan whose master: link names it, in one write one toolpolicy revert undoes. No record: nothing applied, the run continues.

    tool-policy /fimplement hook:
    - plan: …/plans/2026-10-02-x-7ea30b5/2026-10-02-x-wip-7ea30b5/design/x.md
    - Permissions: appended 2 rule(s), 0 already present — Bash(lsof *) Bash(./bin/heartbeat serve *)
Note

Nothing waits on a freeze: neither smine-rules plan-sections nor any skill refuses an unfrozen plan.

Headless cells

A claude -p session started by the routine runner, fleet worker launcher, eval runner or a config-server probe; the launcher sets SMINE_HEADLESS=1 on the child process.

LauncherMarker
RunCell and RunChat in internal/routinerunset
The two probe scripts in internal/serverset
A container cellset by an explicit --env SMINE_HEADLESS=1 on docker run
The eval and smoke runnerreaches RunCell through internal/skilleval/arm.go
An attended fleet workeroff (see below)
In a cellRule
Same gate, same sourcesSettings layers plus active skills' grants; the first prompt /<skill> … activates that skill.
--allowedToolsThe launcher passes the skill's allowed-tools (claudeArgs in internal/routinerun, internal/skilleval) plus extras: routine single cells Write, fleet workers Workflow, Skill, Write, eval arms Workflow, Skill or a fallback list. The gate never sees a command line; it decides from the deployed SKILL.md's same line alone, so both agree by construction. For Bash the gate's allow suffices; Claude Code reads the frontmatter line headless too (operator-tested, reported 2026-10-03).
ResumeSessions outlive their process: claude -p --resume <session id> continues one with context; claude --resume <session id> reopens it in your terminal to salvage, compact or steer. Not final: a session limit, Prompt is too long, a heartbeat stop (operator-tested, reported 2026-10-03).
Rules added during a runApply from the next call; settings load on every call.
No typed turnA headless prompt never approves: toolpolicy allow, toolpolicy revert refused; no Workflow launch guard.

What the marker changes

BehaviourAttended sessionHeadless cellWhyDecided inPinned by
Sound hooksplaysilentparallel cells stacked their chimes2026-09-26-sound-hook-headless D1headless-env in TestRunCell, TestRunChat
/fimplement plan hookapplies the plan's frozen record to the worktreedoes nothinga routine must not widen from a plan it wrote itself; headless never widens2026-09-28-permission-guard D9, 2026-10-01-permission-jailbreak-fix D6TestRunPlanHook_Headless, bypass case headless-applies-nothing
Freeze budgetone per /fdesign prompt you typedone, when the first prompt is /fdesigna nightly design run has no typed prompt2026-10-01-permission-jailbreak-fix D3headless-first-prompt-freezes, headless-first-prompt-other-skill
Freeze effectrecord written, rules applied to the worktreerecord written, rules not appliedheadless never widens2026-10-01-permission-jailbreak-fix D20headless-first-prompt-freezes

Decision rows: the archived plan dirs above, in ~/GolandProjects/agent-plans/claude-configs/.

Consequence

Rules an unattended design run froze apply only when an attended session runs /fimplement on that plan. Deliberate, not a gap.

CaseRule
The marker is the test, not the modeAn unmarked claude -p is attended for all four rows.
Attended launchsmine-routine worker -attended starts the cell unmarked: its attended orchestrator's operator is the human step, so the cell's /fimplement applies its plan's frozen rules. Refused when the launcher runs marked: headless never hands attendance on. The fleet skill passes it for implementation cells only; designer cells stay marked, giving a nightly design run its one freeze.
Slash words in a launchA Bash argument starting with / is a path to the gate, so /<skill> … on a command line hits the path fence. smine-routine worker takes -skill <leaf>, -args <text> and writes the slash.

Probed 2026-10-03

CLI 2.1.282; cells started from a session's shell, unmarked, without --allowedTools.

ProbeResult
SessionStart hooksRun: global-context file written for the cell's session id; heartbeat registered it. Cells under .claude/worktrees/<name> get the same hooks as any session.
Session identityOwn CLAUDE_CODE_SESSION_ID, CLAUDE_CODE_MESSAGING_SOCKET in its Bash environment; CLAUDE_CODE_MESSAGING_TOKEN set. CLAUDE_CODE_ENTRYPOINT: the launcher's, says nothing about the cell.
Skill grantA Bash command only the active skill grants: ran.
Rule added mid-runA denied command ran once its rule entered the worktree's settings.local.json.
Gate-only ruleUnder --setting-sources user (no project or local settings loaded), a Bash command ruled only in the worktree's settings.local.json ran: Claude Code needs no own rule for a gate-allowed Bash call.
Plan hook in an unmarked cellFirst prompt /fimplement <file>: got the tool-policy /fimplement hook: lines, plan path resolved.
Resumeclaude -p --resume <session id> over the stream-json protocol continued a finished cell with context, same working directory; SessionStart hooks reran as SessionStart:resume.
Not probedNon-Bash tools without --allowedTools, launchd-started cells, resume from another working directory.

How a call is decided

Checks run in this order on every PreToolUse call; the first answer wins.

#CheckThen
1ExitPlanMode?The engine decides: plan approval stays yours.
2Denied tool, or a deny rule matches?Deny.
3Breaks an invoked skill's enforce gate rule?Deny.
4Workflow with a marked non-primer script, or a launch after a typed turn outside a notification-opened turn?Deny (launch guard below).
5Agent without an explicit non-Fable model?Deny.
6Ungated built-in?Allow.
7Bash, PowerShellPath fence → read-only? → one pipe? → settings rule → skill grant. A match allows, no match denies.
7Read, Glob, GrepInside the read roots or a skill path grant.
7Edit, Write, NotebookEditInside the write roots and not protected.
7Rule-bearing: WebFetch, WebSearch, Skill, Agent, Workflow, Monitor, Artifact, EnterWorktree, mcp__*A settings rule, a skill grant, or a Skill call for an active skill or one your newest message names.
7Unknown toolDeny.

The pieces

PieceRule
Permission requestsClaude Code checks again after the gate: a write under a .claude directory is a "sensitive file" whatever a hook said. The gate also answers PermissionRequest with its pre-call verdict (admitted: allow, else deny); admitted calls never wait on a dialog. ExitPlanMode: no answer. toolpolicy commands: answered pre-call, never rerun. No ledger line.
Browser sign-inA Browser pane call (mcp__Claude_Browser__*) whose URL (own url or a batch action's) carries credentials: denied for every host, as the pane would show a sign-in consent card no hook reaches. Checked right after the deny rules, both hook points. The dev config server needs no credential: its plain loopback URL opens card-free.
Enforce gate rulesEach invoked skill's enforce.json: tools denied in plan mode, agent types only its Workflow spawns, paths the root may not write, a path's only writers. Unparseable: every write denied. Missing (rendered only with gate and heartbeat installed): no rule. Patterns carry the program this install filled for install-scoped variables; repo-scoped ones never reach a pattern. Stateful checks: Heartbeat § Runtime enforcement.
Runtime placementSKILL.md's docker:, enable-delegation:, enforce-permissions: lines (from <leaf>.cue) are declarative: shown by --help and the config server's Runtime block, never read by the gate. The harness withholds Workflow from subagents; root-only skills' lane subagents re-invoke with lane:<direction> for their context slice; mcp__ccd_session_mgmt__set_session_permission_mode is judged like any MCP call. The 2026-10-09 placement denies deadlocked every railroad-review lane; removed 2026-10-12.
Workflow launch guardThe harness hands the newest typed message to every agent of a launch. So once a session has a typed turn, a Workflow launch is denied unless the root transcript's newest turn record (user row stamped turnOrigin, or mid-turn message) is a task_notification turn. No typed turn (headless sdk prompts, peer-only): unguarded. Exempt: the primer, an inline script equal to the cmd/toolpolicy/relay.go constant after collapsing whitespace runs. Only it may carry the marker SMINE-PRIMER; other inline scripts holding it: denied everywhere. No allow rule lifts either deny; deny and enforce rules bind first. The deny reason carries the primer and steps: launch inline, end the turn, relaunch in the turn its notification opens.
Deny reasontool-policy: denied <tool> — <cause>. candidate rule: <rule>. plus steering (what works instead).
LedgerOne line per deny: anchor, scratchpad_dir, plus agent_id/agent_type in subagents.
Session anchorThe transcript's first cwd; EnterWorktree, ExitWorktree move it. Subagents (Agent tool, Workflow) anchor at their own transcript's first cwd (<session>/subagents[/workflows/<run>]/agent-<id>.jsonl) if it lies directly under the session repository's .claude/worktrees, else at the parent's. Never the envelope cwd. Project settings load from the anchor.
Read rootsAnchor; scratchpad; session temp dir /tmp/claude-<uid>/<project dir>/<session id> (holds scratchpad/, images/, and task outputs tasks/<id>.output named by Workflow completion notifications). That dir is rooted as the glob /tmp/claude-<uid>/*/<session id> from the envelope's session_id and the gate's uid, since the project dir differs between a cell's cwd and a Desktop session's project but the session id never does; so headless routine cells lacking scratchpad_dir get it too. Also as scratchpad_dir's parent when present. Read-only: additionalDirectories, Desktop-added folders, extra_read_dirs (~/.claude, app and toolchain caches, ~/go/bin), fixed darwin system dirs, never /etc: /Applications/Xcode.app, /Library/Developer, /System, /opt/homebrew, /bin, /dev, /sbin, /usr. Sole managed-settings layer read: /Library/Application Support/ClaudeCode/managed-settings.json. Settings layers and ~ expand against the gate process's own $HOME.
Write rootsAnchor, scratchpad, additionalDirectories, Desktop-added folders, extra_write_dirs (plan files, auto-memory). Protected and engine-protected paths refused first; only extra_write_dirs skip the engine-protected check.
Folders added in the Desktop appThe "+" button's Add folder, drag and drop onto the session, a directory granted via a Desktop prompt: an addDirectories update, destination session, in the Desktop session record (no removals recorded). The gate reads it every hook call (internal/desktop, located by CLAUDE_CODE_HOST_SESSION_ID), not for toolpolicy check (its report omits them); they count as additionalDirectories, .claude/settings*.json protected alike. Terminal sessions, launchd-started cells: no record, nothing gained. A cell launched from a Desktop session inherits its CLAUDE_CODE_HOST_SESSION_ID (RunChat, RunCell pass the launcher's environment unscrubbed). #add persists a folder instead.
Bash path fenceWords expand statically, never execute; cd tracked; per-program path roles from fence_specs.go, else generic. /tmp writes steer to the scratchpad. Non-read-only calls may not name a protected path in any word or joined flag value; go -exec, -toolexec, -vettool refused.
acceptEdits file commandscp, mkdir, mv, rm, rmdir, touch run ruleless once the fence places every path inside the write roots.
Confined sessionsEval and smoke arms run under SMINE_READ_FENCE (set by the runner): eval dirs, plans archive. Fenced roots: denied, read and write, in every path check (file tools, every Bash path argument however spelled) and in any Bash command naming them absolutely or as ~/…, $HOME/…, before any skill grant. Arm stores live in the arm's worktree (SMINE_STORE_ROOT).
Protected, never written by a sessionSettings layers, gate inputs, protected_paths, the transcript, .git, make sources until #allow Makefile, a plan dir's permissions.json outside a design run that may freeze it.
Not coveredCode a command runs after launch (go test, a make recipe) keeps full filesystem access.

Make is the command surface

RuleDetail
One rule admits every targetEvery repo command is a Makefile target; Bash(make *) admits all.
Make sources are lockedMakefile, GNUmakefile, *.mk: write-locked until #allow Makefile.
Declared variables onlyNAME=value only when the target's ## <target>: help line declares NAME; values free of shell metacharacters.
Denied flagsThose loading make code or overriding the environment: -f, -E, -I, -e.
Operator targetsA help line carrying [operator]: refused in a linked-worktree session, as is executing install.sh or a script under cmd/sync/ there.

Approvals: the # commands

A line of your message whose first word is #allow, #deny, #add, #set, #reset, #disable or #enable; one grammar for all seven (cmd/toolpolicy/hashtag.go). Other lines: ignored prose. Lines apply in order; a misshaped one (third word on #allow, words after #reset) is nothing.

LineEffectAnswer
#allow MakefileUnlocks the target for the session; #deny <target> re-locks; last wins. Targets: Makefile, settings.local.json (short forms -s, --s, -settings, --settings). Other targets: kept, unlock nothing.tool-policy #allow: <target> unlocked for this session — the last #allow or #deny for it wins, or tool-policy #deny: <target> locked for this session
#add /absolute/dirAppends to permissions.additionalDirectories of the anchor's settings.local.json: read and write root from the gate's next call; Claude Code reloads the file too. Relative or ~ path, non-existent directory, third word: silently nothing.tool-policy #add: … is in additionalDirectories of …
#set Bash(mdfind *)Appends allow rules to that file's permissions.allow: the rest of the line plus following lines up to a blank line or the next command line form one rule, gate dialect, no self-service shape check. Refused: path rules (Read(~/x/**); settings path rules never match, use #add); unparseable rules (the rest still applies).tool-policy #set: N rule(s) in permissions.allow of … — … or tool-policy #set: rejected — …
#resetOne-word line: empties both lists of the file, clears the session's switches, inerts every earlier #add, #set, #disable, #enable; later lines in the message apply afresh; other keys stay. Mid-turn: earlier messages go inert, the file keeps its lists (only a typed prompt clears them); their switches return at the next gate event, the off set refolded from messages after the last #reset.tool-policy #reset: cleared …
#disable rgateHook effects off for the session; #enable: back on. Grammar below.tool-policy #disable: off — rgate, hb (from the next hook event on)

What counts as your message

Counts: a typed prompt (turnOrigin: human, every text block) and a message typed mid-turn (queued_command attachment, human origin, no meta mark): interruptions, their addenda and runs of interruptions all arrive as such rows. A Desktop paste wrapper drops with its body wherever it sits: no command inside a paste counts, a pasted transcript fires nothing; typed text around it counts.

Never counts (internal/transcript.Classify): hook block reasons written back as user rows (isMeta, "Stop hook feedback:", above all the session-title hook's rename instruction), the [Request interrupted by user] marker, tool results, compaction summaries, local commands like /model, peer relays, task notifications, headless prompts.

The gate reads commands from the transcript on every call, plus the submitted prompt. A mid-turn #add counts at the agent's next tool call; a #set rule is written on that call and admits it. A submitted prompt prints one result line per command as context; mid-turn messages apply silently. The file is the applied state: already reflected messages write nothing; toolpolicy revert never touches these entries. Claude Code's DirectoryAdded hook (/add-dir, the SDK's register_repo_root) runs the gate, appending that directory likewise; --add-dir and the Desktop "+" raise no such event.

Features for #disable and #enable

FeatureMembersWhat it switches
rgateglobal, invoked, plan, <ext|guide>What the read gate owes: session global file, invoked skills' injected context, plan-mode file, language guides by extension or name. An extension (from the guides' Files: patterns in the anchor's context index) drops every guide covering it: rgate:go both Go and Go test guides; rgate:cue the skills guide for <leaf>.cue, .md payloads still owing it. A guide name (file stem) drops that guide for all files: rgate:go_test, rgate:rust, rgate:skills. A guide an earlier denial recorded clears by name or once all its extensions are off; a path-only guide, by name only.
skillenforce, invokegateThe gates on skill runs.
injectglobal, skill, acdsl, env, planHook writes into context or the session. inject:acdsl: post-Read rules context. inject:env: skillenv exports and its check of an invoked skill's required repo values.
sessiontitleThe session-title hook.
hb(heartbeat spells it)Heartbeat.
allEverything.

A bare group word names all members, <group>:a,b some; words expand before the fold, so #disable rgate then #enable rgate:go leaves all rgate members but go off. The gate folds these lines (last per member wins) into switches.off of its session record ~/.claude/tool-policy/state/<session_id>.json, written only on change; every hook binary and heartbeat reads it before acting, fail-open, so switches apply from the next hook event. Each change appends to switches.history ({action: disabled|enabled, at, feature}, stamped at the gate's write), shown under Session features on the session's config-server context page /sessions-report/<id>. Never features: the gate, its message channel, the worktree write guard, the heart attack, the help hook. /smine-help prints what each feature keeps on.

tool-policy #disable: rejected — unknown feature "gate"; features: rgate[:global|invoked|plan|<ext|guide>], skill[:enforce|invokegate], inject[:global|skill|acdsl|env|plan], session[:title], hb, all
tool-policy #disable: off — rgate, hb (from the next hook event on)
tool-policy #enable: on — rgate:go; still off — hb

Rule dialect

RuleAdmits
BashBare tool name: the whole tool.
Bash(git log*)Whole-command glob; without *, the exact command. Flags go after the rule's words: git commit -s -m misses Bash(git commit -m*). Git's leading -C <dir>, --no-pager, -P and --no-optional-locks are cut before matching.
Skill(fdesign)An agent's call of that skill.
Agent(railroad-station)Spawning that agent type.
WebFetch(domain:docs.rs)Fetches from that domain.
mcp__server, mcp__server__toolEvery tool of the server, or one tool.
Read(~/x/**), Read(//abs/**), Read(@repos/**), Edit(…), Write(…)Path rules: skill grants only, never from settings.

The git floor

No rule lifts these.

RefusedForms
Other global options-c, --git-dir, --work-tree, --exec-path, --bare
EnvironmentGIT_*, except a fenced GIT_INDEX_FILE
Command-running options--upload-pack, --receive-pack, --exec, rebase -x, grep -O, difftool, mergetool, bisect run, submodule foreach, filter-branch
Planted bare repossafe.bareRepository explicit, set globally.

Read-only analysis

Fail-closed: a command is read-only only when the analysis says so.

RuleDetail
The listgate.read_only_commands: a program, or program plus subcommand (git status, go env).
Wrappersgate.wrapper_commands (default command, env, nice, time, timeout, xargs), skipped to the wrapped command.
DisqualifiedNon-literal words on flag-checked heads; redirects other than 2>&1, >/dev/null, 2>/dev/null, <file; heredocs; &; find -exec/-delete/-fprint*; sed -i; go env -w/-u; non-literal VAR= prefixes.

Cards the gate cannot reach

The gate's PermissionRequest answer covers only what Claude Code asks through that hook; cards the Desktop app raises itself are controlled in its Settings.

Files and tests

WhereWhat
internal/install (gate component)bin/configserver -apply builds the gate to ~/.claude/hooks/bin/toolpolicy and owns the allow and deny entries of settings/claude_code/components/gate.json. The Session hooks component renders ~/.claude/hooks/config.json; the gate reads its toolpolicy section at the fixed path <claude home>/hooks/config.json (no policy path on the command), strictly decoded by internal/hookconfig. Unknown key or invalid listing: load fails, the gate exits 1 naming the file, a failing gate to Claude Code (fail-open).
gate.* settingsThe former policy keys: gate.read_only_commands, gate.ungated_tools, gate.tool_classes_bash|read|write|rule_bearing, gate.denied_tools, gate.protected_paths, gate.extra_read_dirs, gate.extra_write_dirs, gate.operator_paths, plus the gate's own lists (gate.wrapper_commands, gate.file_commands, gate.tmp_dirs, gate.engine_protected_dirs, gate.engine_protected_files, …). Deny causes, allow reasons and steering texts too: gate.cause_*, gate.reason_*, gate.steer_* (sections toolpolicy.causes|reasons|steers; an empty text is refused at load). Only the relay primer script stays a constant, in cmd/toolpolicy/protocol.go: the deny hands it out, the primer exemption compares against it byte for byte.
~/.claude/tool-policy/state/<session_id>.jsonSelf-service state and switches.
~/.claude/tool-policy/frozen.jsonlFrozen plan rules.
go test ./cmd/toolpolicyBash fixtures, dialect, decision table, fence, grants, hook contract, ledger, the bypass table bypass_test.go.
~/.claude/hooks/bin/Other deny-only hooks, all Go binaries: skillinvokegate, readgate, worktreewriteguard. The read gate decides only the Bash, Read and Write classes of the same toolpolicy section. No hook gates commit messages: the settings' empty attribution stops the harness's co-author trailer; the trailer commit rule is prose.
Design logThe archived 2026-09-17-permission-closed-world plan dir and plans/2026-09-26-tool-policy-cleanup-wip-4ca7753/.