Safety
What the permission rules in each seat's generated settings cover, and what they cannot do.
Each seat starts with a generated settings file, .state/<team>/settings/<role>.json, passed with
--settings and rewritten read-only on every up. It holds the team's permissions merged with the
role's (team first; a role can add or tighten a rule, never loosen one), plus what fabrikito adds:
- an
Editdeny on the files that would change a seat's next start or its live rules: the generated settings, launchers and prompts, the message log and the gate's state, the team file and its prompts, the launcher (fabrikito,lib/,roles/,plugin/), any plugin given with--plugin-dirinargs, and the claudewrapper(its whole.appbundle, if it is in one), the main checkout's.mcp.json,.claude/settings.json,.claude/settings.local.json,.claude/CLAUDE.md, and.claude/hooks/,rules/,skills/,agents/andcommands/(not the rootCLAUDE.md, which is project work), the seat's own.claude/settings.jsonandsettings.local.json, and Claude Code's user settings, MCP config, instructions, user-level rules (rules/) and extensions under~/.claudeand in~/.claude.json(and, whenCLAUDE_CONFIG_DIRis set whereupruns, under that folder too, with the.claude.jsoninside it; each seat's launch script pins the same value, so a seat uses a folder its rules protect, and the default folder stays protected for sessions started without the variable)..claude/worktrees/stays editable: Claude Code puts the worktrees it makes there, including those of a worktree seat's subagents, which land under the main checkout. A path that runs through a symlink is denied both as configured and as its real path, which stopsrmandmvof a protected link; repointing a link withln -sfis not a file command Claude Code checks, and is not stopped (checked on 2.1.316). The seat's own.mcp.jsonstays editable as project work; its servers normally need approval in a startup dialog, but with a user setting such asenableAllProjectMcpServerson they load unprompted at the next start; - an
askon any push to the base branch (fromrepo.base), forced or not, since changes land through PRs. It is an early warning for a project whose base branch has no protection. The real control is a ruleset on the base branch that blocks force-push and deletion and requires a PR; where one exists, this ask is redundant. It matches command text, not a repository, and it does not see a push from a clone of a repository listed inrepo.docs_repos, for any branch name. The clone is outside the seat's working directory, where acddoes not last to the next command, so the writer prompt has the seat write every git command there asgit -C <clone> ..., andgit -C <clone> pushis not matched (see the first item under "What the rules cannot do" below). The writer prompt is the only guard the team provides against a push to such a repository's default branch. The real control is the same as for the base: a ruleset on that repository's default branch that requires a PR. The merge denies and asks name no repository, so they cover agh pr mergein a docs repository clone as they do anywhere; - a
Bashdeny on a typedtmux kill-serverortmux kill-session(#308), for every seat: either one can end other seats, or every team on the machine, since teams share a tmux server. It is a deny and not an ask, because an ask parks a seat at a prompt until someone answers, and a deny answers at once in every mode. The 24 patterns are the command alone, with arguments after it (-t), with options before it (-S,-L), and each of those behindenv. Every pattern starts at the command word, so agrepfor these words, or a commit message that names them, matches no rule. What a seat uses instead:test/live-env.sh down <dir>stops a test server (see Live tests), andtest/live-env.sh run <dir> tmux ...reaches one;fabrikito downstops a team; anything on a running team goes to the EM. Those, and the e2e test, make their tmux calls from scripts, which a Bash rule does not see, so they work as before. A team file that has anaskor anallowon one of the same patterns is refused at load time, as for any rule a stronger one makes useless: remove it; - an
Editallow on the seat's own progress note (see Progress notes), which is outside its working directory and would otherwise prompt in a mode other than bypass, and anEditdeny on every other seat's note: a note is put into its seat's context at each start, so a seat that could write another's would have a lasting channel into it; - a
Readdeny on the message log,.state/<team>/messages.log(both spellings, when a symlink makes them differ), and on thesay/folder next to it (whatsaykeeps of a message it has pasted and not yet submitted, #212), for every seat but the role with idem: the log holds everySendMessagein full. A team with noemrole has the deny on every seat. This is an accident guard, not a boundary: every seat runs as the same OS user (#29), and a command that reads the file some other way is not stopped; - rules on the security-triage record folder,
.state/<team>/triage/(#138), where thefabrikito:security-triageskill hassecwrite its records. A record can hold an unfixed medium-or-higher finding. The folder is outside every working directory, so without a rule each write and read would prompt in a mode other than bypass. The role with idsecgets anEditand aReadallow ontriage/**, the role with idemaReadallow (and anEditdeny), and every other seat anEditand aReaddeny, all in both spellings.upmakes the folder (mode 700, set on an existing folder too) when the team has asecrole; nothing deletes a record. The role id is fixed: a team whose security seat has another id gets no record folder, every seat butemis denied it, andcheckwarns about a role other thansecthat lists the skill. Like the log's, these denies are an accident guard, not a boundary. As with any settings change, asecseat that is already running whenupwrites these rules keeps its old settings until it restarts; - denies on other teams' state folders (#197), when more than one team runs on the machine. For each
other team's folder, every seat gets a
Readdeny on itstriage/**and anEditdeny on all of it, plus aReaddeny on itsmessages.logandnotes/**unless the seat's role observes that team. All are written in both spellings. The other teams' folders come from two places: - Found: every real folder beside this team's own in the state root (
.state/) that holdssettings/,launch/andprompts/. A symlink there is never followed.checknames the links it skips: list one underother_teamsif it is another team's state. -
Listed:
other_teamsin the team file, for a state folder elsewhere (a pinned team's, see Pinned releases), each with itsobservers. These are the roles of this team the owner allows to read that team's log and notes, never its triage records:yaml other_teams: - { state: /path/to/other/state, observers: [qa2] }
A listed folder may not be, hold or sit inside this team's own state folder, its repository, its
worktree folder or a seat's working directory, whether or not it exists yet. The one exception is a
team's state folder whose parent is a real folder named .state, which may sit inside them once that
team has run up (a pinned team working on fabrikito lists <install>/.state/<team>). If
it exists, it must be a team's state folder itself (holding settings/, launch/ and prompts/),
not its checkout or .state/ above it. Outside them, one that doesn't exist yet, or is still empty,
is accepted, and check says so. A folder that holds only a .DS_Store file counts as empty; a
folder or a link of that name is content (#229). check lists the
other teams found and listed, and each seat's denies. Settings are written at up, so a team that starts
after a seat does is covered from that seat's next restart. Teams on one machine still share the
machine user, so these denies are an accident guard, like the others: a command that reads or
writes another way is not stopped. They also share the tmux server and the shared claude
executable the copies come from.
An observer may read the observed team's message log, which records that team's messages in full
except those to or from sec (their body is not logged, #227), so it can still
carry the private reports its seats send to the EM: an unfixed security point among them, before it is
rated (#222). An observer treats everything in that log
under rule 5 of its prompt (roles/common.md): it never quotes the log anywhere,
and sends sec and the EM only a pointer to what it saw: the team and the time of the log line, and
its line number when several lines share that time.
permission_mode (in sessions.claude, or per role) picks the mode; the example uses auto.
bypassPermissions skips every prompt and is meant for an isolated machine only. Deny rules hold in
every mode, bypass included, and ask rules still prompt there, so a seat that hits one waits until
someone answers. say will not deliver to a seat at a permission prompt or any other dialog (see
say): attach and answer it yourself. These rules add to the ones in ~/.claude/settings.json
and the repo's .claude/ settings, which still apply. check shows each seat's mode and rules, and is
where load-time errors (unknown modes, malformed rules, a permission flag in args) are reported.
It also warns, without stopping a start, about seats whose modes would hold messages between them
(Which messages are held), and about any role whose rules have neither a
deny nor an ask on each of the merge patterns, unless that role is marked may_merge: true (a YAML
boolean, so an unquoted yes or on also counts as true). The patterns, the ones teams/example.yaml
denies or asks on (#85):
Bash(gh pr merge *):gh pr merge, with or without arguments.Bash(gh pr -* merge *)andBash(gh pr -* merge): the same with a flag before the subcommand (gh pr -R o/r merge 5,--repo o/r,--repo=o/r). The space aftermergekeepsgh pr -R o/r view 5 --json mergeableout.Bash(gh api *pulls/*/merge*)andBash(gh api graphql*mergePullRequest*): the REST and GraphQL merge.Bash(gh api graphql*enablePullRequestAutoMerge*): auto-merge.Bash(gh api graphql*mergeBranch*)andBash(gh api *repos/*/merges*): merging a branch without a PR.
A team file written before these were added gets the warning until it has them. Known false matches:
a gh pr -R o/r <subcommand> whose later text has merge in it, and gh api reads whose path or
query names these. Not covered: a GraphQL query read from a file or stdin (-F query=@q.graphql,
--input), curl or other HTTP clients against the API, gh aliases, extensions and scripts, a
git push of a merge commit to the base branch (the base-branch push ask covers that), and other
hosts' CLIs.
Claude Code's auto memory is off in every seat: the generated settings set autoMemoryEnabled: false
and CLAUDE_CODE_DISABLE_AUTO_MEMORY=1, and the launch script exports the variable. Auto memory lives
in one folder per repository (<config>/projects/<repo>/memory/), shared by all its worktrees, and its
MEMORY.md loads into every session there, the owner's own in the main checkout included
(docs). A seat's note there would reach every
other seat and the owner without review, outside the PR process. Set auto_memory: true under
sessions.claude, or on a role, to leave it to Claude Code's own settings (that seat's launch script
unsets the variable, so it is not inherited from a tmux server started inside a seat). check shows it per seat
in the MEMORY column.
Turning auto memory off stops a seat from writing memory by itself, but not from editing that folder
with its file tools. So every seat with auto memory off also gets an Edit deny (which covers the
Write tool too) on the repository's auto-memory folder, in every config folder and both spellings of
the main checkout's path (#108). Claude Code names the folder after that path, with
every non-alphanumeric character turned into -; a name over 200 characters is matched by its first
200 and a *, because Claude Code appends a hash to it. As with the other denies, Bash redirects, tee
and cp into the folder are stopped too, and a script that opens the file itself is not (checked on
Claude Code 2.1.314). check names the deny in each seat's added: line. The deny covers only that
default folder: if you set autoMemoryDirectory in your settings, your sessions load memory from the
folder it names, and the deny keeps protecting a folder nobody reads, with no warning.
These rules are an accident guard, not a security boundary: every seat runs as the owner's OS user, with the owner's GitHub credential, and can change what loads. #29 tracks making them a boundary.
What the rules cannot do:
- A Bash rule matches the command text. The same program under another path (
/usr/bin/gh), insidesh -c, in a script, or reached another way is not matched: for examplegit -C <dir> push, a baregit pushwhile on the base branch, orgh apiwith a PATCH or DELETE ongit/refs/heads/<base>. - A
Readdeny covers Claude's file tools and the file commands it recognises in Bash (cat,head,tail,sed,tee, redirects). It does not covergrep -rover a folder,git showof a committed file, or a Python or Node script that opens the file itself. AReaddeny on.envfiles keeps a seat from opening one by accident; it does not keep a value out of its reach (the Read tool andcatwere refused, whilegit showand a recursive grep returned a dummy value, on Claude Code 2.1.316, #248). Keep real credentials out of the repository and the worktrees. - The protected-path
Editdenies stop the file tools, redirects, andcp,mvandrmwith a protected file as the target (checked on Claude Code 2.1.316). They match the command text Claude writes, so they do not stop the same command run through another shell or a script (sh -c 'cp …', which qa checked gets through), or a program that writes the file itself, such as a Python or Node script. - The deny on
tmux kill-serverandtmux kill-sessioncatches the typed command and nothing else. It does not catch a script that runs one (a straykill-serverin a test script is what stopped every seat of two teams on 2026-11-10),sh -c, tmux under another path, tmux's own short forms (tmux kill-ser), other commands that end a session (kill-window,kill-pane,respawn-pane -k), or a signal to the tmux process itself (pkill tmux,killall tmux,kill $(pgrep tmux)), which ends the same server. It is a guard against the accident of typing one. The boundary is elsewhere: a tmux server per team (#305), and tests whose tmux calls can reach only their own server (#306, #307). It also has false matches: any typed tmux command with one of the two words as an argument of its own after the command, such as asend-keysof that text, is denied too. - A subagent a seat starts with worktree isolation did not come under the
.claude/settings.jsonin its worktree: it behaved as its parent session did (one check on Claude Code 2.1.316, #53: a headlessclaude -psession in bypass mode in a throwaway repository, oneBashdeny in the worktree's file). The subagent's matching command ran with no denial, and the parent session had no deny loaded. So a deny written into a worktree's settings did not bind such a subagent. The reverse was not checked: that a deny in the seat's own settings refuses the same command in its subagent. Two more paths were not checked:EnterWorktree, which moves the session itself into a worktree, and a separateclaudeprocess a seat starts from Bash. That process loads the project settings of its own folder and does not get the seat's generated--settingsfile at all, which is the general limit above: the rules guard against accidents. Severity as sec rated it: low (the checked path is ruled out; the unchecked ones fall under the general limit above). - The hard rules are prompt text, and the shared prompt treats a subagent as knowing only its brief. It tells a seat to put into the brief the rules that apply to the subagent's work (the hard rules, what is out of bounds, where temp files go), and that the seat answers for what the subagent does (#246). The launcher does not check a brief.
- "Never force-push a branch you did not create" cannot be written as a pattern. Engineers keep force-pushing their own branches; only the EM is asked.
- Any process running as this user can type into a seat's pane, so an ask rule guards against accidents. It is not a gate that only the owner can pass.
- Seats act with the owner's GitHub rights, so they can change a ruleset or branch protection through the API. No rule here stops that (an ask that matched it would also stall seats reading the settings); #29 tracks the fix.
- Settings above these files apply as well: managed settings, and, depending on the account, installed mods, which can approve calls these rules would stop.
- Paths in Read and Edit rules:
//pathis absolute,~/pathis under the home folder,./pathor a bare name is relative to the seat's folder. A single leading/anchors at the folder that holds the generated file, so it is refused. - Claude Code matches permission rules against each part of a compound command (
;,&&,|), but against the whole command text when it can't parse the command (for example"$(...)"in an argument, such as a PR body from$(cat <<EOF ...), or${...}next to quotes). In default mode, a subshell( ... )or a command it can't parse prompts even when no rule matches (the reason varies: "can't be checked before it runs", "shell operators that require approval"); in bypass mode it prompts only when a rule matches (measured on 2.1.316, #103; other modes not measured). So seats rungit pushas its own simple command: chained with an unparseable part, amainelsewhere in the command makes it match the base-branch ask. A prompt that names a rule (Permission rule ... requires confirmation) means the command's text matched an ask rule, even when the reason it shows is about parsing ("expansion obfuscation", "shell operators that require approval"): that reason only says why Claude Code could not narrow the prompt to the matching part. The shared prompt keeps the instructions (push alone, avoid a subshell, a named rule means a match) and leaves this mechanism here (#117). - Seats push with
git push origin <branch>:<branch>on a branch cut with--no-track. A branch cut withgit switch -c <branch> origin/mainhas the base as its upstream, so underpush.default=upstreama baregit push, or evengit push origin <branch>, would push to the base.
The sandbox is the OS-level control that would hold
whatever the command text, but the launcher does not turn it on: seats run without one unless settings
that apply to them enable it. The hard rules in the shared prompt (roles/common.md) still apply;
read them before changing them. A seat started in a folder Claude Code has not seen stops at a trust
dialog until a human answers it once; up reports which seats those are.