Skip to content

Security

6. Policy and autonomy

Autonomy is a per-repo/per-task policy table: action → auto | ask | forbid.

ActionDefault
Commit in the topic’s own checkoutauto
Push an agent/* branchafter cleanup: the supervisor pushes the prepared branch once you approve it (§4.5); the agent never pushes
Open/update PR, comment on issueauto, 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, deployforbid for the agent; human-gated
Sensitive actions triggered by untrusted inputask

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:

prototypeintegration (default)published
Approved commits go tothe integration branch, fast-forwarded to the approved SHA; no PRa 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 seriesper SHA, plus the forge’s reviewper SHA, the forge’s review and required checks
Promotion to mainthe humanthe human merges and promotesnot applicable
Agent permission modedontAsk with the allowlistdontAsk with the allowlistmanual (host approvals, §4.2)
Egress requests (§4.2)asked once per repositoryasked once per repositoryasked again when their source changes; lockfile suggestions ignored
Ruleset whr doctor expectsforce 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 requiredthe 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. prototype needs an explicitly configured integration_branch that 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, postCreateCommand and 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 doctor expects in prototype, 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_branch is 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:

ModeBehaviour
manualAsks (an approval Decision, §4.2)
acceptEditsWrites without asking
dontAskDenies silently
planPlans read-only, then asks the human to approve the plan
autoIdentical 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 auto to reduce prompts: in headless mode it asked exactly as often as manual. 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 --resume and keeps the session and transcript.
  • Prefix allow rules such as Bash(ls:*) do not match a compound command like a && 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).

  1. 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 text whr renders: 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, or whr console drops OSC 52 and the other OSC, DCS, APC, PM and SOS sequences is open: Werner decides (threat model, Accepted risks; issue #223).

  2. 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 --internal network 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 (--network repeats). 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.com admits github.com, not api.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 from devcontainer.json or a lockfile is one exact host, a request containing * is refused, and the Decision the human answers names exactly that host (§4.2). Approving github.io therefore 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 build 1.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.com alone. 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 needs claude.ai and downloads.claude.ai, which the tool store (§5.6) removes. A client that obeys proxy variables, such as curl, 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
      
  3. 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 with contents: write alone, registers it with the redactor, hands it to hostgit as a value and revokes it when the push returns, on every path. hostgit gives 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 configured owner/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 is whr’s own code (git and the helper hostgit sets, 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’s container 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 generic credential.helper is reset and the pipe’s helper is set with -c only for the scheme and host of the configured forge’s base URL (credential.https://<host>.helper), with http.followRedirects off, 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 with git ls-remote over loopback http (TestTheHelperIsScopedToThePushHostAndRedirectsGetNoCredential, #252); an https push to the forge is unverified until #28’s live run. In api-key mode 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). In subscription mode 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; whr never reads, stores, relays or logs that credential (D40). The sign-in shell (issue #281): whr may open the human’s terminal in a workspace’s environment as the agent’s user, with the agent’s CLI on PATH and 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 the whr process 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 of whr. 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): no whr command 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 ends failed (§4.1). The database is backed up only with whr serve stopped, 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.

  4. Isolation policy, testable. Reject mounts of $HOME, ~/.ssh and 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 /etc without complaint, so the adapter enforces the deny-list. It resolves symlinks first (a symlink to $HOME is rejected), then rejects $HOME, its parents, secrets directories, unix sockets and runtime socket directories. The spike’s check_mount was the seed; the rules now live in runtime.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) and Library/Application Support (browser cookies)) and any directory that contains one, such as ~/.config or ~/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, /Volumes and /Library; and the system trees /etc, /private/etc, /System, /dev, /proc, /sys, /boot, /root, /private/var/root, /Library/Keychains and /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 /tmp is not rejected.
      • A secrets path is resolved too. ~/.config/gh or ~/.ssh is 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_ed25519 to ~/keys/id_ed25519 when ~/.ssh is 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 ~/keys is 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 CheckMountsWithin accepts 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 reason outside 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ürgen is 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 /tmp gave an empty capability set, a read-only root filesystem, a writable /tmp and a failing mount. Use them where the agent allows.
    • Never --ssh, which forwards the host ssh-agent. Never container rm --all.
    • Host services are reachable from every guest, default-network and --internal alike, 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 a pf rule 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, --virtualization and Rosetta.
    • Agent-writable repositories are hostile input to the host (spike #2, item 9). A pre-commit hook and a core.fsmonitor command planted from inside a guest ran on the host when the host later ran plain git commit and git 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 through hostgit (isolated configuration, hooks and fsmonitor off) on the supervisor’s own repository (§4.5).
    • Never mount the human’s own repository’s .git into an environment. A shared .git exposes 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 .git as 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 image whr builds (environments, the base image, the console) is named under the reserved registry host whr.invalid/, which no registry answers (RFC 6761), never under a bare name that normalises to docker.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 reached registry-1.docker.io, while a missing whr.invalid/ image fails closed in container create and in a build’s FROM, and Provision refuses 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-configured environment.image, the operator’s choice; and a repository’s own devcontainer image, which the repository chooses (D38) and which is pulled as it names it. A repository-chosen image (a devcontainer image or a Dockerfile’s FROM) may never name whr.invalid/: that host is the supervisor’s alone, and such a file is refused (#133). The check scans the file’s text and its build.args for whr.invalid, after removing quoting and line continuations; a name assembled from ARG pieces (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 (spike builder-store, container 1.5.0): container build without --pull does build FROM a local whr.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 than docker/dockerfile and BUILDKIT_* build arguments are refused; COPY --from and RUN --mount from= also use a local whr.invalid/ image without --pull, and the scan refuses both (verified, spike builder-store).
  5. 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 /v1 through 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 configured public_url, never by a request header such as X-Forwarded-Proto: a request counts as HTTPS only over TLS or when it arrives for the public_url host, and then the UI sets Secure, __Host- cookies; plain loopback HTTP stays plain. Previews (D33, issue #72): the preview proxy replaces the app’s Content-Security-Policy with default-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 and eval stay 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).

  6. 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 console land 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.

  7. 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.db in the supervisor’s data directory, which no environment mounts: the audit tier of the events table (triggers refuse to change or delete a row) and the hash chain over it in the audit_chain table (§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-all stops 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 and whr doctor’s bot-key check built in #251, its use on the production path in #253) is an unencrypted SSH ed25519 private key (git signs without a prompt) in a 0600 file owned by the whr user, named by bot_signing_key_file (one of the secret files of §5), outside every workspace root and never mounted into an environment; the whr user 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), and whr doctor checks 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 a whr PR, 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.

  8. 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, ~/.ssh or 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.