The policy gate
hooks/scripts/policy-gate.mjs, on PreToolUse for every tool
This gate turns two pieces of configuration into refusals: the autonomy
classes in .tyran/policies/autonomy.yaml, and the deployment class in
.tyran/config.yaml.
Read the hook runtime first. Everything there about the platform failing open applies here, and this gate inherits its runtime from it.
What it enforces
Section titled “What it enforces”| what | where it comes from | what it decides |
|---|---|---|
| path classes AUTO / GATED / KERNEL | .tyran/policies/autonomy.yaml | whether a file-writing tool call may proceed |
| the deployment class P1 / P2 / P3 | .tyran/config.yaml, autonomy: | how far a git push may reach |
| credential-shaped reads | built in, no configuration | whether a file may be read into the model’s context |
| how far all three are turned DOWN | .tyran/config.yaml, boundaries: | whether each of the rows above is enforced at all |
The matrix
Section titled “The matrix”The definition of this gate is a matrix, not a list of examples. The columns
are supervision, not merely which actor is running: a subagent’s tool calls
never surface a permission prompt, and neither do a main loop’s under
acceptEdits or bypassPermissions.
| path class | supervised main loop | main loop under acceptEdits | subagent, or other prompt-less modes |
|---|---|---|---|
AUTO | pass | pass | pass |
GATED | pass — the platform’s own permission prompt is the approval | ask — the gate summons the prompt itself | deny |
KERNEL | deny | deny | deny |
no rule matches, inside .tyran/ .claude/ hooks/ | the policy’s default: — AUTO in the shipped template since 0.1.44 (it was GATED; tightening it back is one word in .tyran/policies/autonomy.yaml) | ||
| no rule matches, anywhere else | pass — see below | pass | |
| outside the repository | deny | deny |
Two rows deserve their reasoning spelled out, because both could defensibly have gone the other way.
A path no rule matches is a row, not a fall-through — and it is really two rows. ADR-19 correction 1 says a denylist over input somebody else controls is structurally incomplete, so “unmatched means allowed” would mean the policy protects only what someone remembered to list. The opposite extreme — refuse everything unlisted — was rejected on a measurement rather than on taste: 65 of the 65 tracked files in this repository match no rule in the shipped template, so that reading would have refused an implementer subagent on every write it makes, and a gate that refuses ordinary work is uninstalled.
The split follows what the policy is actually about. ADR-06 governs
self-improvement — what the retrospective agent may change about Tyran —
and every rule in the template names a Tyran artefact. So inside Tyran’s own
namespace (.tyran/, .claude/, hooks/) the policy is meant to be
exhaustive, an unmatched path means somebody added a new kind of artefact, and
default: applies: fail-closed and cheap, because those trees are small.
Outside it, the policy has nothing to say and neither does this gate.
This narrows the default, not the rules. An explicit rule still applies to
any path anywhere — write - path: src/** and you get exactly that. A path
outside the repository is still KERNEL, and hooks/** and
.tyran/policies/** are still refused unconditionally, before any rule is
consulted.
The shipped template uses that exception exactly once, for a file at the repo
root: CLAUDE.md is GATED. Until it was added, a subagent could rewrite
the law it was bound by and no gate had an opinion — the root is outside every
governed prefix, so the default: never reached it. The rule bans the hand,
not the mechanism: a free-hand Write from a subagent is denied, while
scripts/mistakes.mjs promote --law — a Bash command, and a repo-root file
is not in the shell-protected globs — still writes its own fenced region, and
only once one signature has recorded the occurrences that earn a rule
(self-improvement holds the thresholds). Seeding is create-only, so a repo
adopted before this release keeps the older, silent behaviour until it copies
the rule from templates/policies/autonomy.yaml; a repo that dislikes the
trade deletes four lines from its own policy and accepts one permission prompt
fewer.
GATED passes in a supervised main loop, and asks under acceptEdits.
On PreToolUse a gate has three answers: deny, silence, and
permissionDecision: "ask", which renders the user’s own prompt even in a
mode that auto-accepts edits. ("allow" is none of these — it auto-approves
the call and skips the prompt, so this runtime cannot emit it.) So GATED is
delegated where the platform prompts anyway, asked where the MAIN loop has a
prompt surface that acceptEdits merely mutes, and denied where no prompt can
render at all: every subagent, bypassPermissions, and any mode the gate does
not recognise — an ask a mode might not render must fail toward deny, never
toward approval. Field measurement, before the ask column existed: under
acceptEdits the operator’s own conductor could not perform a GATED write
anywhere, while the refusal text pointed at a main session that refused the
same way.
The deployment class
Section titled “The deployment class”P1 keeps an agent on its own branch · P2 adds the shared and testing
branches · P3 adds production, minus the operations that cannot be undone.
command, on a repo whose default branch is main | P1 | P2 | P3 |
|---|---|---|---|
git push origin feature/x | pass | pass | pass |
git push origin staging | deny | pass | pass |
git push origin main · HEAD:main · HEAD · @ | deny | deny | pass |
git push --all · git push --tags | deny | deny | pass |
git push origin --delete X · git push origin :X | deny | deny | deny |
git push --mirror | deny | deny | deny |
git push --force-with-lease origin main | deny | deny | deny |
The last three rows are the mechanical content of “P3 passes, but irreversible and user-visible operations are still gated”. Without them that sentence was prose: every class allowed the same destructive pushes.
Four spellings of a push reached production before review caught them, and all four are worth naming because each had a different cause:
| spelling | why it got through |
|---|---|
git push origin HEAD · git push origin @ | only a bare git push asked git which branch was checked out; HEAD was compared against the production names as though it were a branch called “HEAD” |
B=main; git push origin "$B" | the remote was checked for shell expansion and the refspec was not, so the destination read as a branch literally named $B |
git -c alias.zz=push zz origin main | the word push never appears in the subcommand slot, so no push was seen at all |
HEAD and @ now resolve through the same symbolic-ref call as a bare
git push — one answer to “which branch is this”, not two — so
git push origin HEAD still passes on a feature branch, which matters because
it is the commonest spelling there is. A refspec the shell would rewrite is
refused, and a git command that defines or uses an alias is refused
outright: an alias can rename any subcommand, so the visible words are not the
command git runs.
The alias answer lives in the shared lexer (planCommand().aliased) and both
gates read it, because the secrets gate had the same blind spot with a worse
consequence: with no push in the subcommand slot it computed that there was
nothing to scan and returned early, so a push carrying a key was published
without being scanned at all. Measured cost of the rule: 2 commands in a
14-command corpus, both of them commands that define or use an alias.
Which branch is production is answered twice, on purpose. A name list
(main, master, production, prod, release, live, trunk, stable)
is an enumeration and therefore incomplete — a repository whose production
branch is called ship is not in it. So the gate also reads the remote’s own
default branch, and under P1/P2 it refuses when it cannot:
git remote set-head origin -aOne command, permanent, writes a local ref and changes nothing on the remote. Refusing here rather than falling back to the name list is deliberate: a miss in an enumeration would otherwise be a silent pass, and ADR-19’s rule is that an exclusion may never be quiet.
Everything the gate knows about a command line comes from the secrets gate’s
lexer (planCommand): segmentation, transparent prefixes like sudo/env,
the cd/pushd/popd working-directory model, and refusal on anything that
needs shell expansion. There is no second decomposition — planCommand records
the raw argv after push, and this gate reads that. Anything the lexer cannot
model is a refusal here for the same reason it is one there.
Credential-shaped reads
Section titled “Credential-shaped reads”The trigger was not hypothetical. A neighbouring project’s .env was read
whole into a conductor session in this project’s own history: dozens of live
credentials, including payment keys and a token that bypasses two-factor
authentication. Nobody had asked for the read, no commit was involved, and the
secrets gate — which defends publication — would never have seen it. A
transcript is storage.
So a read is refused when the path is credential-shaped, for every actor, and the AUTO/GATED/KERNEL classes are not consulted for reads at all.
Covered shapes: .env and .env.* (but not .example / .sample /
.template / .dist / .defaults / .schema), *.pem|key|p12|pfx|jks| keystore|kdbx|asc|ppk, id_* SSH keys, credentials[.ext], .netrc,
.npmrc, .pypirc, .pgpass, .dockercfg, .git-credentials, .envrc,
*service-account*.json, and
anything under .ssh/, .aws/, .gnupg/, .config/gcloud/, plus
.kube/config. Grep travels this rule too, because output_mode: "content"
prints the lines.
The way out is an explicit class: AUTO rule for the path in
.tyran/policies/autonomy.yaml. That file is class KERNEL, and a shell
command that writes to it is refused too, so the exemption is not one an agent
writes for itself in the ordinary way. (Narrowed in 0.1.16 from “a shell command
that names it”: a read-only command on that path passes now, as Read always
did. What closes the route is the write half, which is the half cat >> used.)
It is not a proof: a path this gate
never sees as a word is in the declared floor above. Round two claimed the
stronger version — “cannot be talked out of it from inside a session” — and
review took it apart in three tool calls.
Why KERNEL was NOT extended to cover reads
Section titled “Why KERNEL was NOT extended to cover reads”Both answers were defensible and this one is a choice, not an oversight.
Extending the write classes to reads would make hooks/** unreadable, and a
gate whose own source an agent cannot read teaches its user to switch the gate
off. The narrow rule catches the case that actually happened without that cost.
What this gate does NOT catch — the declared boundary
Section titled “What this gate does NOT catch — the declared boundary”- Shell commands are path-checked for two families only, not classified.
A
Bashcommand that names a credential-shaped path is refused outright; a command that could write to a path under the two built-in protected globs,.claude/settings.jsonor.claude/settings.local.jsonis refused, while a read-only command on the two built-in globs passes (see below). The policy’s ownAUTO/GATED/KERNELrules are not consulted for shell commands. Worst case: aKERNELpath a user declared in their own policy can be written from a shell. The two built-in globs are deliberate — they need no policy file, so a broken policy cannot be the thing that stops an operator repairing it. - Product code is not classified by default. Only
.tyran/,.claude/andhooks/fall under the policy’sdefault:; everything else needs an explicit rule. Worst case: a user who believes the policy covers their whole tree gets no refusal forsrc/**until they write the rule. The alternative was measured and rejected above. - A repository with no
.tyran/directory is left alone (except for the read rule, which needs no configuration). Worst case:rm -rf .tyrandisables the path classes wholesale. Accepted because refusing every write in every repository that has not adopted Tyran is a plugin nobody keeps installed; detecting the deletion belongs todoctor. - The read rule is a denylist and is therefore incomplete. A credential in
a file called
notes.mdis read without objection. It is not a claim that no secret can reach the context by another name. Two shapes are named rather than left to be discovered:- The ssh rule matches OpenSSH’s own key-type names —
id_rsa,id_dsa,id_ecdsa,id_ed25519,id_xmss, their_skhardware variants and a per-host suffix. A key with an invented stem (id_deploy) is not matched by name. Inside any.ssh/directory the whole-path rule still catches it, and the secrets gate still catches its bytes at commit and push. This narrowing bought back everyID_-prefixed constant: the old rule was^id_[a-z0-9]+$, which readID_ISSUEDandID_TOKENas private keys and refused any command that mentioned one. - Because these rules also run over the raw WORDS of a command, a word that
merely ends like a key file — a regex, a flag value, a URL ending
.keyor.pem— is refused even though no such file exists. That is the denylist being deliberately wrong in the safe direction, and it is the one false refusal you are most likely to meet. Reach forRead, which is not subject to the word test.
- The ssh rule matches OpenSSH’s own key-type names —
GATEDin a supervised main loop relies on the platform actually prompting, and the gate cannot tell whether it did. Measured by review:permission_modestaysdefaultwhen the user has allow-listed a tool, and a hook cannot read those settings. Sodefaultmeans “the platform MAY prompt”, never “the user was asked”, and no refusal claims otherwise — the round-two text printed “the user is prompted for this write” in a session where nobody was. Worst case: a user who has allow-listedWrite(*)getsGATEDbehaviour equal toAUTOin the main loop. Review raised P1 to P3 from an unattended main loop with no refusal.- The deployment class is stored in a file the shipped policy now classes
AUTO..tyran/config.yamlwasGATED; limit 5 above had already measured that gating it was convention rather than mechanism whereverWriteis allow-listed, and a second install measured what it cost when it did bite: setup inferredpnpm test, which in that repo is barevitestand never exits, and the agent that discovered every future agent would hang was refused the write that fixes it. It handed the operator a heredoc, during setup. So the trade was made in the direction of the file being repairable, and the cost is stated rather than implied: an agent can raise its ownautonomy:from P1 to P3 and then push to main. What remains is that nothing infers a raise, and that a raise is a diff in a committed file. A repo that wants the mechanism sets the rule back toGATEDin.tyran/policies/autonomy.yaml, which is a KERNEL file and therefore a decision only a human can make — in either direction.boundaries:lives in the same file and inherits the same limit: an agent can turn this gate’s own refusals down. One entry rather than two, because it is one fact — a classAUTOfile decides how far the gate reaches — and the answer to it is the floor in “Turning the gate down” below, which no setting in that file can move. - Only the argv of
git pushis modelled, not every publishing command.gh release createand friends are the secrets gate’s business; the deployment class does not see them. - Multiple pushes in one command line are each evaluated, but the deployment class always comes from the session root’s config, not from the repository the command walked into.
Turning the gate down
Section titled “Turning the gate down”Everything above is what this gate does at its strict setting, which is what it
did before boundaries: existed and what it still does for every repo that
does not mention the block. configuration.md
is the reference; this is what changes here.
| flag | what stops being refused |
|---|---|
outside_repo: allow | the “outside the repository” row of the matrix, for every actor — the row becomes pass rather than deny |
credentials: allow | the credential-shaped read rule, on Read, Grep, and in the text of a shell command |
path_classes: allow | verdictForClass is not consulted: a rule-derived GATED or KERNEL passes |
push: allow | the whole deployment-class analysis is skipped, refspec and alias refusals included — with no destination left to judge, refusing a command for being unreadable to a check that is off is noise |
prompts: skip | nothing. It changes what a PASS means: the gate emits permissionDecision: "allow" instead of silence, which auto-approves the call and skips the platform’s own prompt |
The floor, checked before any flag. BOUNDARY_FLOOR_GLOBS is
SHELL_PROTECTED_GLOBS plus .tyran/STOP — hooks/**,
.tyran/policies/**, .claude/settings.json, .claude/settings.local.json
and the brake. No value of any flag reaches them, and path_classes: allow
consults the floor before it consults the flag. This does not change what a
policy file may say: the asymmetry named above stays exactly as it was, because
this closes a new door rather than reopening an old one.
prompts: skip cannot approve what another gate refuses. Measured rather
than arranged between siblings: deny from any hook beats allow from another
(hooks/HOOK-CONTRACT-MEASURED.md §4). So the secrets gate still refuses a
commit or push carrying a key, and this gate’s own denials are returned
untouched — the rewrite lives in one place, handle(), and only ever acts on a
verdict that was already PASS. A PolicyFailure is not rewritten either: a
gate that could not complete its check must not auto-approve, whatever the
config says.
Every unreadable answer is strict. A missing config, an unparseable one, an oversized one and a value outside the schema all resolve to every boundary ON. That is ADR-22’s own argument applied to the knob that turns ADR-22 down — the file saying “relax” has to be readable before anything relaxes.
The shell is not a way around this gate
Section titled “The shell is not a way around this gate”Round two declared “shell commands are not path-classified” as a boundary. Review measured what that boundary cost, twice, and both measurements are refusals rather than opinions:
- this gate refused
Read .env, and the model reached forBash: grepin its next tool call, unprompted, and read the value; - the read rule’s own way out — “only a human can edit the policy” — was taken
by an agent in three tool calls with
cat >> .tyran/policies/autonomy.yaml.
The sibling write-guard had already
closed this class for its own rule, with a matcher covering Bash and a
refusal that says the rule is not tool-specific. Two sibling gates answering
one question two different ways is the collision ADR-21 exists to prevent, so
this gate now answers the same way, with the same doctrine: it reads the TEXT
of the command, never the effect of running it.
Every literal token of every segment is tested — there is no heuristic about which token “looks like a path”, because an ordinary word matches nothing. Two families are refused, and they are refused for different reasons:
- a credential-shaped path, whatever the program does with it.
cat,grep,cpandbase64all publish the bytes, and the distinction between reading and copying is not one a gate reading text can make. Reads and writes alike, because theReadtool refuses this family too; - a path under
hooks/**,.tyran/policies/**,.claude/settings.jsonor.claude/settings.local.json. This family is a write rule: on the two built-in globs a read-only command passes, becauseReadpasses on them.
.claude/settings.json is on that list because it registers the hooks — it
is the one place inside a repository from which every gate can be switched off
at once. The template classifies it KERNEL, so Edit and Write refused it
from the start; echo x > .claude/settings.json did not, which left the
shortest route to disabling this gate as the one route it did not watch.
The asymmetry that leaves, named rather than hidden. The shell list
(SHELL_PROTECTED_GLOBS) and the validator’s list (MANDATORY_KERNEL_PATHS)
answer two different questions: what this gate refuses to see in a shell
command, and what a policy may not downgrade. The registry is on the first
list and not the second. So a user who rewrites their own policy can make
Edit .claude/settings.json allowed while echo > .claude/settings.json stays
refused. Raising it into the validator’s list would close that and would change
what every policy file is allowed to say — a separate decision, deliberately
not taken here.
The argument of -m / --message / -F / --file / -t / --template /
--data is removed from the text first, so git commit -m "fix .env loading"
is not a refusal — and neither is a journal.mjs append ... --data '{...}'
whose prose merely mentions a dotenv-shaped filename. That last one was
measured twice on one install (a journal entry describing a migration run
against a test env file was refused as if it published the file), and its
cost was the worst kind: conductors stopped writing certain filenames into
their own ledger, which is the journal failing at its one job. This is the
same move stripHeredocBodies makes in the shared lexer, for the same
measured reason: a commit message is data, and lexing data as a program was
the largest single source of false alarms there.
Reading hooks/** and .tyran/policies/** from a shell
Section titled “Reading hooks/** and .tyran/policies/** from a shell”Measured
Measured on 0.1.15, for one file under hooks/**:
the shipped 0.1.15 gate, driven over one file under hooks/ and the same file on 0.1.16
| call | 0.1.15 | 0.1.16 |
|---|---|---|
Read FILE | pass | pass |
cat FILE | deny | pass |
grep -n X FILE | deny | pass |
wc -l FILE | deny | pass |
node --check FILE | deny | pass |
git log --oneline -- hooks/ | pass | pass |
The last row is what made the old rule indefensible: a bare directory token
names no file to classify, so it always passed, while wc -l on a file inside
it did not. In every other row the bytes were one Read call away.
The rule this encodes: the shell must not become a second route to something
the Read tool already refuses. For .env that symmetry is the whole point —
Read denies and grep denies, because the measured incident is a refused
Read .env followed by Bash: grep in the very next tool call. Where Read is
allowed, refusing the shell buys nothing and costs a tool call every time
somebody inspects the gate they are working on.
A command is exempted only when all of the following hold:
- every segment’s program is
cat,head,tail,wc,grep,rg,diff,nodeorgit, matched exactly on the program name./bin/catandCATare the same program;catnipis not, and neither isconstructor; - for
git, the word immediately after it islog,showordiff. A global flag before the subcommand refuses, becausegit -c core.pager=…names a program for git to run andplanCommand’s alias check only coversalias.; - for
node,--check(or-c) is required rather than merely permitted:node FILEexecutes FILE; - every token beginning with
-is in that program’s flag list, short clusters read letter by letter. An unrecognised flag refuses — the enumeration’s incompleteness costs a false refusal, never a pass; - the raw command contains none of
< > $ ` ( ) { } &— every redirection spelling, command substitution, parameter expansion, subshell, group, and&/&&at once. Tested on the raw text because the shared lexer consumes these as separators: by the time a token exists, the>that made the line a write is gone; - no word of the raw command is credential-shaped, tested a second time
against the same
SECRET_READ_RULESand with none of the filters the path findings go through. The findings are computed from a stripped copy of the command —stripMessageArgumentsremoves a message-bearing flag together with its argument, and non-literal tokens are dropped — so a credential can be invisible in them.-tisgit commit --templateand a legaldiffflag, which madediff -t .env FILEread as “one readable path, one read-only command” and exempted it. Four shapes were measured deny-on-0.1.15 → pass-before-this-check:diff -t SECRET FILE,grep --file=SECRET FILE,grep -m SECRET FILE,diff ~/SECRET FILE. One false refusal is declared with it, in the direction this gate errs in:git log --grep=.env -- FILEloses the exemption over a word in a search pattern.
| and ; are allowed, because every composed segment still has to be an
allowed reader — a pipe into a writer is a writer.
Refused, and each one is a mode of an otherwise allowed program:
| refused | why |
|---|---|
sed -n 1,20p FILE · awk NR<10 FILE | the script argument is a program with its own write commands (sed’s w, awk’s print >). Reading it needs a second parser, which ADR-21 is exactly about |
node FILE | executes it |
git log --output=FILE · git show --output=FILE | writes a file — a real hole found in an earlier review of this gate |
git -c core.pager=tee log | a global -c names a program for git to run |
rg --pre CMD · rg --hostname-bin CMD | runs CMD once per file |
tail -f FILE | never returns |
cat FILE > /tmp/cat · cat $(cat FILE) · cat FILE | tee /tmp/x | redirection, substitution, a pipe into a writer |
the same commands on .env, id_rsa, .aws/credentials | the credential family, unchanged |
diff -t .env FILE · grep --file=.env FILE · grep -m .env FILE · diff ~/.env FILE | the credential family again, found on the raw text: the finding list is computed from a stripped copy of the command and never saw these words |
the same commands on .claude/settings.json | below |
.claude/settings.json is deliberately outside the exemption, and that is a
narrowing rather than a consequence: Read passes on it too, so the principle
alone would admit it. It is the hook registry — the one place inside a
repository from which every gate is switched off at once — and this change is
scoped to the friction that was measured. Direction of error stays refuse.
The declared floor for the shell rules
Section titled “The declared floor for the shell rules”Not a ceiling. SHELL_DECLARED_MISSES in the source is the same list:
- a path assembled at runtime — from a variable, a command substitution, a
glob, or a file on disk.
D=.tyran/policies; cat >> $D/autonomy.yamlgets through, and it is the route that stays open; - a relative path resolved against the session directory rather than the
one a
cdmoved to — stricter, but a different path from the shell’s; - anything a script writes once it is running;
- a
KERNELpath declared by the policy rather than built in — onlySHELL_PROTECTED_GLOBSis checked, so a broken policy can never be the thing that stops an operator repairing it; - a program an allowed reader is configured to launch — a
diff.externaldriver or pager in git config, aNODE_OPTIONScarrying--require, a shell function shadowingcat. The flag table matches the command text, and the environment is not in it; - a different program wearing an allowed one’s name.
READ_ONLY_PROGRAMSis keyed on the basename, which is what makes/bin/catandcatone entry — and also makes a repo-writablesrc/catan allowed reader, withsrc/**classed AUTO in the shipped template. Declared rather than closed: requiring a bare program name would break/bin/catand still admit an absolute in-repo path, and it would buy nothing anyway, because miss 3 already lets the same script do the same work with the path baked in and no allowed name at all.
The cost, measured
Section titled “The cost, measured”Measured
This gate is registered for every tool call, because the alternative — a matcher listing the tools it knows — makes any tool it has never heard of an unclassified write, and the platform’s list is not frozen. The price of that choice, measured on this machine (12 runs each, median / slowest):
12 runs of each call shape on the author's machine; median and slowest
| call | median | slowest |
|---|---|---|
a tool naming no path (WebFetch) | 39 ms | 42 ms |
| a write outside the governed namespace | 39 ms | 44 ms |
| a write refused inside it | 39 ms | 59 ms |
| a read that passes | 42 ms | 113 ms |
a Bash call with no push | 45 ms | 64 ms |
Almost all of it is Node process startup, and none of it approaches the 4 s
internal deadline or the 8 s registered timeout. A git push costs more,
because it runs one or two git symbolic-ref calls, each with its own budget.
Re-measured for boundaries: in 0.1.43, because that block made the gate
read .tyran/config.yaml on paths where it previously read nothing — every
tool call now resolves it, and a passing call resolves it twice, once in
decide and once in handle. The numbers above are the new ones and they did
not move: a second small read disappears into process startup. A per-process
cache was tried and removed — it saved nothing measurable and left module state
that outlived the call, which is the wrong trade for the file that decides
whether a refusal happens.
Failure is refusal
Section titled “Failure is refusal”Everything below denies, because the platform fails open and a gate you can switch off by breaking it is not a gate (ADR-22):
.tyran/exists but.tyran/policies/autonomy.yamldoes not;- the policy is unparseable, or the validator rejects it (including any attempt
to downgrade
hooks/**or.tyran/policies/**); - the policy is larger than 256 KB — every file this gate reads is size-checked before it is read, because the platform’s timeout kills the process and never reads what it wrote;
.tyran/config.yamlis missing in a repository that HAS a.tyran/directory, or does not declare a deployment class. It is not defaulted toP3, and the missing-file case now refuses exactly as a missing policy does — round two had the policy deny and the config pass, which is the same asymmetry ADR-22 is about at a smaller scale;- a push whose destination the gate cannot determine.
A git worktree is the same repository
Section titled “A git worktree is the same repository”Two field reports described opposite failures with one cause: this gate read “the repository” as “this directory tree”, and a worktree is neither inside it nor a different repository.
- a session running in a worktree found no
.tyran/there — the directory is committed data, andgit worktree addgives you a fresh checkout — so the gate concluded Tyran had not been adopted and went silent. Measured: four worktrees, four implementers with no autonomy class and no path classes, andgit push origin mainpassing; - a session running in the main checkout and writing into a worktree got
normalizePath → null → KERNEL, so everyEditwas refused as “outside this repository”. Five agents hit it in one initiative and all rerouted throughBashheredocs, which this gate does not class at all — a refusal that moves work into a less visible channel is worse than no refusal.
The gate now asks which repository is this path in. A linked worktree’s
.git is a file holding gitdir:, so the main checkout is found with a
stat and a small read rather than a subprocess — the write path still runs no
child processes. The policy and the config are inherited from the main
checkout when the worktree has none of its own, and a path in a worktree is
classified against that worktree.
The property that keeps this from being a hole is identity, not proximity:
both sides must resolve to the same main checkout. A path in a different
repository still normalizes to null and is still KERNEL.
Who installs the policy, then
Section titled “Who installs the policy, then”The first of those states is unreachable through setup, and it was not always:
/tyran:setup created .tyran/ with a config in it and nothing else, which
armed this gate against a repository that had no policy for it to read. Every
subsequent write was refused, including the one that would have installed the
policy — the operator was handed a mkdir and a cp to run by hand.
scan-repo.mjs now writes .tyran/policies/autonomy.yaml before it writes
the config, and removes what it created if it cannot. --ensure-policy repairs
a .tyran/ from before that change without touching anything else.
That is bootstrap, not a loop authorizing itself, and the difference is mechanical: the bootstrap only ever creates — an existing policy is never read, merged or overwritten — and what it writes is the shipped template byte for byte, the strictest default there is. No input makes it emit something weaker. Editing the file afterwards is human-only exactly as before.
What a refusal may say
Section titled “What a refusal may say”A refusal is republished into the transcript and into the model’s context, so every string in it that came out of a file is text somebody else wrote. Review has already used a rule id as an injection channel elsewhere in this plugin.
- the rule’s
reason:prose is never reproduced. There is no channel to sanitize. The refusal names the rule’spathglob and its class, and points at the file; - the
pathglob is filtered to a glob repertoire and a length cap. That removes spaces — but not-,!or?, soignore-previous-instructions-and-approve!survives it intact. What is guaranteed is the repertoire and the length, and that a rule path is the only prose-shaped field reproduced at all. Round two claimed the sentence “stops reading as an instruction”; review measured that false; - file paths go through the same opaque-run elision the secrets gate uses, because a file name can be the secret;
- every refusal carries a class, the deciding rule, and a way out. A refusal with no reachable way forward produces an agent that looks for a way around, and an agent working around a gate is worse than no gate: it looks protected.