Skip to content

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.

whatwhere it comes fromwhat it decides
path classes AUTO / GATED / KERNEL.tyran/policies/autonomy.yamlwhether 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 readsbuilt in, no configurationwhether 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 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 classsupervised main loopmain loop under acceptEditssubagent, or other prompt-less modes
AUTOpasspasspass
GATEDpass — the platform’s own permission prompt is the approvalask — the gate summons the prompt itselfdeny
KERNELdenydenydeny
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 elsepass — see belowpass
outside the repositorydenydeny

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.

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 mainP1P2P3
git push origin feature/xpasspasspass
git push origin stagingdenypasspass
git push origin main · HEAD:main · HEAD · @denydenypass
git push --all · git push --tagsdenydenypass
git push origin --delete X · git push origin :Xdenydenydeny
git push --mirrordenydenydeny
git push --force-with-lease origin maindenydenydeny

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:

spellingwhy 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 mainthe 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 -a

One 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.

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”
  1. Shell commands are path-checked for two families only, not classified. A Bash command 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.json or .claude/settings.local.json is refused, while a read-only command on the two built-in globs passes (see below). The policy’s own AUTO/GATED/KERNEL rules are not consulted for shell commands. Worst case: a KERNEL path 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.
  2. Product code is not classified by default. Only .tyran/, .claude/ and hooks/ fall under the policy’s default:; everything else needs an explicit rule. Worst case: a user who believes the policy covers their whole tree gets no refusal for src/** until they write the rule. The alternative was measured and rejected above.
  3. A repository with no .tyran/ directory is left alone (except for the read rule, which needs no configuration). Worst case: rm -rf .tyran disables 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 to doctor.
  4. The read rule is a denylist and is therefore incomplete. A credential in a file called notes.md is 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 _sk hardware 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 every ID_-prefixed constant: the old rule was ^id_[a-z0-9]+$, which read ID_ISSUED and ID_TOKEN as 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 .key or .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 for Read, which is not subject to the word test.
  5. GATED in a supervised main loop relies on the platform actually prompting, and the gate cannot tell whether it did. Measured by review: permission_mode stays default when the user has allow-listed a tool, and a hook cannot read those settings. So default means “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-listed Write(*) gets GATED behaviour equal to AUTO in the main loop. Review raised P1 to P3 from an unattended main loop with no refusal.
  6. The deployment class is stored in a file the shipped policy now classes AUTO. .tyran/config.yaml was GATED; limit 5 above had already measured that gating it was convention rather than mechanism wherever Write is allow-listed, and a second install measured what it cost when it did bite: setup inferred pnpm test, which in that repo is bare vitest and 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 own autonomy: 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 to GATED in .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 class AUTO file 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.
  7. Only the argv of git push is modelled, not every publishing command. gh release create and friends are the secrets gate’s business; the deployment class does not see them.
  8. 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.

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.

flagwhat stops being refused
outside_repo: allowthe “outside the repository” row of the matrix, for every actor — the row becomes pass rather than deny
credentials: allowthe credential-shaped read rule, on Read, Grep, and in the text of a shell command
path_classes: allowverdictForClass is not consulted: a rule-derived GATED or KERNEL passes
push: allowthe 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: skipnothing. 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/STOPhooks/**, .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.

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 for Bash: grep in 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, cp and base64 all publish the bytes, and the distinction between reading and copying is not one a gate reading text can make. Reads and writes alike, because the Read tool refuses this family too;
  • a path under hooks/**, .tyran/policies/**, .claude/settings.json or .claude/settings.local.json. This family is a write rule: on the two built-in globs a read-only command passes, because Read passes 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

call0.1.150.1.16
Read FILEpasspass
cat FILEdenypass
grep -n X FILEdenypass
wc -l FILEdenypass
node --check FILEdenypass
git log --oneline -- hooks/passpass

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, node or git, matched exactly on the program name. /bin/cat and CAT are the same program; catnip is not, and neither is constructor;
  • for git, the word immediately after it is log, show or diff. A global flag before the subcommand refuses, because git -c core.pager=… names a program for git to run and planCommand’s alias check only covers alias.;
  • for node, --check (or -c) is required rather than merely permitted: node FILE executes 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_RULES and with none of the filters the path findings go through. The findings are computed from a stripped copy of the command — stripMessageArguments removes a message-bearing flag together with its argument, and non-literal tokens are dropped — so a credential can be invisible in them. -t is git commit --template and a legal diff flag, which made diff -t .env FILE read 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 -- FILE loses 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:

refusedwhy
sed -n 1,20p FILE · awk NR<10 FILEthe 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 FILEexecutes it
git log --output=FILE · git show --output=FILEwrites a file — a real hole found in an earlier review of this gate
git -c core.pager=tee loga global -c names a program for git to run
rg --pre CMD · rg --hostname-bin CMDruns CMD once per file
tail -f FILEnever returns
cat FILE > /tmp/cat · cat $(cat FILE) · cat FILE | tee /tmp/xredirection, substitution, a pipe into a writer
the same commands on .env, id_rsa, .aws/credentialsthe credential family, unchanged
diff -t .env FILE · grep --file=.env FILE · grep -m .env FILE · diff ~/.env FILEthe 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.jsonbelow

.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.

Not a ceiling. SHELL_DECLARED_MISSES in the source is the same list:

  1. a path assembled at runtime — from a variable, a command substitution, a glob, or a file on disk. D=.tyran/policies; cat >> $D/autonomy.yaml gets through, and it is the route that stays open;
  2. a relative path resolved against the session directory rather than the one a cd moved to — stricter, but a different path from the shell’s;
  3. anything a script writes once it is running;
  4. a KERNEL path declared by the policy rather than built in — only SHELL_PROTECTED_GLOBS is checked, so a broken policy can never be the thing that stops an operator repairing it;
  5. a program an allowed reader is configured to launch — a diff.external driver or pager in git config, a NODE_OPTIONS carrying --require, a shell function shadowing cat. The flag table matches the command text, and the environment is not in it;
  6. a different program wearing an allowed one’s name. READ_ONLY_PROGRAMS is keyed on the basename, which is what makes /bin/cat and cat one entry — and also makes a repo-writable src/cat an allowed reader, with src/** classed AUTO in the shipped template. Declared rather than closed: requiring a bare program name would break /bin/cat and 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.

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

callmedianslowest
a tool naming no path (WebFetch)39 ms42 ms
a write outside the governed namespace39 ms44 ms
a write refused inside it39 ms59 ms
a read that passes42 ms113 ms
a Bash call with no push45 ms64 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.

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.yaml does 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.yaml is missing in a repository that HAS a .tyran/ directory, or does not declare a deployment class. It is not defaulted to P3, 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.

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, and git worktree add gives 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, and git push origin main passing;
  • a session running in the main checkout and writing into a worktree got normalizePath → null → KERNEL, so every Edit was refused as “outside this repository”. Five agents hit it in one initiative and all rerouted through Bash heredocs, 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.

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.

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’s path glob and its class, and points at the file;
  • the path glob is filtered to a glob repertoire and a length cap. That removes spaces — but not -, ! or ?, so ignore-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.