Security
6. Policy and autonomy
Autonomy is a per-repo/per-task policy table: action → auto | ask | forbid.
| Action | Default |
|---|---|
| Commit in the topic’s own checkout | auto |
Push an agent/* branch | after cleanup: the supervisor pushes the prepared branch once you approve it (§4.5); the agent never pushes |
| Open/update PR, comment on issue | auto, after the push |
| Write the project board (a task’s card status, session and link; the supervisor’s own action, never an agent’s, D30) | auto |
| Merge, tag, release, deploy | forbid for the agent; human-gated |
| Sensitive actions triggered by untrusted input | ask |
Two limits hold whatever a repository’s table says (issue #51). Merge, tag, release and deploy are forbid, and an agent push is at most ask: the supervisor pushes only after approval (§4.5), so an override can tighten these but never loosen them. A mode other than auto, ask or forbid, and an action the table does not list, is forbid.
Starting a run is not an agent action and is not in the table: only the human starts a run (whr run, the UI, or accepting a card’s Decision), and a forge event or webhook only adds to the inbox (D40). No setting turns an event into a start.
Enforcement is outside the agent: forge branch protection, required human review, and a bot identity that cannot bypass them. Approval is per commit SHA (ties to ReviewCandidate). Every approval is a Decision record.
Workflow presets (D47, issue #105). Each repository names one in the supervisor’s configuration (repositories[].workflow); the repository never chooses it. A preset sets the rows above, within the two limits, and the forge flow around them:
prototype | integration (default) | published | |
|---|---|---|---|
| Approved commits go to | the integration branch, fast-forwarded to the approved SHA; no PR | a PR into the integration branch (develop) | a PR into the default branch |
| Approval | “Ready to push?” per head SHA; one Decision may cover a topic’s series | per SHA, plus the forge’s review | per SHA, the forge’s review and required checks |
Promotion to main | the human | the human merges and promotes | not applicable |
| Agent permission mode | dontAsk with the allowlist | dontAsk with the allowlist | manual (host approvals, §4.2) |
| Egress requests (§4.2) | asked once per repository | asked once per repository | asked again when their source changes; lockfile suggestions ignored |
Ruleset whr doctor expects | force push blocked on the integration branch; the default branch (~DEFAULT_BRANCH) restricts updates or requires a PR, with no bypass for the App (#217) | the integration branch protected, PR required | the default branch requires review, checks and signed commits; no bypass actor |
Four rules close the gaps between the presets and the rest of the design:
- The supervisor never moves the default branch, in any preset.
prototypeneeds an explicitly configuredintegration_branchthat is not the default branch; without one the configuration is refused. Promotion to the default branch is always the human’s. - The environment comes from the default branch only (D38): the devcontainer file, the Dockerfile,
postCreateCommandand the egress requests are read at the default branch’s commit, never at the integration branch’s, so an approved agent commit on the integration branch cannot configure the agent’s next environment. - The default branch is read from the forge each time it decides something (issues #201, #203). The check that refuses to move the default branch and the choice of the commit an environment is read from ask the forge for the repository’s default branch at that moment, never a value kept from an earlier answer, because the human can change the default on the forge at any time. When the forge cannot answer, the fast-forward is refused and the environment is not read (the zero environment, as for an unreadable repository). A change between that answer and the forge write cannot be closed by the supervisor; the ruleset on the default branch, which
whr doctorexpects inprototype, the one preset that fast-forwards, and which follows a change of default because it targets~DEFAULT_BRANCH, is the independent control. - A task never runs looser than the repository. A task keeps the branch it started with, and publishes under the stricter of its own preset and the repository’s current one; changing
integration_branchis a policy change like changing the preset (refused at start until accepted, and audited). A task’s stored preset that cannot be read (a corrupt value, a preset name that no longer exists) fails the publish closed: nothing is pushed and the error says why; it is never read as “no preset” (issue #236). The same holds wherever a task’s preset decides something: the egress gate at a run’s start (whether old answers and suggested hosts still count) and the agent’s permission mode refuse to start the run when the stored preset cannot be read, never falling back to the repository’s preset (issue #256). Only an empty value, a task from before presets were recorded, is no preset.
The fast-forward of prototype is never forced: a branch that moved is refused and the topic is rebased (§4.2). It is the human’s approval carried out by the supervisor, so the floor holds. Changing a preset is a policy change: it is audited, it needs a passkey step-up when confirmed on the web (/changes, #107), and a looser preset never applies to a run already started.
Agent permission modes. The agent CLIs have their own coarse modes. In the spike with Claude Code (a fixed allowlist of Read and a few harmless shell prefixes) they behaved as follows for a file write:
| Mode | Behaviour |
|---|---|
manual | Asks (an approval Decision, §4.2) |
acceptEdits | Writes without asking |
dontAsk | Denies silently |
plan | Plans read-only, then asks the human to approve the plan |
auto | Identical to manual in the test: read-only commands such as pwd and git status ran, and a file write, touch, curl and rm were all asked |
Rules for using them:
- Offer them as per-session presets over the action table, never as the policy itself. The table is per action and enforced outside the agent; the modes are coarser and live inside it.
- Do not count on
autoto reduce prompts: in headless mode it asked exactly as often asmanual. Whatever it is meant to do is not visible there. bypassPermissions, which switches every prompt off, is never offered by default and never outside an isolated environment.- A mode is fixed when the agent process starts. Changing it on a running session restarts the process with
--resumeand keeps the session and transcript. - Prefix allow rules such as
Bash(ls:*)do not match a compound command likea && b; the CLI asks about the whole command. An allowlist needs a rule for compound commands (match each part, or ask).
7. Security
The rules below are the security requirements. The threat model says what each defends against, where it is enforced and tested, and which risks are accepted (issue #11).
Untrusted input. Issue text, PR comments and CI logs are untrusted. Use trust tiers by author (owner vs external); hold or flag runs on issues from unknown authors. A run that combines private data, untrusted input and outbound network requires approval. Untrusted text is inert where a human reads it (issues #205, #223): agent output, issue and PR text, CI logs, Decision inputs and error texts that quote them reach the supervisor’s log and a human’s terminal only with their control characters escaped or replaced (C0 except tab, DEL, C1, the bidirectional controls and the line and paragraph separators U+2028 and U+2029), so they cannot forge a log line or drive the terminal with escape sequences; JSON output escapes them by its encoding, and the web UI renders such text escaped (T20). An interactive terminal the human opens into the console environment (
whr console, SSH; D43) is not textwhrrenders: today it carries the guest’s terminal stream as it is (D43), and the human’s terminal emulator, a host process, parses it. Whether that stays so, with its risk accepted, orwhr consoledrops OSC 52 and the other OSC, DCS, APC, PM and SOS sequences is open: Werner decides (threat model, Accepted risks; issue #223).Default-deny egress through a logging allowlist proxy: forge, package registries, LLM API only. Block LAN, host, other workspaces and cloud-metadata addresses.
Verified on Apple Container in spike #2 (issue #2). The default network gives none of this: a guest reaches the internet, the LAN, other containers and any host service bound to all interfaces. What works:
- One
--internalnetwork per environment. It blocks the internet, DNS (names do not resolve), other LAN devices, IPv6 and containers on other networks. It does not block the host: issue #69 measured that an internal guest reaches host listeners bound to the Mac’s LAN address or to all interfaces, through the network’s gateway; spike #2’s earlier “blocks the host through every address” was wrong. Listeners bound only to loopback stayed unreachable. Agents on the same internal network can reach each other, so a network is never shared between tasks. - The proxy runs in a sidecar container, attached to the default and the internal network (
--networkrepeats). The host cannot serve an internal network because it gets no interface on it, so a host-side proxy cannot bind to its gateway. - The LAN is refused over IPv6 too. Besides the private and reserved ranges, the proxy refuses every global IPv6 prefix assigned to the host’s own interfaces, read when the environment’s spec is built and passed to the sidecar, so an allowlisted name whose DNS points at a LAN address over IPv6 is refused like one over IPv4 (issue #124). Implemented and tested with fake prefixes; not run against a sidecar with real global IPv6, and an interface with a /128 mask yields only the host’s own address, not its LAN, which is not measured on macOS (unverified, §5.6).
- An entry matches exactly one host name.
github.comadmitsgithub.com, notapi.github.com. A wildcard*.example.com(every subdomain, not the bare name) is allowed only in the supervisor’s own configuration and its built-in list, never through a repository’s request: an egress request fromdevcontainer.jsonor a lockfile is one exact host, a request containing*is refused, and the Decision the human answers names exactly that host (§4.2). Approvinggithub.iotherefore never opens every user’s*.github.io. - Allowlist by hostname, with the name resolved by the proxy. Allowed hosts returned 200, denied hosts and a raw-IP CONNECT got 403, and every decision was logged with time, verdict, method, host and source. The guest needs no DNS, which closes DNS exfiltration. The proxy allows only port 443 for CONNECT and port 80 for plain HTTP, refuses a name if any of its addresses is loopback, private, link-local, CGNAT, multicast or otherwise reserved, and dials the address it checked, so a rebinding DNS answer is never used (#78). A tunnel closes after five idle minutes, and the sidecar runs with one CPU and 256 MB. Limits: the match is on the name in CONNECT, so it does not defeat domain fronting, and the sidecar has full egress and is trusted.
- Image builds are outside the allowlist. Building an environment from a repository’s Dockerfile (D38) runs in the builder VM with its own network;
container build1.5.0 offers no network option. Only the default branch is built, with no credentials, so this is an accepted risk in the threat model. - Minimal allowlist for Claude Code:
api.anthropic.comalone. In an authenticated run inside a container the proxy also saw a telemetry host (http-intake.logs.us5.datadoghq.com) and denied it; nothing broke. Installing needsclaude.aianddownloads.claude.ai, which the tool store (§5.6) removes. A client that obeys proxy variables, such ascurl, tests the proxy and not the network; test the direct path with the proxy variables ignored.
flowchart LR phone["Phone or tablet"] -->|"tailnet, API token"| fwd subgraph host["Mac mini"] fwd["tailscale serve"] -->|"loopback"| whr["whr serve on 127.0.0.1"] pf["pf: container subnets blocked from the host's addresses"] end subgraph internal["--internal network, one per environment"] env["Environment: agent, no DNS"] end whr -->|"container exec, stdio"| env env -->|"HTTPS_PROXY"| sidecar["Egress sidecar: whr-proxy, on both networks"] sidecar -->|"allowlisted names, ports 443 and 80, public addresses"| net["Forge, LLM API, registries"] env -.->|"no route"| lan["Internet, LAN, other environments"] env -.->|"loopback not reachable"| whr- One
Credentials. Run-scoped, short-lived, single-repo, non-extractable. GitHub App installation tokens (~1 h); per-repo bot tokens or deploy keys for Gitea/Forgejo/GitLab. Inject through a git credential helper or host-side proxy so raw tokens never reach env vars, disk or logs. The push credential (D51, built in #252): the forge adapter’s
Pusher, which only the Guard calls after its approval check (§4.5), mints for each push an installation token for the one repository withcontents: writealone, registers it with the redactor, hands it tohostgitas a value and revokes it when the push returns, on every path.hostgitgives it to git only through a credential helper fed by an inherited pipe, never in an argument, the environment, the remote URL, a file or a log; the remote is the https URL built from the configuredowner/name, never one read from a repository. A secret over a pipe (Werner’s decision on #244; D51): the pipe carries the push token from the supervisor’s memory to the one child process it starts for it, and only there: its descriptor is close-on-exec in the supervisor and passed to that child alone, so no other process the supervisor starts inherits it; what reads it iswhr’s own code (git and the helperhostgitsets, with hooks off), never a hook, a script or a program from a repository or a guest, and the token is never written to argv, the environment, a URL, a file or a log on the way. The release 1 API key is the accepted exception (D48, T10): it also leaves the supervisor’s memory only over a close-on-exec pipe passed to one child, never argv, a file or a log, but that child is Apple’scontainer exec --env-file /dev/fd/3, third-party code, which hands the environment to Apple’s container services (over XPC, as D25 shows) and from there into the VM, whose handling of it, logged or kept, is unverified; the key ends up in the environment of the agent’s process in the guest, until the sidecar’s gateway holds it (D48). For the push, the genericcredential.helperis reset and the pipe’s helper is set with-conly for the scheme and host of the configured forge’s base URL (credential.https://<host>.helper), withhttp.followRedirectsoff, so a redirect to another host gets no credential (review of #252, measured there with git 2.56 against loopback servers). That the pipe reaches the helper through git’s own subprocesses is measured withgit ls-remoteover loopback http (TestTheHelperIsScopedToThePushHostAndRedirectsGetNoCredential, #252); an https push to the forge is unverified until #28’s live run. Inapi-keymode the LLM API key stays in the sidecar’s model-API gateway and the agent holds only a per-run token, revoked at run end (D48; release 1 still hands the key to the agent’s process, through a pipe at/dev/fd/3, never a file or a command line). Insubscriptionmode the consumer-plan login lives inside the environment (§5.2), is long-lived and not scoped to a repo, and leaks if the agent is compromised. The human signs in there through the vendor’s own flow;whrnever reads, stores, relays or logs that credential (D40). The sign-in shell (issue #281):whrmay open the human’s terminal in a workspace’s environment as the agent’s user, with the agent’s CLI onPATHand its auth directory and proxy variables set as for a run, but the terminal never passes through the supervisor: the API returns only the environment and those variables, and thewhrprocess on the host then replaces itself with the runtime’s own interactive exec, so what the human types and what the CLI writes reach no relay, file, log or web UI ofwhr. It adds no secret to that environment, types nothing for the human (the human runs the vendor’s own sign-in) and is refused while a run of the workspace is live; signing in from the web UI or the phone stays open (spike #82). Accepted risk for a single-developer, watched personal tool; limit it with a dedicated auth directory per environment (never$HOME), the egress allowlist, and revocation at the vendor when an environment is deleted. Revoke run-scoped credentials at run end. Redact secrets at ingest, before anything is stored (§5.4). Agent and CI credentials are separate. Backups (issues #201, #211): nowhrcommand or documented procedure copies an agent-home volume, because it holds the subscription login (D40) as well as the agents’ sessions; the manual tells the human to exclude the volumes from the host’s backup. After a restore without them the human signs in inside the environment again, and a run whose session is gone cannot resume and endsfailed(§4.1). The database is backed up only withwhr servestopped, its WAL files with it, or as a consistent snapshot, never as a copy of the live file alone. The secret files (§5) go only into an encrypted backup.Isolation policy, testable. Reject mounts of
$HOME,~/.sshand runtime sockets. Non-root agents, read-only rootfs where feasible, hard CPU/memory/disk quotas, per-run timeout and token/cost budget. Escape tests (guest cannot reach host or Socktainer socket) in the conformance suite. The VM boundary does not protect what is deliberately exposed.Measured in spike #2:
- The runtime does not reject mounts. It mounted
/etcwithout complaint, so the adapter enforces the deny-list. It resolves symlinks first (a symlink to$HOMEis rejected), then rejects$HOME, its parents, secrets directories, unix sockets and runtime socket directories. The spike’scheck_mountwas the seed; the rules now live inruntime.CheckMount(issue #18):- A source must be an absolute path that resolves; otherwise it is rejected. Read-only makes no difference.
- Rejected: a unix socket; the home directory and every parent of it (
/Users,/); the secrets under the home directory (.ssh,.gnupg,.aws,.azure,.kube,.docker,.config/gh,.config/gcloud,.config/op,.netrc,.gitconfig,.git-credentials,.npmrc,.pypirc,.password-store,.claude,.codex,Library/Keychains,Library/Containers,Library/Group Containers(the 1Password agent socket) andLibrary/Application Support(browser cookies)) and any directory that contains one, such as~/.configor~/Library; the runtime socket directories (~/.socktainer,~/.docker/run,~/.orbstack,~/.colima,~/.lima,~/.local/share/containers,/var/run,/private/var/run,/run) and their parents; the system roots/,/Users,/Users/Shared,/home,/private,/var,/private/var,/private/var/folders,/tmp,/private/tmp,/Volumesand/Library; and the system trees/etc,/private/etc,/System,/dev,/proc,/sys,/boot,/root,/private/var/root,/Library/Keychainsand/private/var/db. Everything below/Volumes(another disk can hold a copy of the home directory) and below/private/var/folders(the user’s$TMPDIR) is rejected too, unless it lies inside the home directory. A subdirectory of/tmpis not rejected. - A secrets path is resolved too.
~/.config/ghor~/.sshis often a symlink into a dotfiles directory (stow, chezmoi). Each secrets path is checked as written and after resolving it, so a mount that equals or contains either is rejected, and mounting the dotfiles directory is refused. - Links inside a secrets directory are followed one level. GNU stow links
~/.ssh/id_ed25519to~/keys/id_ed25519when~/.sshis a real directory. The direct entries of each secrets directory that are symbolic links are resolved and their targets are protected like the secrets themselves, so mounting~/keysis refused. Links deeper in the tree, and hard links, are out of scope. - Mounts below workspace roots (decided, #58). As a second layer, the supervisor passes the workspace roots it owns (the directories it creates checkouts in), and
CheckMountsWithinaccepts a bind mount only if it lies inside one of them. With no roots configured the deny-list above is all there is. A mount outside every root is refused with the reasonoutside the workspace roots. The deny-list stays first, so a root cannot widen it. - Paths are compared by file identity, not by string. A path equals, lies below or contains another when the filesystem says it is the same file, so case differences on a case-insensitive volume and a precomposed against a decomposed Unicode name (a home such as
jürgenis stored decomposed on macOS) are the same path. A path that cannot be examined (it does not exist) is compared by its lower-cased string. - A missing home directory fails closed with an error that is not a forbidden-mount error, because it is a caller bug.
- Mounted unix sockets are unusable. A host socket in a bind-mounted directory could not be listed or connected to, including the real Socktainer socket, and the host listener saw no connection.
- Hardening flags work:
--read-only --cap-drop ALL --user 1000:1000 --tmpfs /tmpgave an empty capability set, a read-only root filesystem, a writable/tmpand a failingmount. Use them where the agent allows. - Never
--ssh, which forwards the host ssh-agent. Nevercontainer rm --all. - Host services are reachable from every guest, default-network and
--internalalike, when bound to the LAN address or to all interfaces; a service bound only to loopback was not (issue #69). That includes macOS’s own services (Remote Login, Screen Sharing, File Sharing). So: supervisor listeners bind to loopback only (D29); host services that listen on all interfaces are turned off or hardened (SSH without passwords); and apfrule blocks the container subnets from the host’s addresses (to be measured, issue #69). The macOS Application Firewall’s effect was not measured. - Not probed: the vsock and vfio device nodes in the guest,
--publish-socket,--virtualizationand Rosetta. - Agent-writable repositories are hostile input to the host (spike #2, item 9). A pre-commit hook and a
core.fsmonitorcommand planted from inside a guest ran on the host when the host later ran plaingit commitandgit status. The host therefore never runs git in an agent-writable tree (D42): an agent’s commits leave as a verified bundle streamed from the environment, and cleanup and push run throughhostgit(isolated configuration, hooks and fsmonitor off) on the supervisor’s own repository (§4.5). - Never mount the human’s own repository’s
.gitinto an environment. A shared.gitexposes every branch, the shared hooks and config and the other worktrees’ metadata. Agents work in a workspace’s own agent clone (D42); its agents share that.gitas one trust domain, and the host reads their commits only as a verified bundle (§4.5), never by running git in the workspace. 4a. Images the supervisor builds are never pulled (#133). Every imagewhrbuilds (environments, the base image, the console) is named under the reserved registry hostwhr.invalid/, which no registry answers (RFC 6761), never under a bare name that normalises todocker.io; creating an environment from such an image uses the local copy only, and a missing one is built, never fetched. A name an attacker could publish on a public registry would otherwise supply the agent’s or the console’s image on a local miss (review of #108). Measured on container 1.5.0 (TestMissingBuiltImageIsNeverFetchedLive): a bare name reachedregistry-1.docker.io, while a missingwhr.invalid/image fails closed incontainer createand in a build’sFROM, andProvisionrefuses it first. The egress sidecar and the console’s sidecar run supervisor-built images too and fall under the same rule, checked locally before they are created. Two images stay outside it on purpose: an operator-configuredenvironment.image, the operator’s choice; and a repository’s own devcontainerimage, which the repository chooses (D38) and which is pulled as it names it. A repository-chosen image (a devcontainerimageor a Dockerfile’sFROM) may never namewhr.invalid/: that host is the supervisor’s alone, and such a file is refused (#133). The check scans the file’s text and itsbuild.argsforwhr.invalid, after removing quoting and line continuations; a name assembled fromARGpieces (FROM ${H}.${D}/x) is not caught, which a test pins as a known limit. The exposure is one repository building on another repository’s locally built image within one supervisor and one human, which is accepted (all repositories of a supervisor belong to the same human). Measured (spikebuilder-store, container 1.5.0):container buildwithout--pulldoes buildFROMa localwhr.invalid/tag, so the scan matters; what it misses stays small, because an environment image’s tag carries a digest of its repository’s commit and build inputs and cannot be guessed by another repository, and the base and console images hold no repository content. A# syntax=frontend other thandocker/dockerfileandBUILDKIT_*build arguments are refused;COPY --fromandRUN --mount from=also use a localwhr.invalid/image without--pull, and the scan refuses both (verified, spikebuilder-store).
- The runtime does not reject mounts. It mounted
Supervisor identity. Sign-in with a passkey (or, before one is enrolled, the API token on the host’s own browser), short-lived sessions, scoped revocable CLI tokens, CSRF protection, the web UI bound to loopback only and reached remotely through a forwarder, never on all interfaces, and the JSON API on a host-only unix socket that no forwarder carries (D29), forge tokens encrypted at rest, webhook signature verification. Link accounts by provider instance + stable user ID, never by email. On the phone and tablet the human signs in with a passkey that requires user verification, and a sensitive Decision (“Ready to push?”, an egress host, a policy change, a secret operation) needs a fresh assertion whose challenge names the Decision and its commit SHA (D45); the static API token is for the local CLI only, there is no TOTP, and the admin on the host enrolls and revokes passkeys. Two listeners (D29, D45): the JSON API and its token exist only on the host’s unix socket; the forwarded loopback listener carries the web UI alone, where every sensitive answer needs a passkey step-up. A request that reaches
/v1through the forwarder is impossible by construction, not refused by a header check. Passkey rules (D45, issue #101): a web session alone never answers a review or an egress host; a step-up assertion names the Decision and its commit SHA or host, is single-use, lives two minutes on the server, is bound to the session, and must still match the Decision as it is when it is checked; passkeys are enrolled and revoked only from the host CLI (whr passkey), never from the web UI; once a passkey exists, the API token no longer signs in to the web UI. Sign-in ceremonies live in their own pool that evicts its oldest entry when full, apart from step-up and enrolment ceremonies, and there is no failure lockout: an unauthenticated flood can delay a new sign-in for as long as it lasts (the begin guard still refuses when full), but signed-in sessions, step-ups and the host CLI are unaffected. HTTPS is decided by the configuredpublic_url, never by a request header such asX-Forwarded-Proto: a request counts as HTTPS only over TLS or when it arrives for thepublic_urlhost, and then the UI setsSecure,__Host-cookies; plain loopback HTTP stays plain. Previews (D33, issue #72): the preview proxy replaces the app’s Content-Security-Policy withdefault-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval'; style-src 'self' 'unsafe-inline'; connect-src 'self'; img-src 'self' data:; form-action 'self'; base-uri 'none'; frame-ancestors 'none': every fetch directive stays at'self', so a preview’s script cannot send data to another site by fetch, image or form, while inline scripts andevalstay allowed because dev servers need them and they open no path out; a preview opened from the web closes when the last web session that owns it ends (sign-out, revoke or expiry); a preview opened from the host CLI is the host’s and stays until its environment closes. A policy or preset change confirms in the web UI only with a step-up that names the change; the supervisor keeps the recorded workflow until then and applies the change at its next start. The one secret operation on the web is revoking the forge tokens, with a step-up that names it; no secret value is ever entered, shown or stored through the web UI (issue #107).SSH/IDE access. Short-lived per-session SSH certificates or keys, no password auth, jump host only over VPN, code-server never public and always authenticated, treat Open VSX extensions as supply-chain risk. SSH and
whr consoleland in the console environment (D43), never on the host; logging in to the host is for the admin. The console mounts workspaces read-only by default, holds no credentials, and runs git with hooks and fsmonitor off.Audit and kill switch. Tamper-evident append-only log stored outside the workspace, linked to commit SHA. It lives in the supervisor’s database,
workharbor.dbin the supervisor’s data directory, which no environment mounts: theaudittier of theeventstable (triggers refuse to change or delete a row) and the hash chain over it in theaudit_chaintable (§5.4). There is no separate audit file, so a backup of the database is a backup of the audit log (§7.3).whr kill-allstops all runs and revokes tokens. Alert on anomalous egress or token spikes. Bot commits are signed with the bot key before push (§4.5). The bot key (D51; its configuration andwhr doctor’sbot-keycheck built in #251, its use on the production path in #253) is an unencrypted SSH ed25519 private key (git signs without a prompt) in a0600file owned by thewhruser, named bybot_signing_key_file(one of the secret files of §5), outside every workspace root and never mounted into an environment; thewhruser creates it on the host (ssh-keygen -t ed25519 -N ''run as that user; a key owned by another account is refused by the configuration), andwhr doctorchecks its mode and owner. A missing or unreadable key fails the prepare closed: nothing is ever committed or pushed unsigned. Whether GitHub shows a commit signed with a key that no account holds as verified is unverified. If it does not, a rule on the default branch that requires signed commits would refuse a merge commit of awhrPR, which the default-branch ruleset blocks anyway (D15), and a rebase merge, which that ruleset does not block (both unverified), and leave a squash merge, which GitHub signs itself and which drops the bot’s signature (unverified). Commits an App creates through GitHub’s API are signed by GitHub; whether to publish that way instead is open.Plugins. A plugin handles sessions, credentials and workspace access, so it is a supply-chain risk. Default deny: plugins are installed only by explicit developer action, from a pinned version or hash, and run out of process with the same isolation as any agent environment. They never receive host credentials,
$HOME,~/.sshor runtime sockets; they get only the per-run credentials a built-in adapter would. Their capabilities are checked by the conformance suite, and every plugin action appears in the audit log.
Separate identities: login identity, connected forge accounts, agent (bot) identity, supervisor sessions.