repolane v0.11.0 · open source

Guardrails

Security model

The threat model, what the guard checks and in which tools, how it fails, and where it stops. A guardrail you can check, not a sandbox.

On this page

Most of this documentation tells you what Repolane refuses. This page tells you how the refusals work, what has to be true for them to work, and where they stop being true. Each limit stated here was found by feeding the guard crafted tool calls in a scratch setup, by running real Claude Code against one, or by reading guard.py. A statement that comes from a live run says so and gives the Claude Code version (2.1.299, headless claude -p).

The guard is a guardrail, not a sandbox. It is a strong default against an agent doing the wrong thing by accident or by overreach, and it is built so that you can check it is still in force. It is not operating-system-level isolation, it is not process isolation, and it should not be described as containment, by us or by you. If you need containment, run the whole thing in a VM or a container. Nothing below is a reason not to use it; it is the shape of what you are getting.

In brief. The guard is a hook process that judges each tool call a Claude session makes, before it runs. It refuses by name, by path and by git rule; it is not a sandbox. PreToolUse fails closed and every other hook fails open (how it fails). The guard cannot see inside a script run by name (the full list). lane doctor tells you whether the rules are in force.

Use with care

Claude and other AI models can make mistakes: they can edit the wrong file, run the wrong command or misread a rule. Repolane’s hooks reduce that risk but cannot remove it, and they are a guardrail, not a sandbox. Review what Claude changes, push only what you have read, and keep credentials you cannot afford to lose out of reach. You are responsible for what runs on your machine and in your repositories. Repolane is provided “as is”, without warranty, under the Apache License 2.0.

Threat model

In scope: what it is meant to reduceOut of scope: what it does not try to do
A session reading or editing another lane, the base clones, or an adopted old checkoutStopping a determined adversary, or untrusted code you ask Claude to run
A session opening .env-style files and credentials whose names it recognises, and putting values into a transcriptFinding secrets by content: a secret in config/production.conf is an ordinary file
A session pushing to main, master, develop or a repo’s own integration branch without you seeing itControlling network egress: curl, git push <url>, package installs and scripts are not inspected
A session rewriting the workspace’s settings, rules, registry or memory, or the installed package, from inside a laneDefending against you: you can edit the hooks, the settings or the guard yourself
Memory being written without you choosing a scopePreventing prompt injection (see below)

Prompt injection. The guard does not prevent it and does not claim to. It does not read what the model reads, and it cannot tell an instruction you gave from one that arrived in a file, a web page or a tool result. What it can do is deny some actions a hijacked session might attempt, when those actions are among the ones it checks. Anything the hook injects into a session is itself text the model reads: the lane file, each repo’s own CLAUDE.md, .repolane/knowledge/preferences.md and every file in .repolane/knowledge/shared/. Treat those as instructions you have chosen to trust; see Extending.

What the mechanism is

Repolane registers hooks. Before Claude Code runs a tool call it runs lane hook pre-tool-use and sends it the call, as JSON, on stdin; that command runs the guard (scripts/hooks/guard.py in the installed package). The guard prints a decision on stdout and exits.

AnswerMeaning
deny + reasonthe call is refused; Claude receives the reason as a tool result
allowthe call proceeds without Claude Code asking you: the hook’s answer short-circuits the permission prompt. Observed in a live run (Claude Code 2.1.299, headless): in-lane Read and Edit ran with nothing on the allow-list
nothing (no decision)the call proceeds under Claude Code’s normal permission flow, which may still ask you

A rule in CLAUDE.md or a system prompt is input to the model. It competes with everything else in the context window, degrades under compaction, and can be argued with. A hook is a separate process run by the harness that sees the actual arguments of the actual call. The model is not the thing being asked. That is the whole design, and it is also the exact limit of the guarantee: the hook governs the tool calls it is shown, and nothing else.

Installing and registering the hooks

lane init adds Repolane’s hooks to the workspace’s .claude/settings.json, keeping your own keys and hooks, and Claude Code applies a project’s settings when it is started inside that folder, which is why a session at the workspace root is guarded. lane start and lane resume write the same wiring into .claude/settings.local.json at the lane root and in each worktree, because a session opened in a lane starts from there. Every hook is a command, lane hook <event>, not a path into the installed package, so an upgrade does not break the wiring, but lane must be on the PATH Claude Code runs hooks with. If it is not, the hook command cannot start and Claude Code runs the tool unguarded; lane doctor checks this first and says so loudly. lane upgrade refreshes Repolane’s hooks in every workspace, and lane resume <id> repairs a lane whose wiring went missing.

The hook events

.claude/settings.json at the workspace root and the settings.local.json that lane start and lane resume write into every lane and worktree register the same eight events, each as lane hook <event>.

EventMatcherDoesOn failure
PreToolUseBash, Edit, Write, MultiEdit, NotebookEdit, Read, EnterWorktree, Glob, Grepthe only event that refuses tool calls; also injects the repo’s rules the first time a session touches itfails closed: input it cannot make sense of, or an internal error, is answered with a deny that says why
PostToolUseEdit, Write, MultiEdit, NotebookEdit, EnterWorktreeappends the edited path to lanes/<id>/.touched; after EnterWorktree, hands the session .repolane/LANE.mdfails open: output is unused
UserPromptSubmitnone (every prompt)injects the lane’s state line per repo; on the first prompt, the memory indexesfails open: proceeds without the injection
PreCompactnonein a lane, re-states the rules and the lane spec so compaction keeps them; always clears the per-session cache so the memory and rules briefs inject againfails open
SessionStartstartup|resume|clear|compact|forkprints the board or the lane brief into the session, names the owner, and in a lane adds .repolane/LANE.mdfails open
SessionEndnoneappends the log line to the lane file (a line identical to the last one apart from its timestamp is skipped); tidies duplicate transcriptsfails open
WorktreeCreatenoneinterprets EnterWorktree name="<id>" as “resume this lane”exit 1: creation fails
WorktreeRemovenonealways refuses: worktrees are removed by lane doneexit 1: removal refused

Every event runs the guard with a 30-second timeout, except WorktreeCreate (120 s). lane hook session-start runs the session-start script instead, with a 60-second timeout. Only PreToolUse can refuse an ordinary tool call. Everything else adds context or keeps records.

What it depends on to work at all

Three things, each of which can be false.

1. The hook config is present, and lane is on the hooks’ PATH. Hooks are configuration. The workspace root carries them in .claude/settings.json; every lane and worktree carries a generated .claude/settings.local.json. That file is per-lane state, merged into whatever was there, kept out of git. A session cannot write it (see self-protection), but you can, and so can any tool that rewrites it. A lane whose settings lost its hooks is unguarded while guard.py is healthy, and so is every session when the lane the hooks call cannot be found. lane doctor checks that lane on PATH is the one the hooks will run, walks every lane and worktree and names the ones that do not point at the guard; lane resume <id> rewrites them.

2. Claude Code keeps routing the relevant calls through the hook. The matcher lists the tools the guard is asked about, and every tool not listed never reaches it; the next section lists them. A harness that renames a tool, adds one that reaches the filesystem, or changes the hook payload does not break the guard loudly: it makes it quietly narrower.

3. The guard recognises the shape of what it is looking at. File tools are easy: it has a path and judges it. Shell commands are not. The guard reads a command with a small shell reader (quotes, $( ), backticks, ( ), redirects, heredocs, bash -c, eval) and judges words, not a pattern over the raw line. It is a reader and not a shell: where the meaning of a command depends on something the reader cannot see, it either refuses (fails closed) or cannot know, and both are listed below.

A second layer, with its own limits

lane start and lane resume also write permissions.deny entries into each lane’s settings, which Claude Code itself enforces: Read and Edit denials for .repolane/repos/**, for every other lane, and for the secret names **/.env, **/.env.local, **/.env.*.local, **/.env.development, **/.env.production, **/.env.staging, **/.env.test, **/*.pem, **/*.key, **/*.p12, **/*.pfx, **/*.p8, **/*.jks, **/*.keystore, **/*.kdbx, **/id_rsa, **/id_ed25519, **/id_ecdsa, **/id_dsa, **/.netrc, **/.npmrc, **/.pgpass, **/.git-credentials, **/.htpasswd and **/service-account*.json. That list is not identical to the guard’s: it has no entry for *credentials*.json or secrets.*, or for a .env.<name> that is not in the list, and it only covers Read and Edit. The entries use absolute paths, so moving the workspace folder breaks the wiring until lane resume <id> rewrites it.

So reading a .env has two layers, and they are independent. In live runs (Claude Code 2.1.299, headless) a Read of lanes/<id>/api/.env was refused by Claude Code’s own permissions.deny, with no guard output at all, and a Bash cat api/.env was refused by the guard. Two independent mechanisms refusing the same thing is a good property; both are configuration in files that have to be present.

Which tool calls the guard sees

ToolReaches the guard?Checks that apply
Bashyespath rules, secret names, git and gh rules, cd rules, memory gate, workspace commands, project rules
Read, Edit, Write, MultiEdit, NotebookEdityespath rules, secret names, workspace and memory gates, project rules
EnterWorktreeyesone way into a lane only: name="<lane-id>", never a path
Grepyesthe secret rule: a folder that holds a secret may be searched only with a glob that leaves it out, plus the path rules. Observed in live runs (Claude Code 2.1.299), including when a subagent made the call
Globyesthe path rules. It lists names only, like ls, so it is not secret-checked
ExitWorktreenonone
WebFetch, WebSearchnonone
the Agent / Task tool call itselfno (observed: no hook decision)none. A subagent’s own tool calls are a separate matter, next row
a subagent’s own Bash, Read, Edit, Write, Grep callsyes (observed on Claude Code 2.1.299; Edit and Write by the same matcher, not separately exercised)the same checks as any other call, with the lane’s settings. A subagent’s Read and Bash of a file outside the workspace were denied; its Grep over a folder holding a .env was denied with the Grep text
MCP toolsnonone
any tool Claude Code adds laternonone

Not tested: a subagent calling Glob, WebFetch or an MCP tool (WebFetch and MCP tools are outside the matcher whoever calls them; Glob is only path-checked), and any subagent behaviour on a Claude Code version other than 2.1.299.

Keeping every lane’s wiring current

A lane’s settings.local.json holds the tool matcher, the deny list and the hook commands it was written with. lane start, resume, done, merge and lane ref rewrite the settings of every lane, not only the one named, and lane upgrade rewrites them in every workspace. lane resume <id> rewrites one lane on purpose, and lane doctor flags a lane whose settings do not run the current hooks or whose PreToolUse matcher lacks Glob or Grep, and does the same for the workspace’s own .claude/settings.json.

What each check actually does

Secret files

The rule is name-based, not content-based. The file tools and the shell use one list. The same pattern is written in three places in the package (the guard, the git-state helper and the board’s server), and the product’s test suite fails if the three ever differ.

Names recognised, case-insensitively
.env and .env.*
id_rsa, id_ed25519, id_ecdsa, id_dsa
.netrc, .npmrc, .pgpass, .git-credentials, .htpasswd
*.pem, *.key, *.p12, *.pfx, *.p8, *.jks, *.keystore, *.kdbx
*credentials*.json, service-account*.json
secrets.json, secrets.yaml, secrets.yml, secrets.toml (and secret. + the same extensions)

A .example, .sample, .template or .dist suffix is exempt. Not recognised by any tool: .envrc, *.tfvars, *.tfstate, .aws/credentials. They are ordinary files to the guard unless you add a rule with lane secrets <repo> add <path> (see below).

What the Bash rule does. It is deny-by-default once a name is recognised. A short allow-list of commands that only touch metadata (ls, stat, wc, find, lane-env-keys and a few more) is matched as the exact command word, and only without the options that turn them into readers (file -f, wc --files0-from, find -exec). Anything else naming a secret path is refused, including a cat .env hiding inside $( ); file -f .env, wc --files0-from=.env and ls $(cat .env) are refused.

What counts as a name is read after the shell would have read it: a variable set earlier in the same command (f=.en; cat ${f}v), brace expansion (.{env,x}), $'\x2eenv', and what $(echo .env) prints. A list of names piped into a reader (ls .env | xargs cat, find . -name '*.pem' | xargs cat) is judged as if typed after cat. A .env.example that is a symlink to .env is the secret, not the template. A lane reference (lanes/<id>/refs/…) is secret-checked first.

Text that merely mentions a name reads nothing and passes: what echo and printf print, a commit message (-m, -am), a gh --body or --title, a grep pattern, an --exclude=, -g '!…' or find -name pattern, a heredoc body. The one copy of a secret that is allowed is the onboarding step cp .env.example .env: only a source that is a template by name and by where it leads, copied to a place inside this lane. git rm --cached, git check-ignore and git ls-files take names and open nothing, so they pass.

A whole folder is a read of everything in it. grep -r, rg --hidden, tar c, zip -r, cp -r, rsync, scp -r, diff -r and find … -exec <reader> are refused over a folder that holds a secret unless they exclude it explicitly (--exclude='.env*', -x, ! -name, rg -g '!…') or can only reach files that cannot be one (--include='*.py', -t py). The Grep tool is judged the same way: a glob that leaves the secret out ("!.env*"), a glob that cannot match it ("*.py"), or a type. A copy that lands inside the lane is fine. .git, node_modules and virtualenvs are not searched for secrets (a package’s own test keys are not yours), and a folder with more than 100,000 files is refused as too big to vouch for. rg and ag skip hidden files by default, so a .env does not trip them unless --hidden or -uu is given; a non-hidden server.pem does. See what you may notice for the commands that need an exclude.

Program text is searched, not parsed. python3 -c, node -e, ruby -e, perl -e, and anything fed to an interpreter on its stdin: a secret’s name, the common ways of assembling one ('.'+'env', chr(46), fromCharCode, base64) next to a file read, a glob that names one, a path outside the workspace, and, if the program writes at all, a path into the workspace’s own files are refused. It cannot see a name computed from arbitrary data. That is a limit of the approach, not something it pretends to cover.

The sanctioned way to list key names. Inside a session the guard lets the bare word lane-env-keys (or lane-env-check) name a secret file; it prints variable names, never values. The package installs lane-env-keys as lane keys and lane-env-check as lane env. From your own terminal lane keys <path> works; inside a session lane keys <path> is refused (it names a secret and lane is not the bare word).

A symlinked .env in a lane. lane start links a repo’s env files into the lane from the read-only mirror, so writing or removing that link would change the mirror. The refusal says so: the path is a symlink into the mirror, and the way to a local, writable file is rm <link> (removes only the link), then cp .env.example .env, then ask the owner for values.

Secret rules you add are filename-wide

lane secrets <repo> add <path> writes .repolane/registry/rules.yaml entries for Read, Edit, Write, MultiEdit, NotebookEdit and Bash, matching on the name you gave. The message says “for this repo”, but the match is on the file name wherever it appears: after lane secrets api add service-account.json, Read of web/service-account.json is refused too. The Bash rule is a regex over the whole command, so even ls web/service-account.json is refused. A glob that does not match your pattern (cat web/service-acc*) still gets past it, and the Grep and Glob tools are not among the tools a project rule can match, so a Grep over the folder is not refused by the rule. (service-account*.json is a built-in name, so that particular example is covered without a rule.) A project rules file is protected from a session in a lane (see self-protection).

Paths: the lane boundary

Paths are judged as written, and again where their symlinks lead. The program being run is not followed through its links, so a .venv/bin/python that points into a version manager still works.

Aimed atResult
another lane’s folder (file tool, or a shell command that names it)refused
.repolane/repos/… (the base clones): a file-tool Read or Edit from a lane, or a write to one from anywhererefused. A shell read (cat .repolane/repos/web/README.md) passes
an adopted old checkoutrefused (read, edit, or any shell mention)
the workspace’s own files: .repolane/ (except the lane’s own spec, and in the cache the memory inbox and the nx build cache), the files at its root, every .claude/settings*, and the installed package, from inside a lanewrites refused, whether by a file tool, a redirect, or a link that leads there. Reads get no decision and pass
.repolane/memory/ and .repolane/knowledge/, from inside a lanewrites refused (directly written memory is gated)
a path under your home directory, outside the workspace, and your home directory itself (~, $HOME, ${HOME} are expanded), and another account’s home (~user)refused, unless it is a lane reference
/tmp, /private/tmp, and any path outside your home directory (/etc, /var)not judged: the guard gives no decision, so Claude Code’s own permissions apply. System paths stay readable

rm -rf ~, grep -r token ~, cat $HOME/.ssh/id_rsa and a link inside a lane that leads to a file elsewhere under your home are refused. What is looser than the sentence “nothing outside this folder is readable”:

  • /tmp and everything outside your home directory is not judged. Read /etc/hosts and cat /etc/passwd get no guard decision and pass.
  • A path the reader cannot resolve is judged by the part it can read (<workspace>/.repolane/$f is a path into the workspace’s own files), and a variable it cannot read at all is “not seen” (see the list below).
  • A cd is a door, and the working directory is simulated. In Claude Code a cd moves the session. Every directory a command would enter (cd, pushd, builtin cd, \cd, { cd x; }, eval cd x, a bare cd) is resolved from where the previous statement left the shell and judged like EnterWorktree: cd ..; cd ..; cd .. leaves a lane on the second step, and cd app && cat ../../../../x is judged from app. What cannot be followed is widened or refused, never ignored: a cd to a computed target (cd $x) leaves the directory unknown (and is refused inside a lane); a cd inside a loop or a function is refused inside a lane; one after ||, in an if or while body or a pipeline keeps both directories; CDPATH is read. A cd inside ( ), $( ) or bash -c moves nothing outside its own process. In a live trial (Claude Code 2.1.299, headless) Claude Code itself reset the directory after pushd and blocked builtin cd /tmp (“may only change directories to the allowed working directories”), so that layer agrees; it was not tested when the target is inside the session’s additional directories.
  • The other lane-control commands are judged by lane. From inside lane L1, lane-done L3, lane-park L3, lane-resume L3, lane-with L3 and lane-ref L3 add|rm are refused. They act only on the lane you are in. From the workspace root they pass.
  • A lane reference is a way out you chose. In a Claude session lane-ref <id> add <path> refuses /, your home directory, an ancestor of it or of the workspace, system and credential directories (.ssh, .aws, .gnupg, .kube and similar) and secret files. Once attached, file tools get allow on everything under it, except a secret by name (that check runs first). Do not attach a folder that holds credentials. The board’s refs/attach route can attach a path you give it (see the board).

For file tools the guard answers allow when the path is inside the lane (and for an attached reference: a read of any reference, an edit of one attached with --rw). That means Claude Code does not ask you about those calls; the hook has already answered. If you rely on your own permission settings to prompt for edits inside a lane, they will not.

Self-protection, and where it stops

Inside a lane the guard refuses file-tool and shell writes to the workspace’s own files: everything under .repolane/ except the lane’s own spec (so registry/rules.yaml, rules.shared.yaml, config.yml, env.sh, scripts/local/ and the rule files are protected) and, in the cache, the memory inbox and the nx build cache; every file at the workspace root, CLAUDE.md included; everything under .claude/ (including a lane’s or worktree’s own settings.local.json, by a file tool or by a relative path in a shell command); and the installed package. git -C <workspace> checkout|reset|clean|apply and rm -rf <workspace> are writes to it. The product’s guard suite covers all of this.

What stays open:

  • From the workspace root nothing here applies. At the root there is no lane, so there is no lane to protect the workspace from. That is the point: the root is where you maintain the system.
  • The guard judges the command, not the program it starts. A script that is run by name can do what the line cannot (see below).
  • A session that disarms its own hooks is not hidden: lane doctor reports lanes that do not point at the guard. lane doctor does not notice a deleted .repolane/registry/rules.yaml; the file is protected from a lane session, so that is a matter for you rather than the session.

Pre-approved commands

Claude Code’s own permission system lets a project pre-approve commands, so they run without a prompt. Repolane writes such a list into .claude/settings.json and into every lane’s settings.

  • The list is short. A lane’s settings.local.json carries thirteen commands, each written twice (by its bare name and by its path in the installed package): lane-merge-plan, lane-status, lane-brief, lane-find, lane-menu, lane-sessions, lane-env-check, lane-env-keys, and lane-memory list, show, audit, draft and goal. draft and goal are there because a lane session cannot use Edit or Write on the lane file or the memory folders, so it writes them through these commands; draft only writes into the memory inbox and goal only the lane’s own ## Goal. The lane also gets Read of .repolane/memory/, .repolane/knowledge/ and the memory inbox, and Read and Edit of its own lane file. The workspace’s own .claude/settings.json carries fourteen: lane brief, lane status, and the lane-* commands above without lane-find. draft and goal are there too, because a session that started at the root and then entered a lane keeps the root’s settings. The other lane-memory verbs (file, rm, move, drop, index) and lane-workspace prompt, because they delete, move or rewrite files.
  • The list only applies after you have accepted Claude Code’s workspace trust dialog. In a headless run on Claude Code 2.1.299 in a workspace that had not accepted it, Claude Code printed “Ignoring N permissions.allow entries from .claude/settings.json: this workspace has not been trusted” and none of the list applied. Interactively the dialog appears first. The dialog path was not exercised in the trials.
  • The guard exempts a narrow set of lane-* commands from its path rules. A command is trusted when every statement in it, including any inside a $( ), is one of Repolane’s own lane-* commands and nothing redirects except to /dev/null; the reasoning is that those commands validate their own arguments. Only a command the installed package really has counts: a bare name it provides, or a path that leads into it. /tmp/lane-evil and a repo’s own scripts/lane-evil are just other commands. lane-run and lane-gh are not exempt: they carry someone else’s command, and it is judged as if typed directly.
  • lane-memory validates its names. A slug, repo name, lane id or memory name must be [A-Za-z0-9._-]+ (not . or ..); it also refuses to write through a symlinked memory folder, so lane-memory drop ../../../home/Documents/notes is rejected and file cannot move a .md file in from outside. The product’s memory suite covers it, and the board’s /api/memory enforces the same names before it calls lane-memory.

Rules about memory scopes (Rules) are instructions: the guard blocks a direct write to .repolane/memory/, and lane-memory is the approved way in. That the scope question is asked is a convention.

Git and GitHub

git push is read, not pattern-matched: the guard judges the refspecs. These are all refused when they reach a protected branch: git push origin main;, main&&…, +main, 'main', HEAD:refs/heads/main, :main, --delete main, --all, --mirror, and a bare git push while a protected branch is checked out, whether the line is bash -c, eval, a heredoc, xargs, an env -S string or reached through a git -c. Protected is main, master, develop, the repo’s default_branch/compare_branch, and the branches lane sync recorded from real GitHub protection.

RefusedNot refused
a push that reaches a protected branch, in any spelling above; a force-push (-f, --force, --force-with-lease, +) of onegit push and git push origin <your-lane-branch>, with or without -u; a force-push of an unprotected branch (git push --force origin feat)
git remote add|set-url|remove|rename|prune, git config remote.*.url|pushurl, git config url.<x>.insteadOfgit fetch, git remote -v
git branch -D|-d|-f|-m|-M|-c that deletes, renames, overwrites or copies over a protected branch; update-ref and symbolic-ref on onegit branch -d feature, git branch -m old new, git branch -v
git stash unless it is a named git stash push -m <name>; stash pop|apply|drop|clear|branch (the stash stack is shared across worktrees)git stash list
git -c alias.*, core.hooksPath, include.path, GIT_CONFIG_*, GIT_EXEC_PATH, GIT_TEMPLATE_DIR; git config setting any of those or any setting that holds a program (core.editor, core.pager, core.sshCommand, credential.helper…); writes to .git/config or .git/hooksGIT_EDITOR=true git rebase --continue (a program given for one call is read as the shell code it is)
an unknown git <word> that names a protected branch (it may be a user alias for push): git x origin maingit lg, git co feature
gh repo delete|archive|rename|transfer; gh api with a body, any non-GET method (lower-case, quoted, -XPUT, --method=PUT, an implicit POST via -f/-F/--input); gh pr merge|close; gh secret|variable set|delete; gh alias set; gh extension install|exec; gh ssh-key|gpg-key add; gh release|issue|label|run|cache delete; gh workflow disable; gh repo edit --visibility|--default-branch|--name; gh auth tokengh pr create, gh pr view, gh pr checks, a GET gh api, gh repo edit --delete-branch-on-merge
git commit, checkout, reset, merge, rebase and friends inside a base clonethe same inside a lane (asking before committing is a convention, see Rules); git reset --hard and git clean -fdx in your own lane worktree are your own work

ALLOW_DEFAULT_BRANCH_PUSH=1 is advisory. It works per statement and it never overrides a force-push. It is visible in the transcript, and it records that the human agreed. It does not prove it: Claude can type it as easily as you can. A hard version would need something the model cannot produce, for example you exporting ALLOW_DEFAULT_BRANCH_PUSH=1 in the environment Claude Code is launched from, which the hook process inherits and the command text does not show. That is not implemented.

What you may notice (guard friction)

The checks that stop bypasses also stop some ordinary commands. In a live trial (headless claude -p, Claude Code 2.1.299, model haiku, one batch of 15 tasks), git, edit, test, commit and push work mostly ran without a denial. These are the spots where the guard shows, each with the recovery the denial text names:

  • A recursive search over a lane whose repo has a .env. grep -r TODO api is refused until it leaves the secret out: grep -rn TODO api --exclude='.env*' (add --exclude='*.pem' --exclude='id_*' for keys), or search a subfolder, use --include='*.py', or with rg pass -g '!.env*'. For the Grep tool: pass a glob "!.env*" (or "*.py", or a type). A Grep with no path is judged from the lane root and refused the same way.
  • cd <repo> is denied by design (the session stays at the lane root). The message names the way: git -C <repo> …, lane-run <repo> <cmd…>, lane-gh, or <repo>/path for files. cd sequences are judged step by step. In that trial it was the model’s first instinct for 6 of 15 tasks and each denial was recovered in one retry. One run with one model: a different model may differ.
  • Git aliases and hooks paths are denied. git -c alias.x=push x origin main and anything setting core.hooksPath is refused outright.
  • A symlinked .env in a lane. cp .env.example .env is allowed, but cat .env.example > api/.env over the link is not, because the link leads into the read-only mirror. The fix is rm api/.env (only the link), copy the template, ask the owner for values.
  • Writing a new env file (echo X=1 > api/.env.local) gets a write-worded denial: values are the owner’s, copy the template and ask.

What the guard does not check, in one list

Collected so you do not have to assemble it yourself. A heuristic over a reader is not a shell. Where a command’s meaning depends on something the reader cannot see, the guard refuses and says why (fails closed), or, where refusing would block ordinary work, it does not see, and that is listed here.

Fails closed (the call is denied with the reason): input to a shell or interpreter that comes from anything but a literal heredoc, here-string, echo, printf or a file it can read (curl … | sh, cat x | bash); code that only exists after another command ran (bash -c "$(…)", eval $x, unless the substitution is a literal echo/printf/heredoc); bash -c, eval or env -S nested more than 4 deep, a group nested more than 100 deep, more than 8 programs inside one command, a brace expansion past 256 words, a glob component longer than any file name that is mostly wildcards; an unterminated $'…'; a cd to a computed target or inside a loop or a function; xargs git push … fed from a stdin it cannot read.

Not seen (the call is judged on what it says, and what it does is outside the guard):

  • The contents of a script run by name (bash build.sh, source x, ./run), so a script can do what the line cannot. The same is true of anything a test run, make or a package manager hook starts. This is the one caveat to keep in mind everywhere: the guard judges the command line, never the program it starts.
  • A variable it cannot resolve (cat $f where $f was set elsewhere), and variables set outside the command.
  • The output of a command other than echo/printf/cat (cat $(ls -a | grep env)).
  • Interpreter code that builds a name by arbitrary computation. Only the common assembling idioms are refused.
  • What git diff --cached, git log -p or git stash show -p print from history (they name no secret).
  • Whether an rg run is stopped by a .gitignore.
  • Secrets by content, or by any name it does not list (.envrc, *.tfvars, .aws/credentials).
  • Tools outside the matcher: WebFetch, WebSearch, MCP tools, the Agent/Task call itself, and anything Claude Code adds later. Network egress and prompt injection are out of scope entirely.
  • Whether you were asked. In-lane file-tool calls are answered allow, so Claude Code does not prompt for them; memory scope and “ask before committing” are instructions, not checks.
  • Consent for a protected-branch push. The ALLOW_DEFAULT_BRANCH_PUSH=1 marker is advisory (above).
  • Workspace commands from the root. lane upgrade, lane migrate, lane rules pull, lane init, lane add, lane secrets … add|ignore are refused from inside a lane and pass from the root session, where you maintain the workspace (covered by the product’s guard suite).
  • Itself, when it is not running. A syntax error in guard.py, a missing python3, lane missing from the hooks’ PATH, a lane whose settings lost the hooks, or a timeout all leave a call unguarded. lane doctor is how you notice (how it fails).

How it fails

The two failure modes are chosen on purpose, and the same text is at the top of guard.py.

PreToolUse fails closed; every other hook fails open. A guard that crashed open would make an input that crashes it the easiest bypass there is, so a malformed PreToolUse call is refused whatever it names, secret files included. Everything else (UserPromptSubmit, PreCompact, SessionEnd, PostToolUse, Worktree*) only adds context or tidies up, and refusing bookkeeping would break the session over nothing.

What goes wrongWhat happensExit code
PreToolUse: the payload makes no sense (a command that is not a string, a cwd that is not a path, a body that is not JSON but plainly a PreToolUse call), or any unexpected exception while judging a tool callfails closed: answered with a deny that says why. A guard bug costs one refused command with the reason printed, not a silently unguarded session0
Any other event: unreadable inputfails open: ignored0
An uncaught exception in one of those other eventstraceback on stderr; Claude Code shows a hook error1
A body that is valid JSON but not an object (null, a list)no event name, nothing to decide0
A tool or event the guard does not handle, or a command that matches no ruleno decision; the call proceeds under Claude Code’s normal permissions0
guard.py has a syntax errorevery event fails as a crash: a hook that exits non-zero without a decision lets Claude Code run the tool, so every rule is off. This is what fail-closed cannot cover. lane doctor fails with “does not compile”1
lane or python3 is not on the PATH the hook runs withthe hook command cannot start; same as above. lane doctor checks lane first127
The hook runs longer than its timeout (30 s; 120 s for WorktreeCreate)what Claude Code does with a timed-out hook was not tested; treat the call as unguarded. The path regexes are not quadratic: a 96 KB command costs about the same as a short one (the suite requires each of 18 pathological 96 KB commands to be answered, correctly, in under 3 s). Latency measured with a hook driver over more than 100 calls was about 45 ms per call (mostly Python start-up)n/a
A project rules file is malformedthose rules are not enforced; the built-in rules are unaffected; a warning goes to stderr; lane doctor fails0
A lane’s settings.local.json lost its hooksthat lane runs unguarded; lane doctor names itn/a
WorktreeCreate finds a bad name, or WorktreeRemove is calledrefused, with a message on stderr1

lane doctor tests this: it compiles the guard, fires its named probes (eleven: ten must be refused, one ordinary command must not be; it counts your own project rules too), checks that each project rule still refuses its own example:, and walks every lane and worktree, including whether its PreToolUse matcher includes Glob and Grep. lane doctor -v also runs the full guard suite (over 1,800 cases, built on its own throwaway setup that it deletes afterwards, and asserting no hard-coded count). A green doctor means the rules are in force, not that nothing can get past them. It checks files: it cannot tell whether Claude Code is actually loading the hooks, for instance when claude is started outside the workspace folder.

Workspace commands

The guard refuses lane upgrade, lane init, lane migrate, lane add, lane rules pull, lane secrets … add and … ignore, and another lane’s lane-done, lane-park, lane-resume and lane-with, from inside a lane. They pass from a session at the workspace root, which is where you run them; for lane upgrade, init and migrate the refusal says to run them yourself, from a terminal, with no Claude session inside the workspace. lane-ref add refuses the paths listed under paths.

This is the guard judging a command line. It does not make a session at the workspace root a safe place to run whatever you are told: you still choose what to run there.

You are not the threat model

Everything here constrains Claude sessions. You can edit settings.local.json, run the command yourself, or delete the hook. That is correct, because it is your machine, but it means “Repolane refuses X” always means “a session under the guard refuses X”.

Precautions for sensitive repositories

  • Do not rely on the guard to keep a secret from the session. Keep credentials out of the working tree (a secret manager, or files outside any path a session can reach), and name the files that must stay in the tree with the names the guard knows. Add per-repo names with lane secrets <repo> add <path>.
  • Run lane doctor after anything that edits settings: after lane upgrade, after a pull, and when a session behaves oddly. It is the only way you learn a lane lost its hooks.
  • Run lane resume <id> on a lane made earlier, so it picks up the Grep matcher and the full deny list.
  • Accept the workspace trust dialog before you rely on the pre-approved list, and treat the list as convenience, not as a permission model.
  • Do not attach a folder that holds credentials with lane ref. A credential in a name the guard does not know is an ordinary file inside a reference.
  • Do not run lane board on a machine other people have shell accounts on (see below).
  • Read what you pull. lane rules pull copies scripts into .repolane/scripts/local/ and markdown into .repolane/knowledge/shared/, which is injected into every session. Review the pinned commit before --latest. A script it pulls does not overwrite a same-named file you put in .repolane/scripts/local/.
  • If untrusted code or an untrusted prompt source is in your threat model, put the whole workspace in a VM or container and treat the guard as defence in depth inside it. That is the boundary hooks cannot give you.

The board

lane board runs a local HTTP server from the Python standard library. It serves the page and a JSON API that can change your repos.

  • It binds 127.0.0.1 only. Port 7777 by default (WS_BOARD_PORT changes it); if the port is taken it tries the next, up to 20 attempts. It is not reachable from the network.
  • Host and Origin must be local. Every request, GET / and the static files included, must carry a Host of 127.0.0.1:<port> or localhost:<port> (the server’s own port), and an Origin of the same when one is sent. Anything else gets 403. This is what stops a DNS-rebinding page from loading / and reading the token.
  • Every /api/ call needs an X-Token header; a mismatch is a 403. The token is secrets.token_urlsafe(24), generated in memory each time the server starts, never written to disk, and invalid once the board stops. It is not printed in the terminal. The terminal shows only the address: lane-board http://127.0.0.1:<port>/ (Ctrl-C to stop). The token is embedded in the page the server sends you (window.__T).
  • The page at / is served without a token, because it is what carries the token to your browser. With the Host check, a browser has to navigate to 127.0.0.1 or localhost to get it; any process on the machine that connects to the port and sets that Host can fetch the page and read the token.
  • A custom header is also the CSRF defence. A form post from another site cannot set X-Token, and the server sets no CORS headers (an OPTIONS preflight returns 501).
  • Paths are checked and are never pathspecs. A lane and repo must resolve to a real worktree under lanes/; the base clones are never operated on. Every git call runs with --literal-pathspecs. Staging and diffing accept only exact changed files, or a directory that holds changed files; ., globs, .. and option-like names are refused with 400.
  • Secret files are dropped by name from the diff and from staging. The diff endpoint withholds a file whose name matches and shows the rest; staging, commit and amend refuse while a secret is staged. Renames and copies are checked on both sides (git mv .env notes.txt is caught), including in a commit’s file list. The matcher is case-insensitive (.ENV is a secret) and uses the guard’s full name list. {"files": ["."]} does not stage .env, and a commit’s diff (…/commits/<sha>) does not return a secret’s diff for a commit that holds a glob-matched file.
  • Bodies are bounded. A request body over 1 MiB gets 413, a non-numeric Content-Length 400, a body that is not a JSON object 400. A lane id or memory name outside [A-Za-z0-9._-]+ gets 400. A stalled client times out after 20 seconds.

The limits that remain:

  • If the token leaks, the holder can act as you through the API. Beyond reading diffs, specs, briefs and memory, the mutating routes are:

    RouteWhat it does
    …/repos/<repo>/stage, …/unstagegit add / git restore --staged exact changed files you send (a secret by name is refused)
    …/repos/<repo>/discardthrows away uncommitted changes to named files
    …/repos/<repo>/amendrewrites the last commit (refused once pushed, and while a secret is staged)
    …/repos/<repo>/commitcommits what is staged (refused while a secret is staged)
    …/repos/<repo>/push, …/pullgit push -u origin <branch> and git pull --ff-only
    /api/lanes/<lane>/start, resume, park, donelane start, resume, park or done
    /api/memory/…/move, delete, file, discardlane-memory move, rm, file or drop, with names limited to [A-Za-z0-9._-]+
    /api/lanes/<lane>/refs/attach, …/refs/<name>/detach, …/revealattach an arbitrary path as a lane reference (making any user-readable path readable to that lane’s sessions), detach it, or reveal it in Finder (macOS only). The guard’s lane-ref add refusals do not apply here
    …/repos/<repo>/prreturns a GitHub compare URL for a branch. It makes no request itself and changes nothing

    …/repos/<repo> stands for /api/lanes/<lane>/repos/<repo>.

  • The token is per run. It is good for the workspace the board was started in for as long as that server runs; stopping the board makes it worthless. See which workspace it shows.

  • Loopback is not per-user. On a shared machine any other local account can connect to the port, send a Host of 127.0.0.1:<port>, fetch /, and read the token. If you share a machine with people you would not give commit access to, do not run the board.

  • There is no TLS, the normal choice for loopback.

The board’s behaviour is covered by its own test suite in the Repolane repo, which starts the real server on a scratch setup.

What leaves your machine

There is no telemetry, no analytics, no update check, no crash reporting and no licence call. Lane specs, memory, findings and diffs are files in your workspace.

That is not the same as “no network”. Repolane causes network traffic when you or a session run something that needs it:

SourceTalks to
lane add <url>, lane upgrade, lane start (a git fetch), lane status and bare lane (a git fetch -q origin in every base clone, every time you run them; the help line and the script’s header say so), lane rules pull, the board’s Push and Pull buttonsyour git remotes
lane upgrade and lane upgrade --checkPyPI, for the newest version of the package (and pipx or uv download it). Only when you run them
lane gh, lane sync, and the board’s background pollGitHub, through gh. With the board open it runs gh pr view for each branch every 60 seconds (git state refreshes every 3 s, sessions every 45 s; those two are local)
lane doctorgit ls-remote against the shared rules repo, only if you configured one with lane rules pull
the board’s page, in your browsernothing outside the machine: its fonts ship with the board and the board serves them
the hooksno network access

Everything but the font request uses credentials already on your machine. Anything a session does (web fetches, MCP tools, curl in a shell command) is outside Repolane’s view; see the coverage table above.


Read Rules for the reasoning behind each refusal, and Working with Claude for what a session is told before it starts. To report a way past the guard, see Contributing and reporting.

Next steps