Guardrails
Extending
Your own refusals in .repolane/registry/rules.yaml, your own lane commands, shared team rules with lane rules pull, per-repo secret files, and sharing a workspace's files with a team.
On this page
In brief. Four ways to extend Repolane without forking it, all inside your workspace: project rules in
.repolane/registry/rules.yaml, per-repo secret names vialane secrets, your own scripts in.repolane/scripts/local/, and shared rules vialane rules pull. The first two can only add refusals. The last two run code or feed text to sessions, so trust the source as you would your own scripts.
There are four ways to add to Repolane without forking it, and each one has a different trust story. All four live in your workspace, never in the installed package, so an upgrade never touches them. They are in order of how much they can do to you, least first.
| You want | Mechanism | Can it run code or change what a session reads? |
|---|---|---|
| a refusal specific to your project | .repolane/registry/rules.yaml | no. It can only add a deny |
| a per-repo secret filename denied | lane secrets <repo> add | no. It writes deny rules into the file above |
your own lane <name> command | an executable in .repolane/scripts/local/ | yes: it is a script you run |
| the same rules, scripts and notes across a team | lane rules pull <git-url> | yes: it copies scripts and notes in |
Rules of your own
The rules that ship with Repolane are true of every project. Ones that are true only of yours (a
migration the entrypoint runs, a branch that deploys itself) go in .repolane/registry/rules.yaml, in
your workspace. It never reaches the package or anyone else unless you commit it to a repo you share. A rule can only add a refusal: the loader has no syntax for granting anything, and a project
rule is checked before the built-ins, because a few of those answer allow and stop.
rules:
- id: no-manual-migrations
when: { tool: Bash, matches: '\balembic\s+(upgrade|downgrade)\b' }
deny: "entrypoint.sh applies migrations on every start. Restart the service instead."
ref: memory/api/migrations.md
example: "alembic upgrade head"
- id: no-editing-generated-schema
when: { tool: Edit, path: 'schema/generated/**' }
deny: "schema/generated/ is written by codegen. Edit the source schema and regenerate."
ref: docs/codegen.md
example: { path: "schema/generated/types.ts" }
| Field | |
|---|---|
id | how the refusal names itself, and what lane doctor reports. Must be unique across every rules file |
when.tool | the tool name as Claude Code spells it: Bash, Edit, Write, Read, … Only tools in the hook matcher are ever consulted (see Security model); Grep and Glob are in it, but WebFetch and MCP tools are not, so a rule for those never fires |
when.matches | a Python regular expression, for Bash. One rule has matches or path, never both |
when.path | a glob, for the file tools. Matched against the path as written and every tail of it |
deny | what Claude is told. Say what to do instead, not just “no” |
ref | required. The file that says why. A refusal nobody can check is one that gets routed around |
example | a case the rule must refuse. lane doctor feeds it through the guard on every run |
Values are single-line: the parser reads a small YAML subset and reports anything else as an error
with a line number rather than half-understanding it. matches is tested against the same
normalised command segments as the built-ins, so echo ok && alembic upgrade head is judged like
alembic upgrade head.
When a rules file is wrong (no ref, an invalid regex, a duplicate id), those rules are not
enforced, the hook says so on stderr, the built-in rules carry on, and lane doctor fails and
names each one. lane doctor verifies that each rule refuses its example, and that it is refused
by that rule. It does not notice if the whole file is deleted. A session inside a lane cannot write
or delete it (.repolane/ is protected from a lane), but you can, from the
root or your terminal (self-protection).
Keep the workspace in a git repo of your own if it matters.
Per-repo secret files
The built-in secret rule recognises a fixed set of names; see Security
model. Names it cannot know about (service-account.json, .npmrc,
id_rsa, .aws/credentials) are declared per repo.
lane add scans a new repo for filename patterns that look like credentials, by name only and never
by reading contents. Most candidates turn out to be ordinary source (password.py,
token_store.py), so it asks before denying anything, and with no terminal it only lists them.
| Command | |
|---|---|
lane secrets <repo> scan | candidate names, one per line, excluding ones you dismissed |
lane secrets <repo> add <path-or-glob>… | writes deny rules (one each for Read, Edit, Write, MultiEdit, NotebookEdit and Bash) that make a file with that name unreadable and unwritable to every future session. The rule matches the name wherever it appears, in any repo and lane, not only in <repo> |
lane secrets <repo> ignore <path-or-glob>… | mark a candidate reviewed-and-not-a-secret, in .repolane/registry/secrets-ignored.yaml; no deny rule |
lane secrets <repo> list, … ignored | what is denied, what is dismissed |
These rules are in .repolane/registry/rules.yaml, so they carry the limits of any project rule: they are
checked for the tools in the matcher, a shell command that builds the filename by glob or quoting
(cat web/service-acc*) is not recognised, and a session in a lane cannot edit or delete the file (.repolane/ is protected from a lane session). The Bash
rule is a regex over the whole command, so it also refuses harmless mentions (ls web/service-account.json).
lane secrets <repo> add and ignore are refused from inside a lane; run them from the workspace
root or your terminal.
Your own lane <name> commands
Any executable file in .repolane/scripts/local/ becomes lane <name>:
mkdir -p .repolane/scripts/local # it does not exist in a new workspace
cat > .repolane/scripts/local/hello <<'EOF'
#!/usr/bin/env bash
# lane: say hello
echo "hello from .repolane/scripts/local/hello"
EOF
chmod +x .repolane/scripts/local/hello
lane hello
.repolane/scripts/local/is part of your workspace, never of the package: an upgrade never touches it. Withlane init --gitit is tracked like your other files.- A second line of the form
# lane: <one-liner>is whatlane helpshows next to it. - A local script is a full peer of a built-in: it runs with
LANE_PRODUCT(the installed package’s files) andLANE_WORKSPACE(the workspace) set, and cansource "$LANE_PRODUCT/scripts/lib.sh"to use the same helpers. - Built-ins win. A local file with the name of a real command never runs;
lane doctorflags it as shadowed. - The command name is a bare filename. A name containing
/is rejected before any lookup.
A local script is code that runs with your account’s access, and the guard does not gate
lane <name> any differently from other workspace commands.
Shared rules: lane rules pull
A team can keep one set of rules, scripts and notes in a git repo and pull it onto each machine:
lane rules pull <git-url-of-your-rules-repo> # first pull; records the commit SHA
lane rules pull # re-fetch; refuses to move past the pinned SHA
lane rules pull --latest # intentionally move the pin
lane rules status # URL, pinned SHA, remote HEAD, what is loaded
The shared repo holds rules.yaml (required, the same schema as above), and optionally
scripts.d/ and knowledge.d/.
How it is made safe, on purpose:
- Pinned, never tracking a branch tip. The first pull records the exact commit SHA in
.repolane/config.yml. Only--latestmoves it. - Copied, never symlinked. The clone lives in
.repolane/registry/.shared-cache/, which is machine-local (lane init --gitignores it). Nothing points into it. A file inscripts.d/does not overwrite a script of the same name that you put in your.repolane/scripts/local/: it keeps yours and says so (unless an earlier pull put that file there, tracked in.repolane/cache/scripts-pulled, in which case it is updated). - A separate file. Rules land in
.repolane/registry/rules.shared.yaml, never merged into your own. Ids are namespaced so they cannot shadow one you wrote. Duplicate ids are rejected, loudly. - Rules can only deny. Shared rules go through the identical parse, validate and deny-only path as your own.
- No remote Python. It does not pull custom guard logic from a remote.
What comes with a shared rules repo
Read this before you point lane rules pull at a repo you do not control. The rules are the safe
part. The other two parts are not rules:
scripts.d/becomes executable commands. Files are copied into.repolane/scripts/local/and becomelane <name>. You can read them before they land, but once they do, running one is running that repo’s code as you.knowledge.d/is injected into every session. Markdown is copied into.repolane/knowledge/shared/(never into.repolane/knowledge/itself, so it cannot overwrite a note of yours), and every.mdfile there is added to each lane session’s first-prompt context, the same way.repolane/knowledge/preferences.mdis. A hostile or careless note there is an instruction the model reads, in every session, in every lane. The guard does not and cannot filter it. Treat the shared repo as having the same authority over your sessions as your ownCLAUDE.md.
lane doctor prints an advisory when the pinned SHA is behind the shared repo’s HEAD. To do so it
runs git ls-remote against that repo, which is a network call.
Sharing a workspace’s files with a team
Your workspace can be a git repo of your own (Install),
and nothing in it is ignored except what is machine-local. So .repolane/registry/repos.yaml,
.repolane/registry/rules.yaml and .repolane/scripts/local/ are tracked like any other file you
commit: a team that wants everyone to start from the same repo list, rules and commands can keep one
workspace repo and clone it on each machine. What should stay per person (.repolane/memory/,
.repolane/knowledge/, the lane files) is up to you: leave it out of the shared repo, or add it to
.gitignore.
Those files can hold local absolute paths (repos.yaml records old_paths: for adopted checkouts),
so push them only to a private remote. For rules alone, lane rules pull (above) is the lighter way:
every machine pins the same commit of a separate rules repo, and nobody’s own files are touched.