پرش به محتوا

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.