Safety rails
What an unattended session may and may not do, and what stops it.
مستندات به انگلیسی نوشته شده است. بقیهٔ سایت به فارسی خوانده میشود.
Things that stop the system hurting you, all of them mechanical.
Phase locks. Starting a phase claims it — a small lock file in your repo recording who holds it, a lease that auto-expires, and the scope it is working in. If a second session finds the phase held by a live session, it stops and asks rather than building the same phase twice. Locks are committed, so they work across machines and accounts.
Scope decides who may run beside you. What makes two sessions dangerous is a shared working tree,
not the mere fact of being two — so the rule is about scope: never two live sessions whose scopes
intersect; same repo ⇒ serialized; all ⇒ exclusive against every unqualified claim; disjoint ⇒ parallel. Scope comes from the plan's
Repos column, and phase-lock.sh <slug> conflicts <N> --scope "<csv>" answers the question across every
plan before you start — a working tree doesn't know which plan asked for it. Every boot prompt states
its phase's scope and the command to check it. A phase that declares nothing counts as all and runs
alone; still want to overlap two sessions on one repo? Give each its own checkout or git worktree.
Never stash to hand off. A git stash lives in one working tree and is invisible to every other
session and clone. Commit instead — even a WIP commit. The filesystem of a closed session is not a
channel; git is.
Verification before handoff. A phase's own verification commands must be green before it can be
handed off as complete. Red work is handed off as blocked, with the failure recorded, so the board
shows the truth.
Structural validation. scripts/validate.sh <slug> catches malformed graph rows, dependencies on
phases that do not exist, cycles, invalid handoff statuses, missing required sections, and handoffs
whose declared dependencies disagree with the plan. Since 5.0.0 three checks that were advisory fail
it too, each naming itself in the line so a failure can be grepped for: F14
verification-empty-open (an open phase whose §Verification holds nothing runnable), F24
gate-directive-missing and gate-type-unknown (a *(GATED)* heading with no Gate-check, or a
directive whose type is not on the list), and F25 decision-outstanding-unowned,
decision-key-unknown and decision-state-unknown (a ## Decisions row nobody owes, or a key or
state outside the vocabulary). Run it before trusting a board.
Explicit-path commits. Never git add -A — a phase commits the files it touched, so unrelated
work in your tree is not swept in.
The one deliberate hole in the push wall. The console's autopilot hard-denies git push for
every session, at two layers (its approvals hook and the CLI-side deny list it writes into each
run's settings). The single exception is a run started with the work branch + open a PR
options: for that run — and only that run — bare git push moves from the wall to an approval
card, and gh pr create stays a card even under the trusted profile, so publishing the branch
still takes one human tap — auto-grant never answers either card; only a permission.destructive
exception in the plan's manifest, for the running phase, lets the console answer one, and that answer
is announced. The answer is judged on the words the line RUNS (runner/shell-reading.ts), never on
data — a quoted here-doc's body, an interpreter's -c payload, a quoted argument — and a push the row
allows only in its bare form is refused at once, naming that form (control-tower phase 107).
Force-pushes, --force-with-lease and --delete stay denied outright.
Residual risk, stated plainly: with the console process dead its hook cannot ask, and that run's
CLI-side deny list no longer contains bare git push — the destructive shapes still do.
The carve-out narrows a wall that stands; it never rebuilds one that does not. Since shipped deny
rules became strikeable from Settings (by name, behind a confirm), an operator who struck
Bash(git push:*) has made pushing their standing policy — and a PR-run's carve-out will not
quietly re-add the force-push denials on top of a wall they removed. The two asks (git push,
gh pr create) stay pinned either way: one human tap to publish is about the run's shape, not the
wall.
The console's own push is a second door, and a narrower one. --allow-publish is the eighth
capability flag, off by default like the other seven, and the only way this console itself ever
reaches a remote: a finished phase's own pe/* branch, pushed to origin through one seam
(pushRef in runner/worktree.ts) with one frozen argv — git push --porcelain --no-follow-tags origin refs/heads/<ref>:refs/heads/<ref>. Never a trunk, never pe/integration, never with force,
never a delete, never a second try after a non-fast-forward rejection, never a name outside
^pe/[A-Za-z0-9._-]+(-p\d+)?$ — a ref outside that shape is refused before git is spawned. Two
conditions must both hold, the flag and a permission.destructive row in the plan that allows
git push; without either the landing parks with phase.landing-push-refused {reason} and nothing
reaches the origin. A free console parses the flag and pushes nothing. The wall above stands
untouched: the landing session the console boards afterwards never pushes — the push was the
console's act, and the deny wall still refuses it — and its two acts, gh pr create and
gh pr merge, are pinned asks under every permission profile, answerable without a person only by
that same permission.destructive row.
The console's issue filing is the same door's other half, and narrower still. Under
--allow-issues — a flag of its own since control-tower phase 115, which --allow-publish implies
and which never pushes — the console may file an issue, comment on one, label one or close one on the
repository's behalf — never on a session's say-so alone. A session records a DRAFT (scripts/phase-issue.sh: one
ledger line, no gh, refused outright when the plan says off), and whether that draft becomes an
act is decided by the plan's Issues: word AND the flag, both: file files a file or comment
draft at once, draft files it on a person's Approve, a close files on a person's Approve alone
and — where the phase lands by policy — not before its landing has landed. Every act is one call of
one writer, viewer/server/pro/issues/write.ts, with a fixed argv that is never spread from input
(gh issue create --repo <owner/repo> --title … --body-file <tmp> --label … · gh issue comment <n> --repo … --body-file … · gh issue close <n> --repo … --comment …), an environment built rather
than inherited (GH_REPO cannot redirect a write), the body in a 0600 file removed after the call, a
provenance footer naming the plan, phase, run, session, trace and console, and the labels — its type,
severity:<level>, awaiting-plan and from-session — so a reader of the issue can always tell it
was a machine's act and whose. A label the repository lacks is created first (gh label create,
never --force, so a label a person coloured is never repainted), and the relabel is gh issue edit <n> --add-label with no other editing flag. issues-readonly.test.ts allows exactly those verbs —
and label list and repo view, two reads — in that one file and fails on a gh write anywhere
else in the server. Two holds stand between a file word and the writer, and neither drops
anything: past the console's per-day filing rate (20 a day by itself; a person's Approve never counts)
a draft waits and is filed in order once the rate allows, and a critical or security finding bound
for a repository that is not private or internal waits for a person, who decides whether to publish
it. And the session's own hands stay off gh:
gh issue create, gh issue comment and gh issue close are ISSUE_ASK in runner/approvals.ts,
pinned asks under every permission profile on the same one-tap terms as OPEN_PR_ASK, so a session
that runs one itself meets a card whatever the plan's word — the flag opens the console's door, never
the session's. A free console parses the flag and files nothing.
Every session carries two caps. Every claude -p the spawn door starts passes --max-budget-usd
and --max-turns, and its phase.session line names the policy that set each — and, for a measured
cap, how it was measured. With no phaseBudgetUsd on the run, the caps are measured: each mode's p99
over the console's own sessions of the last two weeks plus 50 %, else the shipped table ($120 and 490
turns for a phase; deriveCapTable, viewer/server/runner/session-record.ts, control-tower phase
59); a side session gets a quarter of those dollars, never under $1. A cap that bites is not a crash: the same session resumes
under double that cap (phase.resume {raise}), so the numbers bound a runaway without cutting a long
phase. What a size buys: Session budget §3.
The start ceiling. Every door that starts a claude with nobody asking — boot re-adoption, the
wait clock, convergence, the MCP health probe, the automatic reviewer and the rest of START_DOORS —
is bounded on its own, and since 5.0.0 their sum is bounded too: at most 40 automatic starts and $250
of reported session spend per sliding hour, per console (viewer/server/start-ceiling.ts; the
ceilingStartsPerHour and ceilingUsdPerHour knobs under Settings ▸ Automation, where 0 switches
one off). A refused start writes run.start-refused {door, ceiling, limit, count, until}, and the
announcement goes out once per window. A person's Start, Retry or Continue is never counted and
never refused — a ceiling that could stop an operator starting the run they are looking at would be
the console overruling them. The dollars are what sessions reported: a session whose cost never
arrived charges nothing.
The relay never answers what it must not. On a relay: last-resort run a question a session
raises (AskUserQuestion) is held 60 s for a person and otherwise answered by rule
(Decisions § The relay). Before any window opens, the relay checks the deny list
first — the call's tool, or a denied command written into an option — and a match never opens a
window. Then, in order: a multi-select question; an option carrying a destructive verb (a force-push,
reset --hard, a PR merge, a recursive forced rm, a publish, a table or database drop —
DESTRUCTIVE_OPTION_RE in viewer/shared/relay-model.js); a question raised while the run is halted
or parked; and the same question asked twice in one phase. Past those, a phase may put eight
questions to the relay. Every one of these goes to a person — phase.question-unanswerable {reason},
a needs-human park and one push — and the session is told to hand off and declare rather than
guess. (Recommended) is written by the session, which is why a destructive option outranks it.
The permission hook answers this machine only. POST /hooks/permission-request, the relay's
transport, takes the approval hook's credential — the per-run bearer token, compared in constant
time and dead the moment the run ends — and adds the presence hook's rule: a request that did not
arrive on loopback, or that arrived through the --remote proxy, is refused 403 before the token is
looked at (hook.rejected {reason: not loopback | not local}). A Serve handler pointed at the wrong
console cannot expose a permission-deciding POST.
The fleet supervisor is one more door, and a narrow one. It binds 127.0.0.1 and nothing wider —
--host is refused and it exits 2; Tailscale Serve is how it is published. A caller may never send
it the assertion headers (x-phase-console-fleet, x-phase-console-fleet-login): the supervisor
sets them on its hop to a console, so a request arriving with one is refused 400 assertion-sent. It
forwards no identity header its caller wrote; it checks the caller itself and asserts only the login
it verified. A console believes the assertion only with the bearer from <stateHome>/fleet/fleet.token,
only on a loopback Host (else 421 fleet-not-loopback) and never beside the proxy's own identity
header (403 fleet-and-proxy) — and it treats an asserted request as scope: remote, always, so
presence and the permission hooks stay shut to it. The supervisor does not proxy them either:
/c/<id>/hooks/… answers 403.
There is a second door, and it is opened by the profile alone. The supervisor's own loopback bind
never widens — that sentence stays true. What the machine profile's reach.mode: direct does is ask
the supervisor to open a separate HTTPS listener beside it, on the one address and port the profile
names: a literal, never a wildcard, and a public address only once reach.acknowledged names that
exact host. Nothing else opens it — not a flag, not a console, not a paired device, which meets 403
reach-settings if it tries. The door judges its caller itself before any console sees the request: a
Host it does not admit is 421, ten failures from one source (or fifty overall) in ten minutes is 429
with Retry-After, and an unpaired caller gets the pairing page and nothing else. A device that does
pair is asserted onward as device:<slug>#<id6> — a login shape no person can hold — and always as
scope: remote, so presence and the permission hooks are exactly as shut to a phone as they are to a
Tailscale caller. Its secrets live under <stateHome>/fleet/reach/, the directory 0700 and every file
in it 0600: the CA and its key, the leaf and its key, the device register, the outstanding pairing
codes, and the scrypt hash of the pairing password.