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.
| Rule | What it means |
|---|---|
| No dialog | A denial stands until a rule is added; retries never help. |
Mode auto | Gate 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 shape | args may be any JSON value; a Workflow stage launch's object decodes to its text, so no launch form fails open. |
Gate binary missing or failing: Claude Code decides alone, prompts reappear. Fix: bin/configserver -apply.
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 as | Cause | What to do |
|---|---|---|
| Read-only probe | Compound with a state-changing part. | One command per call; read-only chains and one pipe of two allowed sides run. |
| Command, tool, domain | No 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 class | Lists 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. |
One home per rule.
| Home | Holds | Written by |
|---|---|---|
| settings/claude_code/components/gate.json | Global 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.json | Commands 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>.cue | A skill run's needs, e.g. Workflow (below). | The skill's author. |
| plans/…/permissions.json | One plan's implementation needs: Plan rules. | Frozen by its design run, applied by the gate. |
toolpolicy check [--skill <leaf>]… '<target>' … reports the gate's verdict per target; no ledger line.
| Rule | Detail |
|---|---|
| Accumulate | An active skill's grants hold for the rest of the session, subagents included. |
| Active | Typed 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. |
| Named | A /<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. |
| Chains | A 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 readers | Claude Code reads the field for the invoking turn only; the gate's reading counts. |
| Repo-scoped values | Deploys 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. |
Workflow | Claude 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. |
toolpolicy allow '<rule>' … appends to the anchor's settings.local.json. The agent never edits that file directly.
| Case | Answer |
|---|---|
| Rule not yet in the file | Needs your last #allow settings.local.json; a last #deny settings.local.json refuses. |
| Rule already present | APPLIED without either. |
| Undo | toolpolicy revert undoes the last apply. |
| Headless session | Never self-serves. |
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.
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.
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
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.
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.
/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 *)
Nothing waits on a freeze: neither smine-rules plan-sections nor any skill refuses an unfrozen plan.
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.
| Launcher | Marker |
|---|---|
RunCell and RunChat in internal/routinerun | set |
The two probe scripts in internal/server | set |
| A container cell | set by an explicit --env SMINE_HEADLESS=1 on docker run |
| The eval and smoke runner | reaches RunCell through internal/skilleval/arm.go |
| An attended fleet worker | off (see below) |
| In a cell | Rule |
|---|---|
| Same gate, same sources | Settings layers plus active skills' grants; the first prompt /<skill> … activates that skill. |
--allowedTools | The 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). |
| Resume | Sessions 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 run | Apply from the next call; settings load on every call. |
| No typed turn | A headless prompt never approves: toolpolicy allow, toolpolicy revert refused; no Workflow launch guard. |
| Behaviour | Attended session | Headless cell | Why | Decided in | Pinned by |
|---|---|---|---|---|---|
| Sound hooks | play | silent | parallel cells stacked their chimes | 2026-09-26-sound-hook-headless D1 | headless-env in TestRunCell, TestRunChat |
/fimplement plan hook | applies the plan's frozen record to the worktree | does nothing | a routine must not widen from a plan it wrote itself; headless never widens | 2026-09-28-permission-guard D9, 2026-10-01-permission-jailbreak-fix D6 | TestRunPlanHook_Headless, bypass case headless-applies-nothing |
| Freeze budget | one per /fdesign prompt you typed | one, when the first prompt is /fdesign | a nightly design run has no typed prompt | 2026-10-01-permission-jailbreak-fix D3 | headless-first-prompt-freezes, headless-first-prompt-other-skill |
| Freeze effect | record written, rules applied to the worktree | record written, rules not applied | headless never widens | 2026-10-01-permission-jailbreak-fix D20 | headless-first-prompt-freezes |
Decision rows: the archived plan dirs above, in ~/GolandProjects/agent-plans/claude-configs/.
Rules an unattended design run froze apply only when an attended session runs /fimplement on that plan. Deliberate, not a gap.
| Case | Rule |
|---|---|
| The marker is the test, not the mode | An unmarked claude -p is attended for all four rows. |
| Attended launch | smine-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 launch | A 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. |
CLI 2.1.282; cells started from a session's shell, unmarked, without --allowedTools.
| Probe | Result |
|---|---|
| SessionStart hooks | Run: 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 identity | Own 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 grant | A Bash command only the active skill grants: ran. |
| Rule added mid-run | A denied command ran once its rule entered the worktree's settings.local.json. |
| Gate-only rule | Under --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 cell | First prompt /fimplement <file>: got the tool-policy /fimplement hook: lines, plan path resolved. |
| Resume | claude -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 probed | Non-Bash tools without --allowedTools, launchd-started cells, resume from another working directory. |
Checks run in this order on every PreToolUse call; the first answer wins.
| # | Check | Then |
|---|---|---|
| 1 | ExitPlanMode? | The engine decides: plan approval stays yours. |
| 2 | Denied tool, or a deny rule matches? | Deny. |
| 3 | Breaks an invoked skill's enforce gate rule? | Deny. |
| 4 | Workflow with a marked non-primer script, or a launch after a typed turn outside a notification-opened turn? | Deny (launch guard below). |
| 5 | Agent without an explicit non-Fable model? | Deny. |
| 6 | Ungated built-in? | Allow. |
| 7 | Bash, PowerShell | Path fence → read-only? → one pipe? → settings rule → skill grant. A match allows, no match denies. |
| 7 | Read, Glob, Grep | Inside the read roots or a skill path grant. |
| 7 | Edit, Write, NotebookEdit | Inside the write roots and not protected. |
| 7 | Rule-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. |
| 7 | Unknown tool | Deny. |
| Piece | Rule |
|---|---|
| Permission requests | Claude 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-in | A 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 rules | Each 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 placement | SKILL.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 guard | The 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 reason | tool-policy: denied <tool> — <cause>. candidate rule: <rule>. plus steering (what works instead). |
| Ledger | One line per deny: anchor, scratchpad_dir, plus agent_id/agent_type in subagents. |
| Session anchor | The 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 roots | Anchor; 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 roots | Anchor, 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 app | The "+" 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 fence | Words 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 commands | cp, mkdir, mv, rm, rmdir, touch run ruleless once the fence places every path inside the write roots. |
| Confined sessions | Eval 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 session | Settings 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 covered | Code a command runs after launch (go test, a make recipe) keeps full filesystem access. |
| Rule | Detail |
|---|---|
| One rule admits every target | Every repo command is a Makefile target; Bash(make *) admits all. |
| Make sources are locked | Makefile, GNUmakefile, *.mk: write-locked until #allow Makefile. |
| Declared variables only | NAME=value only when the target's ## <target>: help line declares NAME; values free of shell metacharacters. |
| Denied flags | Those loading make code or overriding the environment: -f, -E, -I, -e. |
| Operator targets | A help line carrying [operator]: refused in a linked-worktree session, as is executing install.sh or a script under cmd/sync/ there. |
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.
| Line | Effect | Answer |
|---|---|---|
| #allow Makefile | Unlocks 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/dir | Appends 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 — … |
| #reset | One-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 rgate | Hook effects off for the session; #enable: back on. Grammar below. | tool-policy #disable: off — rgate, hb (from the next hook event on) |
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.
| Feature | Members | What it switches |
|---|---|---|
| rgate | global, 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. |
| skill | enforce, invokegate | The gates on skill runs. |
| inject | global, skill, acdsl, env, plan | Hook 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. |
| session | title | The session-title hook. |
| hb | (heartbeat spells it) | Heartbeat. |
| all | Everything. |
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 | Admits |
|---|---|
| Bash | Bare 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__tool | Every tool of the server, or one tool. |
| Read(~/x/**), Read(//abs/**), Read(@repos/**), Edit(…), Write(…) | Path rules: skill grants only, never from settings. |
No rule lifts these.
| Refused | Forms |
|---|---|
| Other global options | -c, --git-dir, --work-tree, --exec-path, --bare |
| Environment | GIT_*, 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 repos | safe.bareRepository explicit, set globally. |
Fail-closed: a command is read-only only when the analysis says so.
| Rule | Detail |
|---|---|
| The list | gate.read_only_commands: a program, or program plus subcommand (git status, go env). |
| Wrappers | gate.wrapper_commands (default command, env, nice, time, timeout, xargs), skipped to the wrapped command. |
| Disqualified | Non-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. |
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.
ccd_* approval cards.| Where | What |
|---|---|
| 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.* settings | The 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>.json | Self-service state and switches. |
| ~/.claude/tool-policy/frozen.jsonl | Frozen plan rules. |
| go test ./cmd/toolpolicy | Bash 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 log | The archived 2026-09-17-permission-closed-world plan dir and plans/2026-09-26-tool-policy-cleanup-wip-4ca7753/. |