# Custos PoC — architecture and protocol spec v5 - **Status:** binding contract for the Rust PoC built on 2026-10-22. v5 = v4 revised after spec review round 3 (2026-10-23; ADR 0048–0055); v4 = v3 revised after spec review round 2; v3 = v2 revised after round 1 (findings and dispositions: `../research/13-spec-review-log.md`; decisions ADR 0030–0047). v1 is in git history (commit `ced185e`). - **Precedence (ADR 0033):** for **bytes** — constructions, labels, CDDL, verification orders in §3 and §5 — this file is normative and each formula is pinned by a golden vector; ADRs are the rationale and carry "amended by" notes where their byte text differs. For **decisions** (what is built, what is claimed), ADRs win: where this file and an ADR disagree on a decision, this file has a bug. New decisions made while writing v2 are ADRs 0024–0029; review round 1 added ADRs 0030–0040; review round 2 added ADRs 0041–0047; review round 3 added ADRs 0048–0055. - **Markers:** **[PoC]** = built tonight. **[Deferred]** = specified here, not built tonight; the reason is in §13.2 and ADR 0028. Anything unmarked is [PoC]. - **Change only together with the code that implements it.** Contents: §1 principles and security properties · §2 components, crates, dependencies · §3 suite C1 · §4 keys and identities · §5 object formats · §6 account log and gossip · §7 flows · §8 exposure modes · §9 bridges · §10 relay API · §11 daemon, CLI, MCP · §12 approver PWA · §13 PoC scope cut · §14 test plan · Appendix A traceability · Appendix B ADR index. ### What changed from v1 (one screen) | v1 | v2 | Why | | --- | --- | --- | | Canonical JSON signing; custom seal (X-Wing → HKDF-SHA-512 → XChaCha20) | det-CBOR sign-the-bytes envelope; RFC 9180 HPKE; AEAD-C1 (ChaCha20-Poly1305 + key commitment); HKDF-SHA-256 | ADR 0003, 0005, 0006, 0007, 0024 | | Separate device chain, audit chain, grants, revocations | **One hash-chained account log**; every state change is an entry; relay checkpoints; gossip in requests and grants | ADR 0011, 0012, 0024 | | SAS over new device's keys only | Commit-reveal transcript SAS, confirmed on both devices | ADR 0013 | | One `a` per secret, single never-rotating AK | Account Secret `AS` (epochs), per-host shares `a_i = K ⊕ h_i`, symmetric at-rest approver shares | ADR 0014 | | Policy fields signed by the creating host | **Approver-signed policy = handle** | ADR 0015, 0026 | | `run` with env injection, `{{custos:x}}` anywhere | **Proxy by default**: policy header template, exact egress rules, no URL/body substitution | ADR 0016 | | `userVerification` may be `none` | WebAuthn UV on every grant, policy and device.add, verified by hosts | ADR 0017, 0027 | | `notAfter` vs wall clock | Relay-anchored monotonic deadline + 60 s freshness, fail closed | ADR 0017, 0018 | | Revoke-all = `"*"` + timestamp | `revoke.epoch` counter | ADR 0017 | | Relay serves the PWA | PWA on a separate origin; relay serves no code | ADR 0019 | | WebSocket stream | Signed long-poll (PoC) | ADR 0025 | | az/op local sessions | Custos-owned bridge credentials, Tessera-gated, per-fetch | ADR 0021, 0022 | ### What changed in v3 (review round 1) | v2 | v3 | Why | | --- | --- | --- | | `custosd` runs as the agent's user; seeds in `~/.custos` | **Service mode**: daemon runs as its own OS user; same-user "dev mode" is labelled in the signed DeviceRecord; `unfreeze` RPC removed | ADR 0030 | | P8/P9/P14 claimed against a forking relay | Claimed against a non-forking relay; human-compared log fingerprint; N17 | ADR 0031 | | Grant scope "sealed" (ADR 0017) vs plaintext bytes | Granted window and use limit plaintext by decision; requested scope sealed | ADR 0032 | | "ADR wins" for bytes; no self-signed path | Spec normative for bytes; `verify_self_signed`; hash prefixes defined | ADR 0033 | | "Approver shares symmetric at rest ⇒ HNDL-safe" | Values rest on X-Wing; N18 | ADR 0034 | | Fatigue state in daemon memory per session; grants per host | Fatigue derived from the log per host+handle, enforced on the phone; grants bound to the requesting session; typed number match | ADR 0035 | | UV "signs what is displayed"; A6 incl. XSS | UV proves a biometric on the origin; A6 split; Trusted Types; N16 | ADR 0036 | | Egress pre-filled from agent hints; loopback egress; once grant not bound to its operation | Hints untrusted, provider registry or typed host; `https` only; once grants bind (method, URL, H(body)); identity encoding | ADR 0037 | | 1 h default ceiling; "Until" offered outside the envelope; no session storage | Envelope-only options; until-mode defined; 12 h non-prod default; expiring handles | ADR 0038 | | External crypto vectors deferred | Built; C1 frozen only when they pass | ADR 0039 | | Shared bridge credential across tiers | Same tier and env required | ADR 0040 | ### What changed in v4 (review round 2) | v3 | v4 | Why | | --- | --- | --- | | Fork check: "host shows this size or larger"; via user-uid `custos status`/`lock` | Host compares the approver's prefix at size `S` against typed words with `custosd verify` run as the daemon user; mismatch locks; refused revocation append is a red alarm; gossip catches only a careless relay | ADR 0041 | | Socket in `/run` (unreachable); daemon opens `.env` paths; doctor and peer cwd/exe in the daemon | Runtime dir outside ``; no RPC path the daemon opens; `.env` import, doctor and cwd/exe client-side | ADR 0042 | | Service mode self-declared | Client uid that can become root ⇒ `isolation = 3` (red); every bridge needs isolation 1 | ADR 0043 | | "Biometric" approval | User verification incl. device passcode; A6c | ADR 0044 | | "Until HH:MM" with no time-zone library; undefined `duration_s` / `not_before` | jiff in the daemon, DST rule named; durations and `not_before` defined | ADR 0045 | | Approver and host copies of PolicyBody unbound | Signed hash commitment | ADR 0046 | | Session label in the Verified zone; night shift = requested uses; overlap block; free-text `prod`; hint shown first | Verified-only scope sentence; uses ≤ 200; per-card number entry; closed env choice; hint after the host | ADR 0047 | | `use` appended after the bridge fetch | Before it | COMPLETENESS-R2-4 | ### What changed in v5 (review round 3) | v4 | v5 | Why | | --- | --- | --- | | `/usr/local/bin/custosd`, `/usr/local/var/…` (user-owned under Homebrew); only `` and runtime dir checked; `sudo -u …` leaves a ticket; escalation checked once | Root-created `/opt/custos/…`; executable, unit/plist and every ancestor of state and runtime dir checked (owner, write bits, ACL); every documented command is `sudo -k -u …`; `sudo -k -n true`; daemon re-checks `docker`/uid 0 before bridges | ADR 0048 | | Host shows the code for every add; 2-word fingerprint of `device_id`; unsigned `Added` | Role chosen first; approver adds: the existing approver shows the code; warning names the AI agent; `Added` hash in `device.add`; 4-word fingerprint from the device.add leaf; confusable labels rejected | ADR 0049 | | 2-digit number; red "not from your terminal" banners; coalescing ignores number match | 6 digits; no trust styling from `number_match`; number-matched requests never coalesced | ADR 0050 | | Lock flags inside `view.cbor`; undefined state lock; "written before applied" | `/locked` / `frozen` files; short `flock` on `/lock`; apply on a clone, then persist; `verify` replays and reports CORRUPT | ADR 0051 | | Host `audit.report` with reasons it cannot decrypt | Morning report is a PWA screen; host report has public fields | ADR 0052 | | Per-host PolicyBody copies + `body_commit` (overflowed 64 KiB) | One ciphertext under a per-version policy key `PK`; byte bounds; PolicyBody ≤ 8 KiB | ADR 0053 (supersedes 0046) | | First/any rule undefined; confirm per rule; multi-handle once grants; body not shown; URL in Verified zone | Any rule allows, confirm wins for unsafe methods; single-use grants name one handle; write body shown; path in a labelled agent block; wildcard minting entries | ADR 0054 | | Linux gate as root; runtime-skipped two-uid tests; 1Password on macOS unverified | Two-pass gate (non-root + root-only test binary); 1Password bridge Linux-only | ADR 0055 | | — | Enroll as three RPCs; relay encodings and CORS `content-type`; core egress matcher over a parsed `Target`; `http.request` always creates the request; RPC never returns session tokens; ranked cut list; honest limits N30–N33 | round-3 should-fix items (A.7) | --- ## 1. Principles and security properties ### 1.1 Principles 1. **The model never sees a value.** Agents name *handles* (`custos:github-token`). In proxy mode (default, the only mode built tonight) the daemon adds the credential to the outgoing HTTP request; the value never enters the agent's process, its tool output or the model context (ADR 0016). This holds against a prompt-injected agent only when the daemon runs as its own OS user (**service mode**, ADR 0030); in same-user dev mode the agent can steal the host's keys (N19). 2. **The relay is fully malicious and still learns nothing usable.** The relay stores signed log entries containing ciphertext, public keys and minimal metadata. It cannot decrypt a value or forge a grant. It cannot rewrite history without leaving a relay-signed contradiction; showing different devices different branches is detected when a human runs the prefix check on the host (§6.8), or when a careless relay lets an entry cross branches (ETH 2026/058 model; ADR 0011, 0031, 0041). The approver app's code is trusted when it loads: whoever serves the approver origin can obtain values (N9, N16). 3. **Tessera: two halves, one secret.** Each secret version has a random key `K`; each authorised host `i` holds `h_i`, the approvers hold `a_i = K ⊕ h_i`. Release of `a_i` to host `i` is an approval. Phone alone, host alone, relay alone, relay + phone, relay + host disk: none decrypts (ADR 0014). 4. **Every grant is scoped, time-boxed, signed and confirmed with user verification** (Face ID, Touch ID, fingerprint or the device passcode; ADR 0044), and can only narrow what was asked and what policy allows (ADR 0017, 0027). 5. **Every event is evidence.** One hash-chained log per account, each entry signed by the device that caused it, checkpointed by the relay, verified in full by every device (ADR 0011). 6. **Policy belongs to the owner.** Tier, egress, references and lease ceilings come only from approver-signed policy (ADR 0015, 0026). 7. **Crypto agility without negotiation.** One suite id per object, no per-field choice, a monotonic floor (ADR 0009). 8. **Fail closed.** Unverifiable, stale, inconsistent or unexpected input stops use; it never degrades to a weaker path. 9. **Simple beats complete.** A feature is built fully or not at all (ADR 0028). ### 1.2 Adversaries | Id | Adversary | Capabilities assumed | | --- | --- | --- | | A1 | Malicious relay / relay operator / relay breach | Full control of relay code, database, keys and API responses; can drop, delay, reorder, fork per device, replay, inject devices, choose times | | A2 | Network attacker | Reads and modifies traffic between any parties; can shift unauthenticated NTP | | A3 | Prompt-injected agent | Runs arbitrary commands as the host user (not root, not the daemon user, cannot become root without the user's password — else the host is `isolation = 3`, ADR 0043; a cached sudo ticket in a terminal session the agent shares **is** root for its lifetime, which is why every documented host command is `sudo -k …`, ADR 0048); talks to the daemon socket; reads and writes files the user can (incl. shell startup files and `PATH` entries); can trace and signal the user's own processes; can bind unprivileged ports; chooses reasons, URLs, headers, bodies, session labels, cwd, exe path and process names | | A4 | Compromised host (root) | Everything on one agent host, during a window | | A5 | Stolen locked phone | Physical device, no biometrics, no passcode | | A6a | Stolen unlocked phone | Can drive the PWA UI and read IndexedDB, but does **not** know the device passcode and cannot pass biometrics, so cannot produce user verification | | A6b | Script on the approver origin (XSS, malicious static host or TLS tunnel) | Everything A6a can, plus: substitutes the body that the next user-approved WebAuthn assertion signs (ADR 0036) | | A6c | Phone thief who knows the device passcode | Everything the owner can do on that approver, including UV (ADR 0044); no property claims A6c (N26) | | A7 | Revoked device | Holds its old keys (and, for an approver, `AS` epoch 0) | | A8 | Quantum adversary with harvested data | Records everything today; later breaks X25519/Ed25519/P-256 | | A9 | Other local users and web pages | Different UID on the host; JavaScript in the user's browser | ### 1.3 What we guarantee Each property names the adversaries it holds against and the test that proves it (§14.4). | Id | Property | Holds against | | --- | --- | --- | | **P1 Relay blindness** | The relay (database + signing key + TLS key) learns no secret value, no `K`/`h_i`/`a_i`, no `AS`, and no handle name, description, env, tier, egress rule, reference, reason, command, URL, requested scope or agent detail. It sees: account id, device ids, device role/label/platform/isolation, handle/secret/request/grant ids, entry kinds, sizes, times, use counts, and each grant's window and use limit (from which once/tier-2 approvals are inferable; ADR 0032) | A1, A2, A8 | | **P2 Two-of-two** | Without an approval for host *i*: host *i*'s disk + relay reveal no tier ≥ 1 value; `AS` (any approver, including a revoked one) + relay reveal no value; one approval for host *i* reveals nothing to host *j* | A1, A4 before approval, A5, A6, A7 | | **P3 Approval authenticity** | A host uses a value only under a grant entry signed by an approver that is unrevoked at the host's current log head, carrying a valid WebAuthn assertion with UV over the grant body. Relay, hosts and agents cannot produce one. UV proves that user verification (biometric **or device passcode**, ADR 0044) happened on the approver origin, **not** which body the user saw (ADR 0036, N16) | A1, A3 (service mode, isolation 1), A4, A6a | | **P4 Request binding** | A grant names the leaf hash of the exact request entry; the approver was shown that request's facts (host-asserted facts labelled as such), sealed reason and operation; a once grant for a request with an operation is usable only for that operation (ADR 0037). For a number-matched request the user types the request's 6-digit number; a relay colluding with the agent that hides the human's request and shows a colliding agent request succeeds with probability ≤ 10⁻⁵ per human-started request (ADR 0050). Number matching never proves a human started a request | A1, A3 | | **P5 Narrowing** | A grant is never wider than its request or the handle's policy (handles, window, uses, tier rules, 12 h ceiling, policy expiry); hosts enforce it, and the PWA offers only options inside the envelope (ADR 0038) | A3, A6a (cannot widen without UV), buggy approver UI | | **P6 Proxy confidentiality** | In proxy mode the value is sent only in the policy's header template to an exact (`https`, host, port, method, path-prefix) the approver set; never in URL or body; never across a redirect; never with an agent-supplied method override; and never returned to the agent (responses requested with identity encoding and scrubbed; `Set-Cookie` stripped) | A3 in **service mode with isolation 1** (ADR 0030, 0043); in dev mode or isolation 3 only an agent that uses the socket honestly | | **P7 Policy integrity** | Tier, egress, header template, lease ceiling, expiry and bridge reference are read only from approver-signed policy (with UV); hosts and the relay cannot set or widen them; every approver sees the body the hosts enforce (one key-committing ciphertext, ADR 0053); a policy change invalidates grants on older versions at a host that has seen it; handles sharing a backing secret share tier and env (ADR 0040). A forking relay can keep a host on an older, wider policy version (N17); reducing policy changes therefore get the prefix check (§6.8) | A1 (non-forking), A3, A4, A6a | | **P8 Log integrity and fork detection** | Any modification, drop, reorder or insertion of entries is rejected by every device. A relay that shows two devices different histories is detected when a human runs the prefix check on the host (`custosd verify --at S`, §6.8, ADR 0041), which locks the host on mismatch; after an account-wide reduction the check is needed **on every affected host** (§6.8). Embedded checkpoints additionally catch a careless relay that lets a request or grant cross branches; a deliberate forker never does. Until the check is run a fork is **not** detected (N17) | A1 | | **P9 Bounded revocation** | A grant revocation, revoke-all, device revocation or policy change stops use at an honest host within 60 s of being appended (freshness bound), or the host fails closed. Holds against a relay that withholds without forking. A relay that **refuses to append** the revocation is shown to the user as a red alarm with the lock command (§6.1); a relay that forks per device can keep any one affected host on a stale branch until the prefix check is run on **that** host; the PWA lists every affected host and keeps each pending until checked (§6.8, N17) | A1 (non-forking), A2 | | **P10 Enrollment authenticity** | A relay or network MITM of enrollment succeeds with probability ≤ 2⁻⁴⁸ per attempt; a new device cannot be placed into a foreign account or receive a foreign `AS` undetected | A1, A2 | | **P11 Time** | An honest host stops using a grant at `not_after` measured from a relay-signed time on its monotonic clock, and also by its wall clock (earliest wins). Moving the wall clock alone does not extend a lease | A2 | | **P12 Post-quantum** | All public-key encryption is X-Wing (ML-KEM-768 + X25519); all Custos device and relay signatures are composite ML-DSA-65 + Ed25519 (both must verify, both strongly unforgeable, ADR 0004 amendment). The WebAuthn UV factor is classical ES256 and gives no protection against A8 combined with A6a (N27). HNDL resistance of **values at rest in the log and in Custos transport, sealed metadata and `AS`** rests on X-Wing; values and bridge credentials sent to upstreams are only as PQ-safe as the TLS group each upstream negotiates (N30): the log keeps X-Wing wraps of `h_i`, of `a_i` (`atransit`, `grant-share`) and of every policy key `PK` (`policy-host`, which opens the policy body) forever, and a malicious relay can keep `as-transfer` (HPKE of `AS`) beyond its TTL — one break of that yields `ASK`, hence every policy, standing `a_i` and details (ADR 0034, N18) | A8 | | **P13 Local hygiene** | Service mode: daemon executable, unit/plist, state directory and runtime directory, and every ancestor of each, owned by root or the daemon user with no group/other write bit and no ACL (ADR 0048); daemon state directory 0700 owned by the daemon user with no group/other-accessible entry; socket in a separate runtime directory (`/run/custos/` Linux, `/opt/custos/run/` macOS) owned by the daemon user, group `custos-clients`, 0750, socket 0660; peers accepted only from `client_uids` (never uid 0). Daemon non-dumpable on Linux and core dumps disabled; the daemon opens no path a client supplies (ADR 0042); no value, share or key in any log line, error, panic message or relay row | A9, A3 (service mode: cannot replace the daemon binary or a parent directory, read state, re-bind the socket, signal the daemon, read its children's environment, or make it open a file) | | **P14 Complete-by-construction audit** | Every approver action and every request is a signed log entry; an honest host appends `use` (with the calling agent's facts and reason) **before** each bridge fetch and each upstream call; the log rejects more `use` entries than `max_uses`. A compromised host can under-report its uses but cannot over-use in the log or forge approvals. A forking relay can hide a host's `use` entries from the approver's branch (N17) | A1 (non-forking), A4 | ### 1.4 What we do not guarantee (honest limits) | Id | Limit | Mitigation / where addressed | | --- | --- | --- | | N1 | A host compromised **during** a grant window gets the values released to it (inherent to any release) | Short windows, per-use tier 2, derived short-lived credentials (ADR 0018 §4) | | N2 | Time-boxing is enforced by an honest host, not by cryptography. A relay **and** NTP attacker together can stretch a lease by the time they lie | ADR 0018; Roughtime [Deferred] | | N3 | Availability: a relay can withhold everything (fail closed = no use) | Product: local fallback, witnesses | | N4 | Any program running as a client uid can use grants through the daemon (that is the point). Session binding (ADR 0035) is a host-honest label: an agent that obtains another session's token (e.g. from `/proc//environ` on Linux, same uid) can use that session's grants | Grants are scoped; the approval screen states "usable by …" in plain words | | N5 | Redaction is accident prevention, not a boundary; a server or binary that transforms the value can return it in a form we do not detect | Proxy default (value never in agent process), identity encoding forced (ADR 0037), ADR 0016 | | N6 | Within an allowed egress rule, the upstream itself can be abused (e.g. write the agent's data to an allowed repo). An endpoint that **mints or registers a credential** (GitHub `POST /user/keys`, npm token creation) gives the agent persistence that survives grant expiry and revocation. Headers that change semantics other than the dropped method overrides can still be sent | Exact path prefixes, `confirm` rules for writes, once grants bound to their operation, per-use tier 2; method-override headers dropped, `Set-Cookie` stripped, credential-minting paths flagged red (ADR 0037 amendment); the exposure report says to review keys and tokens created at the provider during grant windows | | N7 | Bridged handles: one bridge credential reads everything it is scoped to upstream; item-level gating is daemon policy, not cryptography | Upstream scoping per handle set (ADR 0022 §1, §5); handles sharing a credential share tier and env (ADR 0040) | | N8 | Entra access tokens cannot be revoked mid-life | Never cached; acquired per fetch (ADR 0026 §6) | | N9 | The approver PWA trusts its static origin at load. SRI and `manifest.sha256` are served by that same origin, so they do **not** protect against a malicious or compromised static host or TLS tunnel (often the same operator as the relay); they only separate code delivery from the relay software. Script from that origin can read `AS_0` from IndexedDB and swap the next approved body (N16), so **"zero-knowledge" holds against the relay API, not against whoever serves the approver code** (claim C04 says so). Browser JIT gives no constant-time guarantee; approver keys are software keys | ADR 0019, 0036, 0001; self-hosting the approver origin; out-of-band manifest check and native approvers [Deferred]; owner decision (research/13) | | N10 | Metadata: timing, sizes, ids, device labels, use counts, each grant's window and use limit (tier inferable) are visible to the relay; relay TLS is not PQ in the PoC. With push on (ADR 0085) the relay also holds each approver's push endpoint (its host names the vendor, hence the platform), and the push service (Apple, Google, Mozilla, Microsoft) learns that this relay (IP, VAPID key, `sub` contact) pings this device — so the **timing** of the user's requests — but never any content: the push is empty | Padding, sealed labels, hybrid TLS [Deferred]; ADR 0032; push is opt-in per approver and content-free (ADR 0085) | | N11 | A revoked (or stolen, A6a) approver keeps `AS` epoch 0, so it keeps every `a_i` of every policy written under epoch 0 — **including policies written after its revocation** — and can read all future sealed metadata from the log. It cannot sign (P3). Alone it decrypts no value, but together with any copy of a host's seed (backup, snapshot, N20) it yields that host's values without approval. Revoking an approver therefore does not restore two-of-two for new secrets until AS rotation exists; revoking a **host** needs no re-split (§7.9) | AS epochs [Deferred, product launch blocker next to recovery], ADR 0014 §4; on approver revocation the PWA says so and recommends re-enrolling hosts (new KEM keys) or re-creating the account | | N12 | Hibernation, swap and VM snapshots can capture daemon memory | Short lifetimes, zeroize; mlock [Deferred] (ADR 0008) | | N13 | Supply chain of our own build and dependencies | Exact pins, 14-day rule on `Cargo.lock`, `cargo deny`; reproducible signed releases [Deferred] | | N14 | Dev mode or isolation 3: a process that is (or can become) the daemon's uid can swap the `op` binary between hash check and exec, read `OP_SERVICE_ACCOUNT_TOKEN` from the child's environment, or read the decrypted Azure service-principal secret from daemon memory — gaining standing vault access for the bridge credential's whole scope until it is rotated | Every bridge refuses to run unless `isolation = 1` (`bridge_requires_service_mode`, ADR 0043); in service mode the `op` path must be canonical and no component writable by any uid but root and the daemon user (ADR 0030 §6 as amended); exposure report lists the credential on every host that fetched with it; the 1Password bridge runs on Linux hosts with isolation 1; on macOS only in dev mode, where the risk is certain and stated as N34 (owner decision, ADR 0056); macOS service mode stays refused (child-environment readability unverified, ADR 0055) | | N15 | A relay with a stolen approver key, or a relay that lies about times, cannot forge approvals but can delay them, and it can withhold push pings. Push wakes a backgrounded or locked phone only where Web Push exists (iOS ≥ 16.4 as a Home Screen app, Chrome/Android, Firefox, Edge), the user turned it on, and the relay runs with `--push-subject`; otherwise requests reach the phone only while the app is open. A ping says only that something is waiting: the user must open the app to see and decide. Notifications continue for muted or quarantined hosts. Only "Mute all", which pauses push at the relay, or turning notifications off silences them (ADR 0085 §9–10) | Content-free Web Push (ADR 0085); long-poll while open (ADR 0025); the Settings screen states which case applies | | N16 | Script on the approver origin (A6b: XSS, malicious static host or tunnel) can substitute the body of the next entry the user approves with user verification — e.g. a policy with a new egress host or a `device.add` of an attacker approver. UV proves a biometric happened on the origin, not which body the user saw | CSP with Trusted Types (ADR 0036); `custos status` (host-local, own handles) and the PWA morning report list new approvers and new egress hosts; k-of-n co-signing and veto window [Deferred] (also the mitigation for A6c, N26). The single policy ciphertext (ADR 0053) makes approvers see what hosts enforce, but script on the origin can write the same malicious body into both copies | | N17 | A relay that forks per device and withholds (A1) can keep a host on a branch without a revocation, revoke-all, device revocation or reducing policy change, keep it fresh with new checkpoints, drop the host's requests so nothing crosses, and hide its `use` entries from the approver, until a human runs the prefix check on that host. An account-wide reduction (revoke.epoch, approver revocation) affects every host, and a relay can fork exactly the one the user does not check | Prefix check, listed per affected host with a checklist and a persistent banner (§6.8, MALICIOUS-RELAY-R3-2); a checkpoint the device has kept is compared with its log once the log reaches it and is never dropped before that, so a head of the other branch it has seen is evidence (only a contradicting head first served while 64 sizes are already kept ahead of the log can go unrecorded, ADR 0086 amendment), `custosd verify --at S --words …` run as the daemon user, which locks on mismatch (§6.8, ADR 0041); `custosd lock` stops a host locally; witnesses [Deferred] | | N18 | HNDL: an adversary who stores the log today and later breaks X-Wing recovers `a_i` and `h_i` for every granted or host-proposed secret, hence the values; every policy key `PK` (`policy-host`), hence every policy body; and, from one `as-transfer` a malicious relay kept, `AS` — hence every policy, standing `a_i`, reason and use detail of the account | X-Wing hybrid; side-table transport, `as-transfer` off the relay, AS epochs, hybrid TLS [Deferred] (ADR 0034) | | N19 | Dev mode (daemon runs as the agent's user) **and isolation 3** (a client uid can become root: passwordless sudo, `docker` group, uid 0 — typical for CI runners and many cloud agents and dev boxes): a prompt-injected agent can copy the host's keys, forge requests the approver cannot tell apart, and receive values directly after any approval; values a human types into the `custos` CLI pass through a process the agent's uid can trace in either mode | Service mode (ADR 0030) with an escalation check at enrollment (ADR 0043); dev-mode and isolation-3 hosts carry a signed flag and a permanent red banner; enter high-value values on the phone (§7.3 a, §7.8) | | N20 | Host device seeds are software keys in the daemon's state directory; disk images, backups (Time Machine, VM snapshots) copy them, and a copy plus a future approval for that host yields values | Revoke the host; hardware binding (TPM 2.0 / Secure Enclave / OS keystore) [Deferred] (ADR 0030 alternatives) | | N21 | Approver storage can be evicted: WebKit deletes script-writable storage of a site after 7 days of Safari use without interaction unless the origin is in persistent mode; phone loss or "clear website data" has the same effect. With one approver and no recovery, the account is lost and every native or dropped value must be re-created and rotated | `navigator.storage.persist()` with visible result; iOS onboarding only as a Home Screen web app; second-approver prompt; recovery kit [Deferred] — a product launch blocker (§12) | | N22 | PoC exposure covers HTTP APIs that take the credential in one request header. SSH keys, TLS client certificates, database wire protocols, AWS SigV4, `docker login`, signing keys and tools that read a key from the environment cannot use Custos in the PoC | helper / inject modes [Deferred] (§8.2–8.3); A.0 credential-type table | | N23 | Each secret is bound to the host set chosen at creation; adding a host (a new cloud dev box, a headless runner) means re-entering or re-proposing every value on it | Add-host share grant [Deferred] (§7.11); `custos bridge add` re-proposes a bridge credential in one command | | N24 | Expiring handles (ADR 0038) stop use after `expires_at`; the ciphertext stays in the append-only log. Nothing is erased | Disable + provider rotation for a value that must be gone | | N25 | The agent can edit the user's shell startup files and `PATH`: anything typed into that shell — even `sudo` or an absolute path (bash and zsh allow functions named like paths) — can be faked, and a fake `sudo` can capture the user's password | Fork check and lock documented as `sudo -k -u /opt/custos/bin/custosd …` (not the user-uid `custos`; `-k` neither uses nor leaves a sudo ticket, because a ticket in a terminal session the agent shares is root for 5 min by default, ADR 0048; the daemon refuses to start if its binary or any ancestor is writable by a non-root uid, but a binary swapped while writable runs the attacker's code); for a host suspected of compromise, run them from a console login or SSH as another account (ADR 0041) | | N26 | A phone thief who knows the device passcode (A6c) passes user verification: they can approve grants, change egress policy and add their own approver. With one approver the owner cannot revoke the stolen one | iOS: Stolen Device Protection set to "Always" (reduces; whether it covers WebAuthn in a Home Screen app is unverified); one-approver warning names theft; `custosd lock` on each host; k-of-n co-signing and veto window [Deferred] (ADR 0044) | | N27 | The WebAuthn UV factor is classical ES256; its public point is in the log. A8 plus an approver's seeds (A6a reads IndexedDB) can mint UV assertions | PQ COSE algorithms for WebAuthn when platforms ship them (ADR 0009 amendment) | | N28 | Durability: approvers hold only `a_i`, never the value. A native value survives only while at least one host of its host set keeps its state; losing, reinstalling or re-enrolling (e.g. dev → service mode) every such host loses the value, though its ciphertext stays in the log | Bridged handles keep the value in 1Password / Key Vault; re-enter natives on the phone; add-host share grant [Deferred] (§7.11); `custos service install --print` warns before a dev → service switch. Flagged to the owner (A.0) | | N30 | Values and bridge credentials **in transit to upstreams** (the proxied header, the Azure SP secret to Entra, the Key Vault response, the `op` service-account traffic) are only as post-quantum-safe as the TLS key exchange the upstream negotiates; rustls `prefer-post-quantum` falls back to classical groups, and TLS 1.2 is enabled. Whether GitHub, Entra, Key Vault and 1Password negotiate X25519MLKEM768 is unverified. An A8 recording host egress can later recover values and long-lived bridge credentials | Recording the negotiated group in UseDetails and a per-rule "require hybrid" flag [Deferred] (CRYPTO-R3-1) | | N31 | Log growth: every proxied call appends a `use` entry of ≈ 4.7–6 KB (3373-byte signature, 1120-byte HPKE enc, UseDetails with a reason ≤ 1000 B) and every request ≈ 10 KB (embedded checkpoint, 1216-byte ephemeral key); every device, the phone included, stores and verifies the **full** log and a new approver replays it from genesis. 2 000 overnight calls ≈ 10 MB per host per night. The relay's per-device limit (ADR 0058: a burst of 240 authenticated requests, then 10/s, appends and log reads together) lets a host make **up to ≈ 10 proxied calls/s** across all its agents (one `use` append each; the one append queue, §6.1, and the relay round trip lower this in practice), so a flooding agent under a lease can grow the log every device stores by ≈ 50–60 KB/s, ≈ 200 MB per hour (ADR 0064) | Daemon serialises its appends through one queue (§6.1); sizes asserted by `core::sizes_estimates`; the checkpoint tracker is bounded to 64 sizes (≈ 450 KB, ADR 0086) — the per-apply `LogState` clone still grows with the log (CORRECTNESS-C2-4, rest open); batched use entries per grant interval, signed snapshots / Merkle checkpoints [Deferred]; flagged to the owner (A.0, COMPLETENESS-R3-4) | | N32 | A user who reads the approver-shown enrollment code to an AI agent, or types it into a terminal, enrolls the agent as an approver: attestation is `none`, so a software key is indistinguishable from a platform authenticator | Role chosen first; approver adds use a code shown by the existing approver; warning names the AI agent (ADR 0049); attestation/AAGUID policy and veto window [Deferred] | | N33 | The 1Password reference is passed as `op read` argv; other local uids can read it (Linux `/proc//cmdline` without `hidepid`), revealing vault/item/field names and fetch timing | Setup recommends ID references (`op:////`), which are opaque (AGENT-HOST-R3-8) | | N34 | macOS dev mode with the 1Password bridge (owner override, ADR 0056): the agent runs as the daemon's uid, so while `op` runs it can read `OP_SERVICE_ACCOUNT_TOKEN` from the child's environment (`ps -E`) and at any time read the decrypted token and the device seeds from daemon memory (N19); one read gives standing access to every vault the service account can read until the token is rotated | Permanent red dev-mode banner on every approval card (isolation 2, §7.5.2); dedicated read-only vault per env/tier and ID references (§9.1 rule 5, N33); the exposure report lists the token for every host that fetched with it (§7.9); use service mode on Linux for anything that matters | | N29 | A flooding agent can push its host into quarantine, delaying the human's **new** requests from that host for up to 30 min (a DoS, not a bypass: quarantine never blocks an existing grant) | Per-card number entry (ADR 0047); storm card text; `custosd lock` | --- ## 2. Components, crates and dependencies ### 2.1 Picture ``` ┌───────────────────────────────────────┐ │ custos-relay (zero-knowledge, untrusted)│ │ account logs · checkpoints · mailbox │ └──────▲─────────────────────────▲───────┘ signed HTTP + long-poll│ │signed HTTP + long-poll (CORS) ┌──────────────────────────┴───┐ ┌────────┴─────────────────────────┐ │ custosd (own OS user) mac/Lnx│ │ Approver PWA (separate origin) │ │ host keys · log verifier · │ │ TS UI over custos-wasm (Rust core)│ │ grants · proxy · bridges · │ │ AS · WebAuthn UV · inbox · policy │ │ redactor · daemon-side doctor│ └───────────────────────────────────┘ └───▲─────────────▲────────────┘ unix socket in /run/custos/

(dir 0750 group custos-clients, socket 0660; dev mode 0700/0600) │ JSON-RPC ┌───┴──────┐ ┌───┴──────┐ upstreams (HTTPS, hybrid-PQ TLS when offered) │ custos │ │custos-mcp│◄─stdio─ Claude Code / Codex / Cursor / any MCP client │ CLI │ └──────────┘ custosd ──► api.github.com, Azure Entra + Key Vault, └──────────┘ `op` (1Password service account) … ``` ### 2.2 Crates and packages | Crate / package | Path | Role | `unsafe` | | --- | --- | --- | --- | | `custos-core` | `poc/crates/core` | Pure Rust, **no I/O, no clock, no OS RNG calls in logic** (RNG and time are parameters). Suite C1, det-CBOR codecs, envelope, log state machine, enrollment, Tessera, policy + egress matcher, request/grant build/verify/narrow, WebAuthn verify, checkpoints, time math, redactor, reason sanitizer, words, exposure report. Compiles to native and `wasm32-unknown-unknown` | forbid | | `custos-os` | `poc/crates/os` | Process hardening (`rustix` safe API), peer pid/uid and comm-based process lineage (Linux `/proc//stat`, macOS `ps -o ppid=,comm=`), permission checks of state and runtime directories and of every ancestor of the executable, unit/plist, state and runtime directories (owner, write bits, ACL via `/bin/ls -ld`; ADR 0048), client-side host checks for doctor (environment, home files, `./.env`, privilege escalation; ADR 0042, 0043), no-echo TTY prompt (`rustix::termios`) | forbid (ADR 0028) | | `custos-wasm` | `poc/crates/wasm` | `wasm-bindgen` bindings of core for the PWA. Exports take/return only primitives, `String`, `Vec`; errors are `String` (natively testable, no `JsValue`) | forbid | | `custos-relay` | `poc/crates/relay` | axum + tokio + SQLite. Verifies every append with the core log verifier. Serves **no** static files (ADR 0019). Sends content-free Web Push (reqwest, VAPID ES256 via p256; ADR 0085). Binary `custos-relay` | forbid | | `custos-rpc` | `poc/crates/rpc` | Daemon JSON-RPC request/response structs, error codes, exit-code mapping (serde, plus zeroize to wipe a `secret.propose` value on a parse error; no runtime; ADR 0076). Shared by daemon, CLI and MCP; frozen at T+0:30 with the core API | forbid | | `custos-daemon` | `poc/crates/daemon` | `custosd` library + binary: socket JSON-RPC, sync loop, persisted verified log, grant manager, proxy (`http.request`), bridges (Azure, 1Password), daemon-side doctor facts, audit views, `until` conversion (jiff, ADR 0045), local subcommands `unfreeze`/`unlock`/`lock`/`verify`/`ack-report` (§6.3, §6.8, ADR 0067) | forbid | | `custos-cli` | `poc/crates/cli` | `custos` (clap): human commands, `launch`, `enroll` (with escalation check), `inspect`, `doctor` (client-side checks + daemon facts), `import` (parses and rewrites `.env` itself, ADR 0042), `service install --print` | forbid | | `custos-mcp` | `poc/crates/mcp` | MCP stdio server (`rmcp`), thin daemon client | forbid | | `@custos/approver` | `poc/approver` | Vite + TypeScript UI; never parses or builds signed/sealed objects (ADR 0001 §4) | — | | `demo/` | `poc/demo` | Scripted scenarios (§13.4) | — | | `reference-ts/` | `poc/reference-ts` | v1 TypeScript prototype; **non-normative** reference (ADR 0001 §5) | — | Workspace files: `poc/Cargo.toml` (workspace, `edition = "2024"`, `rust-version = "1.94"`), `poc/rust-toolchain.toml` (`1.94.0`), `poc/.cargo/config.toml` (`[env] LIBCRUX_DISABLE_SIMD128 = "1"`, ADR 0002 build rule), `poc/deny.toml`, `poc/scripts/check-dep-age.mjs`. Workspace lints: `clippy::pedantic = deny`, `unsafe_code = forbid`, `missing_docs = warn` on core. ### 2.3 Dependencies (exact pins) Cryptography — ADR 0002 (unchanged): | Crate | Pin | Purpose | | --- | --- | --- | | libcrux-kem | =0.0.10 | X-Wing (`XWingKemDraft06`), pulls libcrux-ml-kem 0.0.11 | | libcrux-ml-dsa | =0.0.11 | ML-DSA-65, hedged | | ed25519-dalek | =3.0.0 | Ed25519, `verify_strict` only | | libcrux-chacha20poly1305 | =0.0.10 | inside AEAD-C1 and HPKE | | hkdf | =0.13.0 | HKDF-SHA-256 | | sha2 | =0.11.0 | SHA-256/512 | | getrandom | =0.4.3 | OS CSPRNG; `wasm_js` only in `custos-wasm` | | zeroize | =1.9.1 | secret wiping | | *dev oracles* | hpke-rs =0.8.0, hpke-rs-libcrux =0.8.0, hpke-rs-crypto =0.8.0, x-wing =0.1.1, ml-kem =0.3.2 (pinned to keep the lockfile ≥ 14 days), ml-dsa =0.1.1, chacha20poly1305 =0.11.0, libcrux-ed25519 =0.0.10 | differential tests only. hpke-rs 0.8.0 implements hpke-pq **draft-04**; C1 pins draft-05, so the published draft-05 vectors (§14.2, ADR 0039), not hpke-rs, decide conformance | | *vector files* | hpke-pq test vectors, composite-sigs draft vectors, ACVP ML-DSA-65 subset, ACVP ML-KEM-768 subset, Wycheproof Ed25519 + ChaCha20-Poly1305 + HKDF-SHA-256 + ML-KEM-768 + ML-DSA-65 verify (`mldsa_65_verify_test.json`, incl. malformed hints); every file is loaded by a test (ADR 0065) | static data in `core/tests/vectors/` with `SOURCES.md` (URL, date, SHA-256); never executed (ADR 0039) | Everything else — ADR 0029: minicbor =2.3.0 and p256 =0.14.0 (core); tokio =1.53.2; axum =0.8.9; rusqlite =0.40.2 (`bundled`); reqwest =0.13.5 (`default-features = false`, `rustls`; no `system-proxy`); serde =1.0.229; serde_json =1.0.151; clap =4.6.7; rmcp =3.5.1; schemars =1.2.2 (if rmcp requires); rustix =1.1.5 (features `process`, `termios`, `fs`); jiff =0.2.38 (daemon only, `default-features = false`, `std`, `tz-system`, `tzdb-zoneinfo`; pulls jiff-core 0.1.1; ADR 0045); wasm-bindgen =0.2.129; js-sys =0.3.106; dev: proptest =1.11.0, tower =0.5.3, http-body-util =0.1.5, tempfile =3.27.0. PWA: vite 8.1.6, typescript 7.0.2, vitest + @vitest/coverage-v8 5.0.3, happy-dom 20.14.5, @playwright/test 1.63.0, @fontsource-variable/{newsreader,jetbrains-mono,schibsted-grotesk} 5.3.0 (self-hosted brand fonts; ADR 0061). No other crate may be added without an ADR amendment. `custos-core` depends on exactly: the ADR 0002 runtime crates, minicbor, p256. ### 2.4 Engineering rules (binding) `cargo llvm-cov --workspace --all-targets --fail-under-lines 100` (§14.1) · clippy pedantic `-D warnings` · `cargo deny check` clean · `check-dep-age.mjs` clean on `Cargo.lock` and the PWA lockfile · `#![forbid(unsafe_code)]` in every crate · known-answer vectors for every primitive · proptest for every codec and state machine · a fuzz entry point for every parser, written as a plain `pub fn fuzz_(data: &[u8])` in the owning crate and driven by proptest with arbitrary bytes, so it is compiled and covered by the gate; the `libfuzzer-sys` wrappers (`poc/fuzz/`, excluded from the workspace, no new dependency in the workspace) exist for every entry point and each has run ≥ 60 s (owner override, ADR 0056 §4; runs recorded in `crates/core/FUZZ.md` and `crates/daemon/FUZZ.md`) · secret types implement no `Debug`/`Display`/`Clone`/`Serialize` (ADR 0008). --- ## 3. Cryptographic suite C1 (id `0x0001`) Normative table: ADR 0009. Summary: | Function | C1 | | --- | --- | | KEM | X-Wing (ML-KEM-768 + X25519), draft-connolly-cfrg-xwing-kem-11 vectors (= -06), private key = 32-byte seed (ADR 0003) | | Public-key encryption | HPKE RFC 9180 base mode, single-shot, (KEM `0x647a`, KDF `0x0001`, AEAD `0x0003`); our own ≈150-line key schedule (ADR 0003) | | Signature | Composite ML-DSA-65 + Ed25519 per draft-ietf-lamps-pq-composite-sigs-19, hedged, per-type ctx; both halves must verify (ADR 0004) | | Symmetric AEAD | AEAD-C1: HKDF-derived key, nonce and 32-byte commitment + ChaCha20-Poly1305 (ADR 0005) | | KDF / hash | HKDF-SHA-256 / SHA-256 (ADR 0006) | | Encoding | det-CBOR (RFC 8949 §4.2.1, integer keys, no floats, no tags), sign-the-bytes envelope (ADR 0007, 0024) | | RNG | `getrandom` only; failure is fatal (ADR 0008) | | WebAuthn verification | ES256 via p256, limited clientDataJSON verification (ADR 0027) | ### 3.1 Constructions - `H(x) = SHA-256(x)`. `lp(x) = u16be(len(x)) ‖ x`. `u32be`, `u64be` are big-endian. - **KDF:** `KDF(salt, ikm, label, parts, L) = HKDF-SHA-256(salt, ikm, info = label ‖ lp(p₁) ‖ lp(p₂) ‖ …, L)`; `label` is a registry string (§3.2). - **AEAD-C1** exactly as ADR 0005: `okm = HKDF-SHA-256(salt = random32, ikm = K, info = "custos/C1/aead" ‖ 0x00 ‖ P ‖ 0x00 ‖ H(aad), 76)`; output `salt ‖ commit ‖ ChaCha20-Poly1305(k_m, n_m, aad, pt)`; commit compared in constant time before opening; one opaque error. `aad = det-CBOR([1, 1, P, …binding])` (format 1, suite 1, purpose as a CBOR text string, then the binding values listed per use in §5). The info string is not length-prefixed: `P` comes from the closed registry in §3.2 (ASCII, no `0x00`) and `H(aad)` is 32 bytes, so the encoding is injective; ADR 0006 §1's length-prefix rule applies to `KDF` parts (ADR 0033). - **HPKE seal** to X-Wing public key `pk`: `info = "custos/C1/" ‖ purpose ‖ 0x00 ‖ H(det-CBOR(context))`, `aad = det-CBOR([1, 1, purpose])` with `purpose` a CBOR text string, `Sealed = enc (1120 B) ‖ ct`. `DeriveKeyPair` is not implemented (ADR 0003 §5). - **Composite signature:** `M' = "CompositeAlgorithmSignatures2025" ‖ "COMPSIG-MLDSA65-Ed25519-SHA512" ‖ len(ctx) ‖ ctx ‖ SHA-512(M)`; `sig = ML-DSA-65.Sign(sk, M', ctx = Label) ‖ Ed25519.Sign(sk, M')` = 3373 B; public key = 1952 + 32 = 1984 B; private = two 32-byte seeds (ADR 0004). - **Tessera DEK:** `DEK = HKDF-SHA-256(salt = lp(account_id) ‖ lp(secret_id) ‖ u32be(version), ikm = K, info = "custos/C1/tessera", 32)` (normative here; ADR 0006's Consequences formula and ADR 0014's wording are superseded, ADR 0033). - **Account Secret derivations** (epoch `e`, PoC: `e = 0` only): `ak_seed = KDF("", AS, "custos/C1/ak", [u32be(e)], 32)` → X-Wing keypair `AK_e`; `ASK_e = KDF("", AS, "custos/C1/ask", [u32be(e)], 32)` (ADR 0014 §1). The salt is empty because `AK_0.pub` is inside the genesis entry that defines `account_id`. - **base64url:** RFC 4648 §5, unpadded; decoder rejects padding, non-alphabet characters and non-zero trailing bits (TS#7 rule kept). ### 3.2 Label registry (each string has exactly one purpose; CI checks uniqueness, ADR 0006) | Kind | Labels | | --- | --- | | Signature ctx (by envelope `type`) | `custos/C1/log` (1), `custos/C1/checkpoint` (2), `custos/C1/offer` (3), `custos/C1/binding` (4), `custos/C1/confirm` (5); request auth `custos/C1/req-auth` | | HKDF labels | `custos/C1/aead`, `custos/C1/tessera`, `custos/C1/ak`, `custos/C1/ask`, `custos/C1/relay-vapid` (relay's VAPID P-256 key from its seed, ADR 0085) | | Hash prefixes (raw SHA-256 input, not HKDF) | `custos/C1/sas`, `custos/C1/mailbox`, `custos/C1/number`, `custos/C1/headfp` (ADR 0031, 0033), `custos/C1/devfp` (ADR 0049) | | AEAD-C1 purposes `P` | `value`, `ashare`, `policy`, `policykey` (ADR 0053) | | HPKE purposes (label = `custos/C1/` ‖ purpose) | `hshare`, `atransit`, `meta`, `policy-host`, `details`, `grant-share`, `as-transfer`, `use-details` | | CBOR text tags inside signed messages | `"custos-signed"` (envelope), `"custos-req"` (request auth) | ADR 0004's example contexts (`custos/C1/device`, `…/grant`, `…/audit`) are realised as `ctx = custos/C1/log` plus the `kind` field inside the signed payload: every device, grant and audit record is a log-entry body (ADR 0024 §4). ### 3.3 Words The 256-word list frozen from `poc/reference-ts/packages/core/src/sas.ts` (`SAS_WORDS`), byte `b` → word `b`. Golden: SHA-256 of the newline-joined list is a test constant. | Use | Bytes → words | | --- | --- | | Enrollment SAS | `H("custos/C1/sas" ‖ H(offer) ‖ H(binding) ‖ r_N ‖ r_A)[0..6]` → 6 words (ADR 0013); `H(offer)`, `H(binding)` = `object_hash` of the received `Signed` bytes (ADR 0033); golden SAS vector | | Account fingerprint | `account_id[0..6]` → 6 words | | Request fingerprint | `request_hash[0..3]` → 3 words (CHANGE-01); defends against relay substitution (A1), not against the agent (ADR 0035) | | Log fingerprint at size `S` | `fp(S) = H("custos/C1/headfp" ‖ u64be(S) ‖ leaf_hash(entry S−1))[0..4]` → 4 words, always shown with `S`; compared only at the same `S` (prefix check, §6.8, ADR 0031, 0041) | | Device fingerprint | `H("custos/C1/devfp" ‖ account_id ‖ leaf_hash(device.add entry of that device))[0..4]` → 4 words (genesis approver: `leaf_hash(genesis)`); the enrolling device cannot grind it (input includes the approver's `r_A` and signature; ADR 0049); shown on every approval card and device list; golden | | Number match | `u32be(H("custos/C1/number" ‖ request_hash)[0..4]) mod 1 000 000` → 6 digits, zero-padded, shown as `123 456` (ADR 0050); golden vector | --- ## 4. Keys and identities | Key / id | Type | Lives | Never | | --- | --- | --- | --- | | Device signing key | composite, stored as 64-byte seed pair | each device (daemon, service mode: `/device.cbor`, 0600, owned by the daemon user in a 0700 directory — `/opt/custos/state/` on macOS, `/var/lib/custos/` on Linux; dev mode: `~/.custos//device.cbor`, readable by the agent's uid, N19; PWA: IndexedDB, labelled "software key") | leaves the device; readable by a client uid (service mode) | | Device KEM key | X-Wing, 32-byte seed | each device | leaves the device | | `device_id` / `kid` | `H(sig_pub)` (32 B) | public | — | | WebAuthn credential | P-256 in the platform authenticator; record holds `cred_id`, 65-byte point, `rp_id`, `origin` | approvers | extractable by us | | Account Secret `AS_e` | 32 B | approvers only (IndexedDB) | relay, hosts (travels only approver → approver, HPKE-sealed in the mailbox) | | `AK_e` | X-Wing from `AS_e` | public key in genesis | private part stored (re-derived) | | `ASK_e` | 32 B from `AS_e` | approver memory | stored | | `K`, `h_i`, `a_i` | 32 B each, per secret version | `K`: only transiently at the creator and at a host during use. `h_i`: HPKE-sealed to host *i* in the log. `a_i`: AEAD-C1 under `ASK` in the policy; HPKE-sealed to `AK` in a host-proposed `secret.version` and to the request's ephemeral key in each grant — both stay in the log (N18, ADR 0034) | plaintext at rest anywhere | | DEK | 32 B | host memory at use | stored | | Ephemeral request key | X-Wing | host memory, next to the plaintext `RequestDetails` of its request, until **all** `grant-share`s of its grant are opened at grant acceptance (§7.5.4) or `expires + 60 s` | outlives that; persisted | | Relay key | composite | relay; `relay_kid` pinned in genesis (TOFU at account creation) | used for anything but checkpoints | | Session capability | 32 random bytes, b64u | daemon memory; agent env `CUSTOS_SESSION`; `H(token)` in sealed RequestDetails | treated as authentication of *who*: it is an attribution label and a host-honest grant binding (ADR 0015 §3, 0035) | | `account_id` | `leaf_hash(genesis)` (ADR 0024) | public, pinned by every device | — | | `secret_id`, `handle_id`, `request_id`, `grant_id`, `use_id` | 16 random bytes | public | reused | | Recovery key | composite + AEAD key from a printed 32-byte kit | offline paper | [Deferred] §7.10 | --- ## 5. Object formats Notation: CDDL with integer keys. `id16 = bstr .size 16`, `h32 = bstr .size 32`, `ms = uint` (milliseconds since Unix epoch, UTC), `Sealed = bstr` (HPKE: `enc ‖ ct`), `Boxed = bstr` (AEAD-C1). Every map is det-CBOR; decoders reject unknown keys, duplicate keys, non-shortest encodings, indefinite lengths, floats and tags; optional keys are absent, never `null`. ### 5.1 Signed envelope (ADR 0007, 0024) ```cddl Signed = [ protected: bstr, payload: bstr, sig: bstr .size 3373 ] Protected = { 1: 1, ; format version 2: 1, ; suite id (C1) 3: ObjType, ; 1 log-entry, 2 checkpoint, 3 offer, 4 binding, 5 confirm 4: h32 } ; kid of the signer M = det-CBOR([ "custos-signed", protected, payload ]) ; composite-signed with ctx = label of ObjType ``` Verification order: (1) size ≤ 64 KiB; (2) decode the outer array strictly; (3) decode `protected`, check format 1, suite ≥ pinned floor and = 1, `type` = the type the caller expects; (4) look up the signer's key by `kid` from the caller's verified state; (5) verify the composite signature over `M` with the ctx of the *expected* type; (6) only then decode `payload` strictly and require byte-identical re-encoding. `object_hash = H(Signed bytes)`; `leaf_hash = H(0x00 ‖ Signed bytes)`. **Self-signed objects** (ADR 0033) — only the Offer (type 3) and the genesis entry (type 1, `seq = 0`) — use `envelope::verify_self_signed(bytes, expected_type)`: (1)–(3) as above; (4) decode `payload` strictly (size limits first, all §5 rules) and require byte-identical re-encoding; (5) take `sig_pub` from the fixed path (Offer: key 1 → DeviceRecord key 2; genesis: body key 1 → DeviceRecord key 2) and require `protected.kid = H(sig_pub)`; for genesis also `seq = 0`, `prev` = 32 zero bytes; (6) verify the composite signature with the ctx of the expected type. Every other object uses the order above. Both entry points are frozen at T+0:30, have their own fuzz functions and goldens. ### 5.2 Log entry (ADR 0011, 0012, 0024) ```cddl LogEntry = { 1: uint, ; seq (0-based position) 2: h32, ; prev = leaf_hash(entry seq−1); 32 zero bytes for seq 0 3: Kind, ; §5.4 4: ms, ; signer-claimed time, informational only (ADR 0012) 5: bstr } ; body = det-CBOR of the kind's map ``` The envelope `kid` is the **actor**. `account_id = leaf_hash(entry 0)`. The account's `head` after `n` entries is `leaf_hash(entry n−1)`. ### 5.3 Checkpoint (relay-signed; also the append receipt) ```cddl Checkpoint = { 1: h32, ; account_id 2: uint, ; size 3: h32, ; head = leaf_hash(entry size−1) 4: ms } ; relay time ``` ### 5.4 Entry kinds and bodies | Kind | Name | Actor | UV | PoC | | --- | --- | --- | --- | --- | | 0 | genesis | root approver | — | ✓ | | 1 | device.add | approver | **yes** | ✓ | | 2 | device.revoke | approver | no | ✓ | | 3 | secret.version | approver or host | no | ✓ | | 4 | policy | approver | **yes** | ✓ | | 5 | request | host | — | ✓ | | 6 | grant | approver | **yes** | ✓ | | 7 | denial | approver | no | ✓ | | 8 | revocation | approver | no | ✓ | | 9 | revoke.epoch | approver | no | ✓ | | 10 | use | host | — | ✓ | | 11 | as.epoch | approver | yes | [Deferred] | | 12 | root.recover | recovery key | — | [Deferred] | | 13 | suite.floor | approver | yes | [Deferred] (floor fixed at 1 in genesis) | UV = the body's highest key holds a `WebAuthnAssertion` over `H(det-CBOR(body without that key))` (ADR 0027). ```cddl WebAuthnAssertion = { 1: bstr, ; authenticatorData 2: bstr, ; clientDataJSON 3: bstr } ; ES256 signature, DER DeviceRecord = { 1: 1 / 2, ; role: 1 approver, 2 host 2: bstr .size 1984, ; composite sig_pub (device_id = H(this)) 3: bstr .size 1216, ; X-Wing kem_pub 4: tstr, ; label, 1–32 printable ASCII chars, unique among active devices (plaintext in PoC, TS#28) 5: tstr, ; platform: "macos" / "linux" / "windows" / "ios" / "android" / "web" ("windows" reserved so an owner decision needs no format change) ? 6: WebAuthnCred, ; required iff role = approver ? 7: 1 / 2 / 3 } ; isolation, required iff role = host: 1 service mode (own OS user), 2 same-user dev mode (ADR 0030), ; 3 service mode but a client uid can become root (ADR 0043); the PWA treats 2 and 3 alike WebAuthnCred = { 1: bstr, ; credential id (≤ 255 B) 2: bstr .size 65, ; P-256 point 0x04‖X‖Y from getPublicKey() SPKI 3: tstr, ; rp_id 4: tstr } ; origin, e.g. "https://approve.example" Genesis = { 1: DeviceRecord, 2: bstr .size 1216 (AK_0 pub), 3: h32 (relay_kid), 4: 1 (suite floor) } DeviceAdd = { 1: DeviceRecord, 2: h32 (offer hash), 3: h32 (binding hash), 4: h32 (confirm hash), ? 5: h32 (H(as-transfer Sealed bytes)), ; iff record role = approver; authenticates the m4 Added frame (ADR 0049) 6: WebAuthnAssertion } DeviceRevoke = { 1: h32 (device_id) } SecretVersion = { 1: id16 (secret_id), 2: uint (version ≥ 1), 3: Boxed, ; value under DEK, P="value", aad binds [account_id, secret_id, version] 4: { + h32 => Sealed }, ; host_id → HPKE "hshare"(h_i), ctx [account_id, secret_id, version, host_id] ? 5: { + h32 => Sealed }, ; host_id → HPKE "atransit"(a_i) to AK_e, ctx [… , host_id, e]; iff actor is a host ? 6: Sealed, ; HPKE "meta"(SecretMeta) to AK_e, ctx [account_id, secret_id, version]; iff actor is a host 7: uint } ; AS epoch e Policy = { 1: id16 (handle_id), 2: uint (policy_version ≥ 1), 3: 1 / 2 (state: active / disabled), 4: [+ h32], ; authorised host ids 5: SecretRef, ; backing secret (value, or bridge credential) 6: Boxed, ; PolicyBody under the per-version policy key PK (fresh random 32 B), P="policy", ; aad binds [account_id, handle_id, policy_version] — ONE ciphertext for all readers (ADR 0053) 7: { + h32 => Sealed }, ; host_id → HPKE "policy-host"(PK), ctx [account_id, handle_id, policy_version, host_id] 8: { + h32 => Boxed }, ; host_id → a_i under ASK_e, P="ashare", aad binds [account_id, secret_id, version, host_id] ? 9: h32, ; drop request hash (secret drop, §7.8) 10: uint, ; AS epoch e 11: Boxed, ; PK under ASK_e, P="policykey", aad binds [account_id, handle_id, policy_version] (ADR 0053) 12: WebAuthnAssertion } SecretRef = { 1: id16 (secret_id), 2: uint (version), 3: h32 (leaf hash of that secret.version entry) } Request = { 1: id16 (request_id), 2: 1 / 2 (kind: access / drop), 3: [* id16] (handle ids, ≤ 8; empty for drop), 4: bstr .size 1216 (ephemeral X-Wing pub), 5: ms (expires ≤ entry time + 600 000), 6: Signed (host's latest checkpoint), 7: Sealed (HPKE "details"(RequestDetails) to AK_e, ctx [account_id, request_id]) } Grant = { 1: id16 (grant_id), 2: h32 (request_hash = leaf hash of the request entry), 3: h32 (host_id), 4: [+ [id16, uint]] (handle_id, policy_version), 5: ms (not_before = this entry's LogEntry.time, ADR 0045), 6: ms (not_after), 7: uint (max_uses, 1…10 000), 8: uint (revoke_epoch), 9: Signed (approver's latest checkpoint), 10: { + id16 => Sealed }, ; handle_id → HPKE "grant-share"(a_i) to the request's ephemeral key, ; ctx [account_id, grant_id, handle_id, request_hash] 11: WebAuthnAssertion } Denial = { 1: h32 (request_hash), 2: 1 / 2 / 3 (denied / not-me / number-mismatch) } Revocation = { 1: id16 (grant_id) } RevokeEpoch = { 1: uint (new epoch = current + 1) } Use = { 1: id16 (grant_id), 2: id16 (handle_id), 3: id16 (use_id), 4: Sealed (HPKE "use-details"(UseDetails) to AK_e, ctx [account_id, grant_id, use_id]) } ``` ### 5.5 Sealed inner objects (never visible to the relay) ```cddl SecretMeta = { 1: tstr (proposed handle name), 2: tstr (env), 3: 1 / 2 / 3 (kind: native / azure-credential / op-credential), ? 4: tstr (egress hint — untrusted, never pre-selected, ADR 0037), ? 5: [* tstr] (proposed bridge references), ? 6: [tstr, h32] (op binary absolute path, SHA-256) } PolicyBody = { 1: tstr (name, [a-z0-9][a-z0-9-]{0,47}), 2: tstr (env, [a-z0-9-]{1,16}, normalised: trimmed, ASCII lower-case; "dev" / "staging" / "prod" or custom, ADR 0047), 3: 1 / 2 (tier: lease / per-use), 4: uint (max_lease_s ≤ 43 200), 5: 1 (exposure: proxy; 2 helper and 3 inject reserved [Deferred]), 6: [tstr, tstr] (header name, template with exactly one "{}"), 7: [+ EgressRule], 8: Source, ? 9: tstr (description ≤ 200), ? 10: ms (expires_at: after it the handle is disabled at every host — expiring "session" handles, ADR 0038) } ; header name ≤ 64 B, template ≤ 256 B; encoded PolicyBody ≤ 8 192 B — the PWA refuses to sign a larger one (ADR 0053) EgressRule = { 1: "https", 2: tstr (host ≤ 253 B, lowercase ASCII; IDNA hosts stored in punycode form, exact), 3: uint (port), 4: [+ tstr] (methods, upper case, ≤ 8), 5: tstr (path prefix ≤ 256 B, starts with "/"), 6: bool (confirm: write guard for unsafe methods only, CHANGE-20, ADR 0054) } ; no "http", no loopback or IP-literal hosts (ADR 0037) Source = { 1: 1 } ; native: the backing secret is the value / { 1: 2, 2: tstr (vault, [a-z0-9-]{3,24}), 3: tstr (secret name, [0-9A-Za-z-]{1,127}), ? 4: tstr (version, 32 lowercase hex) } / { 1: 3, 2: tstr (op://vault/item/field ≤ 200 B; each segment [A-Za-z0-9 _.-]{1,64}; ids recommended, N33), 3: tstr (op absolute path ≤ 256 B), 4: h32 (op SHA-256) } RequestDetails = { 1: tstr (reason, ≤ 1000 bytes, raw; sanitized only at display), 2: AgentFacts, 3: Scope, ? 4: [tstr, tstr, h32] (op: method, URL ≤ 2048 B, H(body); H(b"") if none), ? 5: tstr (cwd, client-reported, ADR 0042), 6: bool (number_match), ? 7: [tstr, tstr] (drop: name, egress hint — untrusted), 8: tstr (host IANA time zone; "" if unknown — then until-mode is refused), 9: h32 (H(session token); 32 zero bytes = no session, ADR 0035), ? 10: bstr (the op body; present iff 4 is present and the body is non-empty and ≤ 16 384 B; H(10) must equal 4[2] — ADR 0054) } AgentFacts = { 1: tstr (lineage label from comm/ppid, e.g. "claude → custos-mcp"), ? 2: uint (peer pid), ? 3: tstr (exe path, client-reported), ? 4: h32 (exe SHA-256; not filled in the PoC, ADR 0042), ? 5: tstr (session label from launcher) } ; every field is host- or client-reported, never verified (§7.5.2) Scope = { 1: 1 / 2 (mode: once / lease), 2: uint (duration_s), 3: uint (max_uses), ? 4: ms (until, absolute UTC) } ; until-mode: until = first instant after now with local wall time HH:MM:00 in the host time zone (jiff, Disambiguation::Compatible: ; fold → earlier, gap → shifted past the gap); duration_s = ceil((until − now) / 1000), informational (ADR 0038, 0045) ; defaults: once → duration_s 900, max_uses 1; lease → max_uses 200, and duration_s or until is REQUIRED (else invalid_params); ; MCP duration_minutes × 60. Drop requests: {1: 1, 2: 900, 3: 1}. One golden RequestDetails per mode UseDetails = { 1: tstr (method), 2: tstr (host), 3: tstr (path), 4: uint (HTTP status, 0 if none), 5: uint (redactions), 6: AgentFacts (of the caller of this use), 7: uint (source: 1 native, 2 azure, 3 1password), 8: tstr (per-call reason, ≤ 1000 bytes), 9: h32 (H(session token) of the caller) } ``` ### 5.6 Log validity (the state machine every device and the relay run: `core::log::LogState::apply`) State: `size`, `head`, `account_id`, `relay_pub` (supplied; must hash to genesis `relay_kid`), `ak_pub[e]`, `revoke_epoch` (starts 0), devices (record, added-at, revoked-at), secrets (versions → entry hash, host set, creator role), policies (latest version, hosts, secret_ref, state, drop request), requests (by hash: host, kind, handles, expires, status), grants (by id: fields, approver, uses, revoked). An entry is accepted only if **all** hold, else it is rejected and the device handles it per §6.3 (freeze only on attributable evidence): - Common: envelope verifies with the actor's key; `seq = size`; `prev = head`; `kind` ∈ 0…10; actor is **active** (added and not revoked at this position, ADR 0012) with the role the kind requires; UV kinds carry an assertion that verifies against the actor's `WebAuthnCred` (ADR 0027); every embedded `Signed` checkpoint verifies with `relay_pub`, names this account, has `1 ≤ size ≤ seq` and `head = leaf_hash(entry size−1)` — a mismatch is **relay equivocation** (§6.3), not just a bad entry. - **genesis**: verified with `verify_self_signed` (§5.1); `seq = 0`; actor = `H(record.sig_pub)`; role approver with WebAuthnCred; floor = 1. - **device.add**: new `device_id`; approver records carry WebAuthnCred, no isolation field, platform ∈ {`ios`, `android`, `web`} and field 5 (as-transfer hash); host records carry isolation and no field 5; key sizes exact; label's **skeleton** (ASCII lower-case, `0`→`o`, `1`/`i`→`l`, `5`→`s`) not equal to the skeleton of any active device's label (ADR 0049). - **device.revoke**: target active. Effects: target's later signatures invalid; grants *to* a revoked host and grants *signed by* a revoked approver become invalid (TS#13). Revoking the last approver is allowed (UI warns, ADR 0012). - **secret.version**: `version = 1` for a new id or `latest + 1`; host_shares keys are active hosts; actor host ⇒ host_shares = atransit keys = {actor} and meta present; actor approver ⇒ no atransit; `e = 0` (PoC). - **policy**: new `handle_id` with version 1 or `latest + 1`; `secret_ref` names an existing secret.version entry by id, version and leaf hash; `hosts` ⊆ that version's host_shares keys, all active hosts; keys of fields 7 and 8 = `hosts`; field 9 (if present) names a **pending drop** request whose host ∈ `hosts`. Effects: grants naming an older version of this handle become invalid. (A host or approver that cannot open field 6 with the `PK` it unwrapped treats that handle version as unusable and alarms, naming the signer; no freeze — ADR 0053. AEAD-C1 is key-committing, so every reader of field 6 sees the same body.) - **request**: actor host; new `request_id`; access ⇒ 1–8 distinct handles, each active with actor ∈ hosts; drop ⇒ no handles; `entry time < expires ≤ entry time + 600 000`. - **grant**: request pending; `host_id` = request actor, still active; access ⇒ handles ⊆ request handles, non-empty, distinct, each at its **current** version, active, host ∈ hosts; drop ⇒ each handle's policy has field 9 = `request_hash`; share keys = handle ids; `not_before` = entry time (field 4); `not_before < not_after`; `not_after − not_before ≤ 43 200 000`; `1 ≤ max_uses ≤ 10 000`; `max_uses = 1` ⇒ exactly one handle (ADR 0054); `revoke_epoch` = current. Effects: request → granted. - **denial**: request pending → denied. - **revocation**: grant exists → revoked. - **revoke.epoch**: value = current + 1 → all earlier grants invalid. - **use**: actor = grant host; grant valid at this position (not revoked, epoch current, approver and host active, every named policy version still current); handle ∈ grant; `use_id` new; `uses < max_uses` → `uses + 1`. Checks that need sealed data or a clock are done by the device that can (host narrowing §7.5.4, deadlines §7.6, policy body commitment); the honest relay additionally rejects a grant appended after the request's `expires`, and any entry whose time differs from relay time by > 300 s (hygiene, not security). ### 5.7 Enrollment mailbox messages (ADR 0013) ```cddl Offer = { 1: DeviceRecord (draft; webauthn present for approvers), 2: h32 (c_N = H(r_N)), 3: bstr .size 16 (mailbox id) } ; Signed type 3 by the new device Binding = { 1: h32 (account_id), 2: Signed (checkpoint), 3: h32 (H(AK_e pub)), 4: h32 (H(Offer bytes)), 5: bstr .size 16 (r_A), 6: h32 (relay_kid) } ; Signed type 4 by an approver Reveal = { 1: bstr .size 16 (r_N) } ; unsigned Confirm = { 1: h32 (H(Offer)), 2: h32 (H(Binding)), 3: bstr .size 6 (SAS bytes) } ; Signed type 5 by the new device Added = { 1: uint (seq of device.add), ? 2: [uint (epoch), Sealed (HPKE "as-transfer"(AS) to new kem_pub, ctx [account_id, device_id, epoch])] } Frame = [ 1..5 (msg_type: 1 Offer, 2 Binding, 3 Reveal, 4 Confirm, 5 Added), bstr (the message bytes) ] ; every mailbox message is one det-CBOR Frame ``` `H(Offer bytes)`, `H(Offer)`, `H(Binding)` and the hashes in DeviceAdd are `object_hash` of the `Signed` bytes as received (ADR 0033). **Receiving rule:** the mailbox is unauthenticated (anyone who sees the code may post), so a receiver processes frames in index order, **skips** any frame whose type is not the one it expects next or whose message fails decoding or verification, and aborts the run when all 8 slots are used without a valid message (the user restarts enrollment with a new code). An `Added` frame is valid for an approver N only if `H(its Sealed bytes)` equals field 5 of N's `device.add` (read after the sync); other `Added` frames are skipped. If a matching frame's `AS` derives an `AK` that differs from genesis, the run aborts (ADR 0049). **Who shows the code:** for a host add the new host shows it and the approver types it; for an approver add the **existing approver** shows it and the new approver types it (ADR 0049). **Code input:** 8 Crockford base32 characters; normalisation before hashing: upper-case, remove `-` and spaces, map `I`/`L` → `1` and `O` → `0`, reject any other non-alphabet character; `mailbox = H("custos/C1/mailbox" ‖ the 8 ASCII bytes)[0..16]`. A golden vector pins `"abcd-efgh"` → mailbox id. ### 5.8 Limits Signed ≤ 64 KiB; secret value ≤ 16 KiB; reason ≤ 1000 B; op URL ≤ 2048 B; op body in RequestDetails ≤ 16 KiB; ≤ 8 handles per request; ≤ 32 hosts per policy; ≤ 16 egress rules; encoded PolicyBody ≤ 8 KiB (a policy at every bound fits 64 KiB: ≈ 56.8 KB, ADR 0053, test `policy_at_every_limit_fits_64k`); mailbox ≤ 8 messages of ≤ 16 KiB, TTL 10 min; log page ≤ 512 entries; daemon JSON-RPC line ≤ 2 MiB (fits a 1 MiB body in base64); `http.request` body ≤ 1 MiB; checkpoint tracker ≤ 64 sizes, 2 checkpoints each (ADR 0086); `http.request` answer ≤ wait + 150 s (ADR 0087). The daemon refuses agent input that would break a decode limit (op URL > 2048 B, client `exe`/`cwd` > 4096 B, or any request or `use` details that do not round-trip) with `invalid_params` before anything is logged, so every logged request and use opens on the phone (ADR 0079). The daemon's relay client enforces these bounds on what it receives: a response body above its cap (log page ≈ 44.9 MB = 513 × base64(64 KiB); mailbox read ≈ 176 KB; any other body base64(64 KiB) + 1 KiB) is refused before or while it streams in, and a page of more than 512 entries, an entry or checkpoint over 64 KiB, or more than 8 mailbox messages or one over 16 KiB is refused before decoding — a failed sync, not a freeze (ADR 0084). --- ## 6. Account log, checkpoints and gossip (ADR 0011, 0012, 0024, 0025) ### 6.1 Sync Every device holds the **full** log and its own `LogState`. Sync = `GET …/log?from=&wait=25` → entries + fresh checkpoint; apply entries in order; then check the checkpoint (§6.2). Appends: build the entry against the current head, `POST …/log` with `expect_size`; `200 {checkpoint}` is the receipt, and only a checkpoint of size `seq + 1` whose head is the appended entry's leaf counts (an older consistent checkpoint is not a receipt: nothing is persisted and the call fails; ADR 0073); `409 {checkpoint}` → sync, rebuild with the new `seq`/`prev`, re-sign the **same body bytes**, retry (≤ 5 attempts, then error; DS-1). A daemon serialises its own appends through **one queue**, so parallel uses do not race each other for the head (N31); the entry time (field 4) is kept, so a grant's `not_before` stays equal to it. Before re-signing a grant, policy or device.add, the device re-checks the body is still valid at the new head (e.g. policy version still current); if not, it aborts and tells the user. **Refused reduction (ADR 0041 §6).** For a reducing entry (revocation, revoke.epoch, device.revoke, denial, reducing policy change) the PWA treats any of — 5 failed attempts, a 5xx, a timeout, a `409` whose checkpoint size is not greater than the device's own size, a `200` whose receipt does not prove the entry is stored (the checkpoint must verify, have size ≥ `seq + 1` and a head equal to the device's own leaf at that size; a replayed old or unverifiable receipt commits nothing, CORRECTNESS-C3-2) — as a **relay fault**: a red, non-dismissable alarm "Revocation NOT recorded by the relay — lock the host now: `sudo -k -u /opt/custos/bin/custosd lock --profile P`", and background retries while the app is open. Not a freeze (not attributable, §6.3). Every refused reducing entry goes into a persisted queue, which holds the last bytes sent. The alarm lists each outstanding action and stays up until the log holds every one of them. A `422` is unsigned, so it never drops a reduction on its own (MALICIOUS-RELAY-C4-1, ADR 0075): on the first send and on every retry the PWA syncs and asks its **verified log** (`moot`) whether the entry is still needed. A denial whose request is denied (or whose grant is revoked), a device.revoke whose device is revoked, or a revocation whose grant is revoked is dropped. A denial whose request ended **granted** is not done (a relay can hold the user's grant back and append it under the later Deny, MALICIOUS-RELAY-C5-1, ADR 0083): the PWA shows a red notice "the request was GRANTED by … before your Deny: your Deny did not take effect", kept until dismissed, and the entry owed becomes a revocation of that grant (wasm `overridden` and `refresh`), queued and alarmed like any reduction; a `revoke.epoch` the account epoch has reached is rebuilt and sent (the tap is still owed); in every other case — and always for a reducing policy change — a `422` is a relay fault like a 5xx (queued, alarm, retried). A retry re-signs the entry at the head with the current entry time (a UV-signed policy keeps its time), so a reduction queued longer than the relay's 300 s entry-time bound (§5.6) is not refused for its age. The steps of "This wasn't me" and Revoke everything (§7.9) are each attempted even when an earlier one is refused or rejected (ADR 0071). ### 6.2 Checkpoint consistency For a checkpoint `cp` (from a sync, an append, or embedded in a request/grant) and the device's verified log of size `n`: 1. Signature by `relay_pub`, account id matches. 2. `cp.size ≤ n` ⇒ `cp.head = leaf_hash(entry cp.size−1)`. `cp.size > n` ⇒ sync to `cp.size` first; if the relay does not deliver, treat as stale (§6.4). 3. Time monotonicity over all checkpoints the device has seen: if `size_a < size_b` then `time_a ≤ time_b`. The device keeps the oldest and the newest checkpoint per size, so this holds against every time seen, and its latest checkpoint carries the newest relay time seen (ADR 0078). **Retention** (ADR 0086): the tracker keeps the 64 largest sizes (`core::checkpoint::KEPT_SIZES`). Once a size is dropped, the smallest kept size is the floor: a checkpoint at or below it is still checked against the verified log (rule 2), the floor's head (rule 4) and the next larger size, then refused as stale (not a freeze, never accepted). Above the floor every check equals the unbounded one; every kept size the verified log has reached is compared with it on each observation (a different head is evidence), and a size still ahead of the log is never dropped: in a full window whose smallest size is ahead of the log, a new largest size is accepted as Ahead but not kept, and any other new size is refused as stale (MALICIOUS-RELAY-C6-1, ADR 0086 amendment). 4. Two checkpoints with equal size and different heads are a misbehaviour proof. ### 6.3 Misbehaviour, freeze and lock **Freeze only on attributable evidence** (MALICIOUS-RELAY-R1-4): - a failure of §6.2, or an invalid embedded checkpoint (§5.6): two relay-signed statements contradict — **relay equivocation**; - an entry that fails §5.6 but is covered by a relay-signed checkpoint (the relay vouched for an invalid log) — attributable to the relay. The device then stores the evidence (the relay-signed statements and the entries linking them), sets **frozen** (host: the file `/frozen`, §6.3 below; PWA: IndexedDB), stops all use and approval, and shows a red alarm. **Not a freeze:** a sync response with bytes that do not decode or verify and are not covered by a checkpoint the device accepted, a replayed entry, or a page that does not extend the log. Before the full §5.6 check the host reads each entry's `seq` and `prev`: an entry it already holds is dropped, and one at any other position than (`size`, `head`) fails the sync without applying anything (ADR 0079; the approver's `apply_page` does the same and answers `stale`, ADR 0083). A state lock that stays busy for 10 s during a sync is also a failed sync, not a freeze. The sync counts as failed; the device retries with back-off (daemon: 2 s, doubling up to the 25 s long-poll, reset by a good sync), the 60 s freshness rule (§6.4) fails closed, and a non-sticky warning plus a log line are shown (daemon: `status.sync_warning` with a static reason and the time of the first failure in a row, cleared by the next good sync, and one stderr line per failure; ADR 0084). Only the entries that extended the verified log are persisted, and every `log.cbor` append starts at the length the last successful persist left, so a repeated entry or a failed or torn persist never reaches the file twice (ADR 0084). A grant that fails host narrowing (§7.5.4) is ignored with an alarm and no freeze (ADR 0038). **Unfreeze, unlock, lock and verify are local subcommands run as the daemon user** (ADR 0030 §7, ADR 0041). There is no `unfreeze` RPC. On a host, `sudo -k -u /opt/custos/bin/custosd unfreeze --profile P` clears the flag (evidence is kept); in dev mode the same subcommands run as the user. `custosd lock` sets a persisted **locked** state with the same effect as frozen; `custosd unlock` clears it. **Host flag files** (ADR 0051): `/locked` and `/frozen` (det-CBOR: time, reason, evidence) are created atomically (temp file, `fsync`, `rename`) by the subcommands and by the daemon's freeze path, and removed only by `unlock` / `unfreeze` (which moves evidence to `/evidence/`); the sync path never writes them. A freeze whose flag file cannot be written at once (state lock busy) holds in memory, shows as `frozen` in `status`, and the grant manager retries the write every second, so `custosd unfreeze` can always reach it (ADR 0079). **State lock:** `flock(LOCK_EX)` on `/lock`, held only around one read-modify-write (a `log.cbor` append + `fsync`, a `view.cbor` replace, a flag create/remove, `custosd verify` reading `log.cbor`), never across a network wait or for the daemon's lifetime, and never together with the daemon's in-memory state mutex (the daemon waits for it on a blocking thread); subcommands wait ≤ 10 s, then exit 4. The socket method `lock` (`custos lock`, any client — locking only reduces access) is a convenience; because the user-uid `custos` binary can be shadowed by the agent (N25), the documented mitigation is always the `custosd` subcommand. The daemon `stat`s both flag files before each use and each request; an existing file, or any error other than `ENOENT`, means locked. In the PWA, unfreeze is a button behind a WebAuthn UV prompt that clears local state only. A host whose `config.cbor` exists but whose `view.cbor` is missing or unreadable creates `/frozen` ("view missing") at start, not a first start. ### 6.4 Freshness (ADR 0017 §6) A daemon may use a grant only if its last verified checkpoint arrived ≤ 60 s ago on the monotonic clock **and** on the wall clock (the monotonic clock stops while the machine sleeps; a wall clock stepped back is caught by the monotonic one; ADR 0079). Long-poll returns at least every 25 s, so a healthy connection is always fresh; otherwise every lease pauses (MCP/RPC return `stale`). Freshness bounds a relay that **withholds** (stops answering or answers late). It does not bound a relay that keeps answering with fresh checkpoints at a stale size on a forked branch (N17, ADR 0031). **A new arrival** (ADR 0073): a checkpoint refreshes freshness (and replaces `view.cbor`) only if it is consistent, its size equals the verified log's size after the page, its relay time is later than every checkpoint accepted before, and it is within `MAX_SKEW_MS` (120 s, the request-auth bound) of the host's wall clock on both sides. A checkpoint dated more than 120 s ahead is not a new arrival, does not raise the "accepted before" bar, and raises a non-freezing alarm (ADR 0079). A replayed old checkpoint never counts; checkpoints the relay signed before a revocation and replays later count only within that 120 s, so a non-forking relay that withholds keeps a host fresh for at most 120 s + 60 s after the last checkpoint it signed at the old size. ### 6.5 Anti-rollback (ADR 0011 §6) Each device persists its last verified checkpoint (`view.cbor`); a host also persists its verified log (`log.cbor`, append-only; the prefix check reads it, §6.8). **Apply on a clone** (ADR 0051): a sync page is applied to a clone of `LogState`; only on success the daemon takes the state lock, appends the page to `log.cbor`, `fsync`s, replaces `view.cbor`, releases the lock and swaps the clone in; on failure nothing is written. On start, the persisted and then fetched log must have `size ≥ saved.size` and `leaf_hash(entry saved.size−1) = saved.head`; otherwise freeze. Version high-water marks are implied: the log is the only source of versions and devices hold it in full. ### 6.6 Log-before-use A host learns grants only from its verified log, so every grant it uses is logged. Every use is appended (`use` entry) **before** the bridge fetch (if any) and the upstream call; if the append fails, neither is made (P14, COMPLETENESS-R2-4). ### 6.7 Gossip Requests carry the host's latest checkpoint; grants carry the approver's. Because §5.6 checks embedded checkpoints against the prefix, every device that applies the entry cross-checks the other device's view. After a fork a host entry is chained to the host's branch (`seq`, `prev`) and cannot be applied on the approver's branch; the relay decides whether it is delivered at all. Gossip therefore catches only a **careless** relay that lets an entry cross; a deliberate forker drops the forked host's requests and is **not** detected by gossip (N17, ADR 0041). Host-visible signal: when a request created by the CLI or MCP expires unanswered, the result text says "Did your phone show this request? If not, open Audit → Check a host on the phone and run `custosd verify` here (§6.8)". Merkle consistency proofs and external witnesses (C2SP tlog-witness) are [Deferred] (leaf hashing is already RFC 9162-compatible). ### 6.8 Prefix check (out-of-band comparison, ADR 0031, 0041) A forked host's branch keeps growing with its own `use` and `request` entries, so sizes and latest fingerprints are never compared by order. The check asks one question: **does the host's log contain the approver's prefix of size `S`?** 1. **Phone.** After every reducing action — Revoke, Revoke everything, Revoke device, This wasn't me, and every reducing policy change (disable, egress rule removed, lower `max_lease_s`, earlier `expires_at`) — once the append receipt arrives, the PWA shows `S` (its size including the new entry), `fp(S)` (§3.3), the kind of entry `S−1`, the command to type on the host, and **every active host the entry affects** (MALICIOUS-RELAY-R3-2): all active hosts for `revoke.epoch` and for `device.revoke` of an approver; the revoked host for `device.revoke` of a host (plus "it is revoked: lock it if you can reach it"); the old **and** new host sets for a policy change; the grant's host for a revocation. Each host has its own checklist state (pending / MATCH confirmed / locked), ticked by the user; a persistent banner "N hosts not yet checked after " stays until every host is ticked. Audit → **Check a host** shows the same for the current size at any time. 2. **Host.** The user types `sudo -k -u /opt/custos/bin/custosd verify --profile P --at S --words "w1 w2 w3 w4"` (dev mode: as the user). The subcommand first checks its own executable and the ancestors of `` (ADR 0048), then reads the daemon's persisted log (`log.cbor`) under the state lock — not the socket, not the relay — replays it through `LogState` (relay key from `config.cbor`; a file that does not replay prints `CORRUPT`, exit 5, and sets the lock; ADR 0051), and prints: - `MATCH` (exit 0) when the host has ≥ `S` entries and its `fp(S)` equals the typed words, plus the kind and actor label of entry `S−1` ("entry 41: revoke.epoch by "); - `NOT YET` (exit 3) when the host has fewer than `S` entries: "wait one minute and run again; if it still says NOT YET, run `custosd lock`"; - `FORK` (exit 4) when the words differ: the subcommand **sets the persisted lock itself** and prints the host's own `fp(S)` so a typo is visible; unlock with `custosd unlock` only after a later `verify` prints `MATCH`. 3. **Where to type it.** In the agent's shell the command could be faked (N25); for a host suspected of compromise the PWA text says to run it from a console login or SSH as another account. Every device also shows its latest size and `fp(size)` (`custos status`, `custos audit verify`, `custos audit report`, PWA Audit, Report and Active-grants) for information; these are never compared by order. The words are not relay-signed evidence; the stored checkpoints on each device are, and `custos audit verify` exports them. --- ## 7. Flows All flows use only the objects in §5. "Append" means §6.1. ### 7.1 Account creation (approver PWA) 1. Generate device keys; `navigator.credentials.create` (`alg -7`, `userVerification: "required"`, `attestation: "none"`, `rp.id = location.hostname`); take `getPublicKey()` SPKI → 65-byte point (ADR 0027 §3). 2. `AS_0 = random(32)`; derive `AK_0`. 3. `GET /v1/relay` → relay public key; `relay_kid = H(pub)`. (TOFU; the PWA shows the relay fingerprint.) 4. Build genesis (§5.4) as entry 0; `POST /v1/accounts`; receive checkpoint. 5. Show the account fingerprint (6 words). Store keys and `AS_0` in IndexedDB. 6. Storage durability (N21): onboarding on iOS runs only in standalone display mode (Home Screen web app; otherwise the PWA shows install instructions and stops); call `navigator.storage.persist()` and show the result in Settings; on iOS ask the user to turn on Stolen Device Protection with "Always" and show it in Settings as a self-reported checklist item (ADR 0044); until a second approver exists, every screen shows "One approver: if this phone is lost, stolen with its passcode, or its website data is cleared, you cannot revoke it or recover the account — lock each host with `custosd lock`" with an "Add a second approver" action. ### 7.2 Enrollment of a host or an approver (ADR 0013) ``` New device N Relay mailbox Approver A (PWA) gen keys; code = 8 Crockford chars mailbox = H("custos/C1/mailbox"‖code)[0..16] m0 Offer{draft, c_N=H(r_N)} ───────────► show code ABCD-EFGH (host add) user types code ◄── (code locates only; not secret) (approver add: A shows the code, N types it — ADR 0049) ◄──────────────── m1 Binding{account_id, checkpoint, H(AK), H(m0), r_A, relay_kid} (A's signature on m1 is checked after joining, against A's record in the verified log) m2 Reveal{r_N} ────────────────────────► check H(r_N) = c_N SAS = H("custos/C1/sas"‖H(m0)‖H(m1)‖r_N‖r_A)[0..6] → 6 words on BOTH screens user confirms on N (CLI y/N or PWA) m3 Confirm{H(m0), H(m1), SAS} ──────────► user confirms on A; approver target: red "can approve everything" warning append device.add{record, H(m0), H(m1), H(m3), UV} ◄──────────────── m4 Added{seq, [epoch, HPKE(N.kem, AS)] if approver} N: sync log (now authenticated); verify genesis → … → device.add contains N's exact record and hashes; account_id = binding.account_id; H(AK_0 pub) = binding fingerprint; binding signer is an active approver; approver N: derive AK from received AS and compare to genesis. Pin account_id + relay_kid; show account fingerprint. ``` **Role first, code direction** (ADR 0049, HUMAN-FACTORS-R3-1): Devices → Add first asks "A computer where agents run" (host) or "Another phone or browser of yours that can approve" (approver); an Offer whose role differs aborts the run with no override. For a **host** add the host shows the code and the user types it on the phone. For an **approver** add the existing approver generates and shows the code — "Type this only into the Custos app on your other phone — never into a terminal, chat or AI agent" — and the new approver PWA types it, derives the mailbox and posts its Offer; messages m0–m4 are unchanged. The approver confirmation shows "enrolled " next to "no veto window (PoC)". Residual: a user who reads the code to the agent (N32). Properties: commit-reveal means a MITM gets one 2⁻⁴⁸ guess per run (P10). Mailbox framing, the skip rule for junk frames and code normalisation are in §5.7. Notifications: every device sees `device.add` in its log; the PWA shows a banner for every new device — approvers and hosts — with its 4-word device fingerprint (§3.3) and label, and marks requests from a host enrolled < 24 h ago (veto window [Deferred], PoC 0 per ADR 0013). `custos status` and the PWA morning report list new approvers (ADR 0036, 0052). Labels must be unique among active devices after skeleton mapping (§5.6). Both enrollment screens state: "Nobody from Custos, IT support, **or your AI agent** will ever ask you for this code or these words." Host enrollment records `isolation` (service vs dev mode, ADR 0030): `custos enroll`, running as the client uid, checks uid 0, `/usr/bin/sudo -k -n true` (argv exactly that; exit 0 within 5 s; `-k` ignores a ticket cached by the install steps, so only NOPASSWD rules count — ADR 0048) and membership in a root-equivalent group (`docker`, `lxd`, `incus-admin`, `disk`, `libvirt`; one shared list, `custos_rpc::ROOT_GROUPS`, ADR 0067 §1), and sends the result in `enroll.start`; any positive enrolls the host with `isolation = 3` (ADR 0043). The daemon refuses uid 0 in `client_uids` and, before each bridge fetch and grant acceptance, re-checks root-equivalent group membership of every client uid with `/usr/bin/id -Gn ` (cached ≤ 60 s); a positive refuses bridges and raises a re-enrollment alarm in `status` and `doctor` (ADR 0048, 0067). The `as-transfer` message (m4) is HPKE-sealed to the new device, authenticated by its hash in the UV-signed `device.add` (field 5, ADR 0049), and **sits on the relay for up to 10 min** (mailbox TTL; not deleted on read); claim C06 says so. QR pairing [Deferred]; typed code only. ### 7.3 Secret creation **(a) Approver creates** (PWA "New secret", also used by the drop): pick hosts; `K`, `h_i` random; `a_i = K ⊕ h_i`; `DEK`; append `secret.version` (value Boxed, `h_i` sealed to each host, no transit) **then** `policy` (UV) with `a_i` under `ASK`. Wipe `K`, `h_i`, `a_i`, DEK, value. **(b) Host proposes** (`custos secret add`, `custos import`, `custos bridge add`): the CLI reads the value from a no-echo prompt and sends it to the daemon over the socket (service mode: the agent cannot re-bind the socket; the CLI process itself runs as the user, N19 — for high-value values prefer (a) on the phone). The daemon creates `K`, `h`, `a`, encrypts the value, seals `h` to itself, `a` to `AK_e` (`atransit`), `SecretMeta` to `AK_e`; appends `secret.version`; wipes everything. The secret is **pending**: no policy exists, so it cannot be requested. **(c) Approver adopts** (PWA "Pending" list): opens `SecretMeta` and `a` (with `AK_e`), shows the proposal as *host-claimed*, the user sets name, env, tier, lease ceiling, egress rules, header template (native) or the bridge source (credential), then the PWA appends `policy` (UV) carrying `a` re-wrapped under `ASK` (ADR 0014 §2, 0015 §1). The host's egress hint is untrusted: it is never pre-selected and is shown as "Suggested by the host" only after the user has picked a host from the provider registry or typed it (ADR 0037, 0047). The user picks env from the closed list (§7.4). One credential proposal can be adopted into several bridged handles **only with the same tier and env** (ADR 0040). **(d) `.env` import** (`custos import .env --env dev`), done entirely by the `custos` CLI as the user (ADR 0042 — the daemon never opens a client path): refuse a file not owned by the calling uid; parse `KEY=VALUE` lines (optional `export `, single/double quotes, `#` comments); each non-handle value becomes a proposal (b) via `secret.propose` with proposed name `kebab(KEY)`; only after all proposals succeeded the CLI rewrites the file to `KEY=custos:` (temp file in the same directory, `fchmod` to the original mode, `rename`, so owner and mode are kept); the CLI prints: "Values were copied into Custos. The old file contents may survive on disk (copy-on-write, SSD, snapshots, backups). **Rotate these values** at their provider" (ADR 0023 §6). No `shred`. ### 7.4 Policy records (handles) (ADR 0015, 0026) - A handle is the newest active `policy` for a `handle_id`. Names are unique among active handles (PWA enforces; a host seeing two active handles with the same name refuses both and alarms). - Update = new `policy_version` (UV). Disable = new version with state 2. Either invalidates outstanding grants on older versions at every host that has seen it (P7); a forking relay can keep a host on the older version (N17), so a **reducing** change (disable, egress rule removed, lower ceiling, earlier expiry) is followed by the prefix check prompt (§6.8). Every policy version has a fresh policy key `PK`; its body is one ciphertext all readers open (ADR 0053). - Hosts may *tighten* at use time (e.g. refuse), never loosen. Local config contains no policy fields (CHANGE-26). - Handles that share a backing `secret_id` must have equal tier and env (ADR 0040): the PWA refuses or aligns to the strictest; a host seeing two active policies on one `secret_id` with different tier or env refuses both and alarms. - Expiry (PolicyBody field 10, ADR 0038): after `expires_at` (same time rule as grant deadlines, §7.6) a host treats the handle as disabled; the PWA offers Keep / Disable. - Env is a **closed choice** in the PWA: `dev`, `staging`, `prod`, or custom (ADR 0047). Strings are normalised (trim, ASCII lower-case) before storage and every comparison (ADR 0040). A host-proposed env hint (`--env production`) that normalises to one of the three pre-selects it; anything else pre-selects custom. **Custom envs get prod defaults.** - Defaults when adopting: tier 2 for `prod` and custom envs (CHANGE-19), tier 1 for `dev`/`staging`; `max_lease_s`: the PWA asks, presets "1 h" and "Overnight (12 h)", default 43 200 for tier 1 (`dev`/`staging`), 3600 otherwise (ADR 0038); egress: **no pre-filled host from a hint** — registry host if the value's prefix or the chosen name matches a registry entry, else the user types the host (ADR 0037); methods `GET`; `confirm = true` preset for `POST`, `PUT`, `PATCH`, `DELETE` rules (CHANGE-20, ADR 0037). **Overlapping rules** (ADR 0054): an operation is allowed iff any rule matches; it needs confirm iff its method is not safe (RFC 9110 §9.2.1: GET, HEAD, OPTIONS, TRACE are safe) and any matching rule has `confirm = true`; `confirm` never applies to safe methods. The editor warns when a rule without confirm covers a confirm rule's host and prefix for an unsafe method. - Provider registry (`core::providers`, ADR 0037, 0047): data table, each entry with a source URL. **Prefix entries:** GitHub `ghp_`, `gho_`, `ghu_`, `ghs_`, `ghr_` → `api.github.com`. **Name entries** (matched against the handle/drop name, no token-prefix claim): `openai` → `api.openai.com` (OpenAI OpenAPI `servers`), `anthropic` → `api.anthropic.com` (Claude API overview). A value whose prefix, or a name that, matches an entry while the chosen egress host differs produces a blocking red mismatch with a separate hold-to-confirm. No blocking warning is claimed for a value or name the registry cannot recognise. **Credential-minting paths** (GitHub `/user/keys`, `/user/gpg_keys`, `/user/ssh_signing_keys`, `POST /repos/*/*/keys` deploy keys, `PUT /repos/*/*/collaborators/*` collaborator invitations; `*` matches exactly one path segment; each entry sourced, ADR 0054): an egress rule with a write method, or a once `op`, covering one shows a red "this endpoint can create a credential that outlives Custos revocation" banner (N6). Hosts are displayed in ASCII/punycode with the rightmost two labels emphasised; `xn--` gets a warning badge. - **Hints after the host** (ADR 0047): an agent or host egress hint is hidden until the user has picked or typed a host; then hint and choice are shown side by side with differing labels highlighted; if the typed host equals a hint that is not a registry host, approval needs hold-to-confirm. ### 7.5 Request → grant → use #### 7.5.1 Request (host) Triggered by MCP `request_access` / `http_request` (without a usable grant) or `custos request`. The daemon: 1. Applies fatigue controls (§7.5.5). 2. Collects facts (ADR 0042; same code path in both modes): peer pid and uid via `SO_PEERCRED`/`LOCAL_PEERPID` (tokio `peer_cred`); lineage label from `comm` and ppid (Linux `/proc//stat`, macOS `ps -o ppid=,comm= -p `; walk parents to a known harness name: claude, codex, cursor, gemini, goose, aider, opencode, copilot); session label and `H(session token)`; host time zone (jiff `TimeZone::system()` IANA name). `cwd` and exe path are **client-reported** (`params.client = {cwd, exe}`, filled by the CLI and MCP server from their own process) because a different uid cannot read `/proc//cwd|exe`; no exe hash. All of these are **host- or client-reported**; a process can choose its name, cwd, exe path and session label. 3. Computes the scope (§5.5 defaults, ADR 0045): once → `duration_s 900`, `max_uses 1`; lease → requested `max_uses` or 200, and `duration_s` or `until` required (else `invalid_params`); until-mode → `until` = first instant after now with local wall time HH:MM:00 in the host time zone (jiff, `Disambiguation::Compatible`), `duration_s = ceil((until − now)/1000)`; unknown host zone ⇒ until-mode refused. For `http_request`, `op = [method, URL, H(body)]`. 4. Generates an ephemeral X-Wing key; seals `RequestDetails` to `AK_e`; appends `request` with its latest checkpoint. **Keeps the plaintext RequestDetails in memory** keyed by `request_hash`, next to the ephemeral key: HPKE base mode cannot be opened by the sender, and host narrowing (§7.5.4) and op binding (§7.5.6) use this copy. A request whose copy is lost (daemon restart) is unusable (§7.6). 5. Returns `request_id`, the 3-word fingerprint and, when `number_match` was set, the 6-digit number. The `custos` CLI writes fingerprint and number to the controlling TTY (`/dev/tty`), not stdout. MCP results carry the fingerprint (it defends against relay substitution only, ADR 0035). Every result and error that names a request carries its fingerprint too: `access.status`, `drop.request`, and the `needs_approval`/`denied`/`expired` error data of `http.request`; `custos http` writes it to the TTY (ADR 0077). #### 7.5.2 Approval screen (PWA; CHANGE-04, AG-4, HUMAN-FACTORS-R1-2) Three zones, top to bottom: 1. **Verified** (from signed log state): host label and 4-word device fingerprint (§3.3, from the signed device.add), "enrolled N days ago" (red if < 24 h), isolation badge (dev mode and isolation 3: permanent red banner "Any program running as the user on this host could have made this request and can receive released values" — for isolation 3 "… because a program on this host can become root", ADR 0030, 0043); handles by approver-set name with env/tier badge; the policy's egress list; for a request with `op`, `METHOD host` and the egress rule it matched (checked against the policy) and the sentence "This approval works only for this exact request" (once) or "Any operation the egress rules allow" (lease) — the path and query are agent-chosen and shown in zone 3 (ADR 0054); the request fingerprint; the scope sentence built from verified facts only: "Usable only by the Custos session that made this request on " or "Usable by any program on that runs without a Custos session" (ADR 0035, 0047). The host label is signed and was shown during SAS enrollment; no host- or client-reported string appears in this zone. 2. **Reported by the host, not verified** (greyed, labelled): lineage; exe path and cwd ("reported by the client process"); the session label, quoted, followed by "a label reported by the host; any program running as that obtains the token could use it"; for a number-matched request "Number shown on the requesting host's terminal — the agent can see that screen too" (ADR 0050). 3. **"Agent says:"** the reason as quoted plain text; for a request with `op`, a labelled block **"Path chosen by the agent"** with the full path and query, sanitized, wrapped, never truncated (≤ 2048 B by §5.8), and for a body-carrying op the **body** (RequestDetails field 10, checked against `H(body)`): text as text, JSON pretty-printed, every JSON string ≥ 16 characters that decodes as strict standard base64 to UTF-8 shown decoded beneath it ("decoded from base64"). A body that is binary, larger than 16 KiB or absent while `H(body) ≠ H(b"")` shows "N bytes, not shown — deny unless you expect this" and the card has **no approve button** (ADR 0054). A JSON body with a repeated key, or with a key that `sanitize` would change, is likewise hidden; JSON string values keep their whitespace (escaped by the printer) and only have hidden code points removed and counted (ADR 0054 amendment, SECURITY-C1-1). A text body keeps ASCII spaces and line breaks exactly (no collapsing or trimming); every other whitespace character is shown as a visible escape (`\t`, `\r`, `\u{a0}`) and counted, and hidden code points are removed and counted (ADR 0054 amendment 2, SECURITY-C2-3). Every string in zones 1–3 that originates outside the approver (labels, lineage, exe path, cwd, session label, reason, drop name, hints) goes through `core::text::sanitize` (ADR 0035/HUMAN-FACTORS-R1-8): remove every code point that is a Unicode **Default_Ignorable_Code_Point** (includes the Tags block U+E0000–E007F, variation selectors, U+00AD, U+115F, U+1160, U+3164, U+180E, U+200B–200F, U+202A–202E, U+2060–206F, U+FEFF), General_Category **Cc, Cf, Co, Zl, Zp**, and bidi controls; collapse whitespace runs to one space; cap per field (reason 280, op path none — wrapped instead, body none, others 64 characters) with "…"; show a "hidden characters removed: N" badge when N > 0. The tables are hard-coded ranges from Unicode `DerivedCoreProperties.txt` / `UnicodeData.txt` with the version recorded in the module. NFC normalisation and unassigned (Cn) code points are not handled (would need full Unicode tables; unassigned code points render visibly). Rendering via `textContent` only. Risk banners: prod/tier 2 (from the verified policy only); `confirm` operations; credential-minting paths (§7.4); unknown lineage; dev-mode or isolation-3 host; host enrolled < 24 h. Whether a request carries a number match never changes a card's colour, banners or order (ADR 0050): the agent can set `number_match` and read the number, and MCP requests never carry one. #### 7.5.3 Decision (ADR 0017 §4, 0038, CHANGE-03) The PWA computes the **envelope** per handle: `max_window = min(request duration_s, policy max_lease_s, 43 200 s, tier rule)` (tier 2 / once: ≤ 600 s / 900 s; for an until-mode request `not_after ≤ until` replaces the `duration_s` term, ADR 0045), `max_uses = min(request max_uses, tier rule)`, and offers only options inside it; `not_before` is the grant entry's time (§5.4); others are shown disabled with the reason ("policy ceiling 1 h — edit policy", "the agent asked for 1 h"). It never sends a grant the host would reject. Pre-selected: tier 1 → **once, 15 min, 1 use**; tier 2 → **once, 10 min, 1 use**. Handles: for a **lease**, all requested handles are pre-selected when they share env and tier and none is tier 2; a once grant (and any `max_uses = 1` grant) names exactly one handle (ADR 0054; §5.6), so for once only the first is selected and the card says "approve the others separately"; otherwise only the first (HUMAN-FACTORS-R1-11; habituation magnitude unverified, ADR 0035). Options: Once · 15 min · 1 h · Until HH:MM · Custom, each within the envelope. Choosing a lease or Until makes the uses field required, pre-filled with **min(requested, 200)** and labelled "the agent asked for N"; the user may raise it within the envelope explicitly (ADR 0047). **Night shift** preset when the request is a lease ending 5–12 h ahead: until the requested time, uses min(requested, 200), and the card states the total in words ("up to 200 calls to api.github.com until 07:00"), the egress list, and every `confirm` rule: "writes to will wait for you and fail overnight". Tier 2: once only, "Until" disabled. No multi-request approve; bulk *deny* of a host's storm is allowed. "Until 07:00" is shown in the host time zone and the phone's if they differ (ADR 0018 §3). **Number matching** (ADR 0035, 0047, 0050): if `number_match`, the user **types** the 6-digit number shown on the terminal into **that card's own field**; the typed number matches only its own request; a wrong number appends a denial (code 3). Other requests from the same host are not blocked and carry no extra banner. Scope: number matching binds the approval to the request whose number the approver reads on the terminal (relay substitution: ≤ 10⁻⁵ per human-started request, P4); it does not prove a human started it, and it gives the card no extra trust styling. Approve = WebAuthn assertion over the grant body → append `grant`. The PWA builds the grant: for each handle, unwrap `a_i` for that host from the current policy (ASK) and HPKE-seal it to the request's ephemeral key (`grant-share`). #### 7.5.4 Host narrowing check and grant acceptance (P5; ADR 0017, 0038; TS#20 rules kept host-side) Before accepting a grant into its grant table the host takes **its in-memory copy** of the request's RequestDetails (§7.5.1; no copy ⇒ ignore the grant) and the current policies, and requires: - access: handles ⊆ request handles; drop: handles = exactly those whose current policy has field 9 = `request_hash`, mode once rules apply; - no handle is expired (PolicyBody field 10) or in a shared-credential tier/env conflict (ADR 0040); - window = `not_after − not_before`, with `not_before` = the grant entry's time (ADR 0045); - tier 2 or mode once ⇒ `max_uses = 1` and window ≤ 600 s (tier 2) / 900 s (once); - lease with `until` ⇒ `not_after ≤ until` and window ≤ policy `max_lease_s`; lease without `until` ⇒ window ≤ min(request `duration_s`, policy `max_lease_s`) (binding on the instant, so a phone clock running behind cannot make a correct until-grant look widened); - `max_uses ≤` requested; `max_uses = 1` ⇒ one handle; window ≤ 12 h; the policy version's field 6 opened with its `PK` (ADR 0053). A grant failing this is ignored and alarmed; it does not freeze (§6.3). On acceptance the host **opens every `grant-share`** of the grant with the ephemeral key, keeps each `a_i` as `Zeroizing` for the grant's lifetime, and then wipes the ephemeral seed (COMPLETENESS-R1-7; ADR 0008 amended). A grant whose shares do not all open is ignored and alarmed. #### 7.5.5 Fatigue controls (HF-1, CHANGE-02, ADR 0035) Computed by `core::fatigue` from the verified log and the device clock — identically in the daemon and the PWA, so restarting the daemon or opening new sessions resets nothing. Keyed per **host and handle**; drop requests (no handle) use the single pseudo-handle key **(host, DROP)** and count toward back-off and quarantine like any other (IMPLEMENTABILITY-R2-8). Per-session buckets are rejected: sessions are free to create (ADR 0035, 0047). - an **outcome** is a denial entry or a request whose `expires` passed without grant or denial (expired unanswered = soft denial); - the daemon runs coalescing and the pending cap while it holds its append queue, until the new request is logged and registered, so concurrent calls see each other (ADR 0084); - coalescing: an identical pending request — same kind, `H(session token)`, handles, op, **full Scope** (mode, `duration_s`/`until`, `max_uses`) and, for a drop request, the same drop name and hint, both without `number_match` — is returned instead of a new one; a request with `number_match` is never coalesced (ADR 0050, 0069); - back-off: after an outcome, no new request from that host for that handle for 60 s, doubling per further outcome within 1 h, capped at 30 min; - quarantine: 3 outcomes for a host within 10 min ⇒ the daemon refuses new requests from that host for 30 min (`quarantined`). Quarantine and back-off never stop use of an existing valid grant (a night-shift lease keeps working; unanswered overnight `confirm` requests count as outcomes like any other). A flooding agent can thus delay the human's new requests from that host (N29); the storm card says "Do not approve anything to make this stop. `custosd lock` stops this host."; - ≤ 3 pending requests per host. PWA: storm banner at ≥ 3 requests from one host within 10 min (expired ones count); requests of a host in quarantine collapse into one **storm card** and are hidden (they expire unanswered); "Mute this host for N h" (local PWA setting). Push (ADR 0085 §9–10): "Mute all hosts" also pauses push at the relay (the PWA sends `{endpoint: null}` and sends the endpoint again on unmute or expiry). A per-host mute or a quarantine cannot silence a content-free push, so notifications continue for that host (≤ 2/min). The storm card, the muted-hosts line and Settings say so. Relay (ADR 0058): per device a burst of 240 authenticated requests, then 10/s, appends and log reads together (a host therefore makes up to ≈ 10 proxied calls/s, N31); per client address 32 mailbox posts, then 1 per 2 s, 120 mailbox reads, then 2/s, and 5 account creations, then 1/min; an IPv6 client address is its /64 and an IPv4-mapped one its IPv4 address; the client address is the TCP peer, except from the one `--trusted-proxy` peer, where it is the right-most `X-Forwarded-For` entry (ADR 0081; behind a proxy without that option the per-address limits are global); relay-wide, 50 new accounts, then 1/min, and at most 10 000 accounts (`503`). Fatigue control is a throttle, not a security boundary (entry times are signer-claimed within the relay's ±300 s hygiene check). #### 7.5.6 Use (host; proxy mode, §8.1) 1. Resolve handle → current policy (sealed body opened with own KEM key); refuse if disabled or expired. 2. Match the operation against the egress rules (§8.1). No match → refuse (`egress_denied`). 3. Find a usable grant: in the grant table, names this handle at the current policy version, not revoked, epoch current, `uses < max_uses`, inside the window (§7.6), fresh (§6.4), account not frozen or locked, and the caller's session token equals the request's (`H(token)` match; no token ↔ zero hash; ADR 0035). If the grant's request carried an `op` and mode once, the call must match that (method, URL, H(body)) exactly. If the operation needs confirm (§7.4: unsafe method and any matching rule with `confirm`), it requires a grant with `max_uses = 1` whose request carried exactly this (method, URL, H(body)). 4. No grant → create a request (mode once, op = this operation, the call's reason; subject to fatigue and coalescing, §7.5.5) and wait up to `wait_s`; with `wait_s` 0 or absent return `needs_approval{request_id}` at once (IMPLEMENTABILITY-R3-10). MCP passes `wait_seconds` 60 by default. 5. Append `use` with sealed UseDetails — operation, the caller's AgentFacts, per-call reason (required on `http.request`), `H(session token)`, source (native / azure / 1password) — **before** any bridge fetch or upstream call. If the append fails, nothing below happens (P14, COMPLETENESS-R2-4). 6. Value: from the grant's in-memory cache, or: take the grant's `a_i` (opened at acceptance, §7.5.4), open `h_i` (device KEM), `K = a_i ⊕ h_i`, DEK, decrypt; for bridged handles run §9.3 with the decrypted credential (only with `isolation = 1`, ADR 0043). Cache the value (`Zeroizing`) for the grant's lifetime only. 7. Perform the upstream call (§8.1), scrub the response, return it. ### 7.6 Leases and time (ADR 0018) When a grant is first seen in a sync, the host records `t_ref` = time of the checkpoint returned with it and `m_ref` = monotonic now. The grant is inside its window iff `wall_now ≥ not_before − 300 s` (tolerates phone clock skew within the relay's hygiene bound, ADR 0045) **and** `wall_now < not_after` **and** `m_ref + (not_after − t_ref) > mono_now`. Freshness (§6.4) applies on top. On expiry, exhaustion, revocation, epoch bump, policy change, policy expiry, device revocation, freeze or lock, the grant's `a_i` and cached value are zeroized immediately: by the sync that sees the change, and independently by the grant manager (§11.1), which every second wipes everything while a flag is set and purges grants past a deadline and request copies (with their ephemeral keys) past `expires + 60 s`, also while the relay is unreachable (ADR 0079). A daemon restart loses ephemeral keys, request copies, `a_i` and caches: grants must be requested again (simple and fail-closed). Derived credentials are the real time box: Azure bridged values that are tokens expire at the provider; STS/GitHub App/RFC 8693 + DPoP [Deferred]. ### 7.7 Revocation and epochs (ADR 0017 §7) | Action | Entry | Effect at an honest host | | --- | --- | --- | | Revoke one grant | `revocation{grant_id}` | grant invalid at next sync (≤ 25 s typical, ≤ 60 s bound) | | Revoke everything (kill switch) | `revoke.epoch{e+1}` | every earlier grant invalid; later grants carry `e+1` | | Revoke a device | `device.revoke{id}` | host: all its grants invalid, its requests ignored; approver: its later signatures and all grants it signed invalid (TS#13) | | Change a policy | `policy{version+1}` | grants on older versions invalid | Reducing entries need no UV, so the kill switch is one tap (ADR 0027 §1). The "≤ 60 s" bound assumes a relay that appends and does not fork. A refused append raises the red alarm of §6.1; against a forking relay the user runs the prefix check (§6.8), which locks the host on mismatch, or locks it directly (`sudo -k -u /opt/custos/bin/custosd lock`). ### 7.8 Secret drop (agent asks a human for a value) 1. Agent calls MCP `request_secret_from_human{name, reason, egress_hint?}` → daemon appends `request{kind: drop, handles: []}` with `details.drop = (name, hint)` and the fixed scope `{once, 900 s, 1 use}` (ADR 0038). 2. PWA shows "Agent on asks you for a value: " plus the reason (sanitized, §7.5.2). The user pastes the value (trailing CR/LF stripped, §8.1) and fills the drop form (IMPLEMENTABILITY-R2-4): **name** pre-filled from the sanitized agent name lower-cased with runs of characters outside `[a-z0-9-]` replaced by `-` and trimmed to 48, validated against the PolicyBody grammar and editable (approval disabled until valid); **env** required (closed choice, §7.4); **tier** default by the §7.4 rule; **`max_lease_s`** default by the §7.4 rule; egress rule and header template. **The hint is never pre-selected and is hidden until the user has picked or typed a host** (ADR 0037, 0047); then it is shown beside the choice as "Suggested by the agent: — the agent chose this host; check it", with differing labels highlighted, and a typed host equal to a non-registry hint needs hold-to-confirm. A registry prefix or name mismatch blocks with a red warning (§7.4); for anything the registry does not recognise no blocking warning is claimed. The user also picks the handle's lifetime: 1 h / **24 h (default)** / 7 d / keep (PolicyBody field 10, ADR 0038). 3. PWA appends, in order: `secret.version` (approver-created, host shares for that host only), `policy` (UV, field 9 = the drop request hash), `grant` (UV, handles = the new handle; once, 1 use, window = min(900 s, tier rule) — 600 s for tier 2 — computed with the §7.5.3 envelope function). The PoC asks for two WebAuthn assertions (policy, grant); a single combined prompt is [Deferred]. 4. The daemon sees the grant for its drop request (narrowing per §7.5.4 drop rule); MCP returns `{status: "delivered", handle: "custos:"}`. When the answer lands after `wait_seconds`, the agent polls with `request_secret_from_human{request_id}` (it creates nothing); `access.status` returns the delivered handle of a granted drop (ADR 0077). The value never touches the agent, the chat or the transcript (LRN-34). ### 7.9 Panic: "This wasn't me", revoke everything, lock, exposure report (CHANGE-02, CHANGE-12, CHANGE-13) - **This wasn't me** — offered on a request card, on every Active-grant row and on every `use` row in Audit: append `denial{code: not-me}` (if a request is pending), `device.revoke{host}`, `revoke.epoch{e+1}`, and a `denial` for every other pending request; then show the **exposure report** for that host: every handle whose `a_i` was released to it (grants in the log), when, how many `use` entries, and per source what to rotate — native: "create a new value (new secret version) and rotate it at the provider"; Azure: "set a new secret version in Key Vault ``; rotate the service principal secret"; 1Password: "rotate the item; **rotate the service-account token** (listed for every host that ever fetched with it)". Before confirming, the screen states the cost: "This host must be enrolled again and every secret it used must be re-created for it (PoC)". Checklist state is local in the PoC. - **Revoke everything**: `revoke.epoch` plus a `denial` for every pending request, then offers "Mute requests from all hosts for N h" (local PWA setting; it also pauses push, ADR 0085 §9) and shows the prefix check for **every active host** with its checklist (§6.8). - **Lock on the host**: `sudo -k -u /opt/custos/bin/custosd lock --profile P` (persisted local freeze of use and requests; only `custosd unlock` with the same privilege unlocks, §6.3). Works without the phone. `custos lock` over the socket does the same but runs through the user-uid binary the agent can shadow (N25). "This wasn't me" tells the user to run the check and lock from a session the agent cannot have touched. - Per-host shares mean revoking a **host** needs **no re-split** for the remaining hosts: the revoked host's `h_i` is useless without an `a_i` that approvers will not release again (P2). Revoking an **approver** does not restore two-of-two for new secrets (N11). - [Deferred] Product panic rotate: provider rotation adapters (GitHub, AWS IAM, Azure Key Vault, 1Password, npm, webhook), a signed rotation checklist in the log, age policy and nags. - The exposure report also says, per provider: "Review keys, tokens and sessions created at during these grant windows — a credential the agent minted outlives Custos revocation" (N6); for GitHub it names deploy keys and repository collaborators (ADR 0054). After **Revoke device** of an approver, the PWA states N11: "This approver still holds the account secret: it can read future metadata and, with a copy of a host's disk, values of secrets you create from now on. Until account-secret rotation exists, re-enroll your hosts (new keys) or re-create the account." - Every revoke action above ends with the prefix check listing every affected host (§6.8); a refused append raises the §6.1 alarm. ### 7.10 Recovery [Deferred] (ADR 0012, CHANGE-24) Printed kit = 32-byte seed `R` (words + QR). `R` derives a composite **recovery key** (public key in genesis key 5) and an AEAD key that wraps each `AS_e` in the `as.epoch` entries. Recovery: a new approver device appends `root.recover` signed by the recovery key, which revokes all approvers and adds itself; every device alerts; product veto window 72 h during which any existing approver can cancel. The relay can never initiate recovery or add an approver (CHANGE-22, CHANGE-24). Never uploaded; no password, so no Argon2id parameters to downgrade (CHANGE-11). ### 7.11 Add a host to an existing secret [Deferred] (ADR 0014 §3) A `share` grant releases `a_i` once to an existing holder host *i*, which reconstructs `K`, picks `h_j`, appends a new `secret.version` for {i, j} with transit `a_j` sealed to `AK`; the approver adopts it into a new policy version. PoC workaround: re-create the secret for the new host set. Cost (N23): approvers hold only `a_i`, never the value, so a human re-enters each native value on the phone (§7.3 a) or the new host re-proposes it; `custos bridge add` re-proposes a bridge credential in one command. This hits the remote-box use case and is flagged to the owner (research/13). ### 7.12 AS epoch rotation [Deferred] (ADR 0014 §4) On approver revocation: a remaining approver picks `AS_{e+1}`, appends `as.epoch{e+1, AK_{e+1} pub, per-approver HPKE(AS_{e+1})}` (UV), then re-issues every policy with shares and bodies under `ASK_{e+1}`. Hosts seal new transit and details to `AK_{e+1}`. --- ## 8. Exposure modes (ADR 0016) ### 8.1 `proxy` (default; the only mode in the PoC) Entry point: daemon RPC `http.request` (used by MCP `http_request` and `custos http`). The loopback listener for SDKs is [Deferred]. 1. **Parse** the URL once, in the daemon (WHATWG via `reqwest::Url`), and build `core::policy::Target {scheme, host_ascii, port, method, path_raw, query_raw}` from that parse; the core matcher sees only `Target` (core has no URL parser, §2.3), and the **same** `Url` object is the one sent (IMPLEMENTABILITY-R3-7). Reject: userinfo, fragments, non-ASCII after IDNA, percent-encoded `/`, `.` or `\` in the path (`%2F`, `%2E`, `%5C`, any case), a `;` or `%3B` anywhere in the path (path parameters: Tomcat/Jetty/Spring strip `;…` before resolving dot segments, so `/repos/..;/admin` would reach `/admin`; ADR 0069), any scheme but `https`, IP-literal hosts, `localhost`, and a query containing a `_method` parameter (method-override, ADR 0037 amendment): `&`- and `;`-separated names are compared case-insensitively after `+` → space, percent-decoding (up to 3 rounds), dropping an array suffix (`[]`, `[x]`) and PHP's name mangling (space, `.`, `[` → `_`), so `%5Fmethod`, `.method`, `+method`, `_method[]` are refused too (ADR 0065). The request body is checked the same way for any method (`core::policy::check_body_override`, `egress_denied`, before an approval request is created): a form parameter with such a name, or the token `_method` in any encoding (JSON incl. `\u005f` escapes, multipart `name="_method"`) not adjacent to a letter, digit or `_` (ADR 0065). 2. **Match** (`core::policy::matches(&Target, &[EgressRule])`): a rule matches when scheme, host (exact, lowercase), port (explicit or default), method ∈ methods, and path equal to the prefix or starting with it at a `/` boundary; the operation is allowed iff **any** rule matches, and needs confirm iff the method is unsafe and any matching rule has `confirm` (ADR 0054). 3. **Headers** from the agent: at most 32, total ≤ 8 KiB; drop hop-by-hop headers, `Host`, `Authorization`, `Proxy-*`, `Cookie`, `Accept-Encoding`, `TE`, `Range`, `If-Range`, `X-HTTP-Method-Override`, `X-HTTP-Method`, `X-Method-Override`, the URL-override headers `X-Original-URL`, `X-Rewrite-URL`, `X-Original-URI`, `X-Forwarded-Prefix`, `X-Forwarded-Path` (ADR 0073), and any header with the policy's header name (case-insensitive); send `Accept-Encoding: identity` (ADR 0037). Add the policy header with `template.replace("{}", value)`; if the result is not a valid HTTP header value (CR, LF, other controls, non-visible-ASCII), return `invalid_value` (static message, nothing echoed) without calling upstream. **No substitution in URL or body**; agent-written `{{custos:x}}` text is passed through literally. Values entered anywhere (PWA, CLI prompt, `.env` import) have trailing CR/LF stripped before encryption; nothing else is trimmed. 4. **Send** with reqwest: redirects **disabled** (a 3xx is returned to the agent with the scrubbed `Location`), 30 s timeout, request body ≤ 1 MiB, response body ≤ 4 MiB (truncated flag), rustls with `prefer-post-quantum` (reqwest builds rustls without it; the daemon enables the feature itself and a test pins X25519MLKEM768 as the first key share, ADR 0079), environment proxy variables ignored (ADR 0029). 5. **Scrub** response headers and body with the redactor for this value (§8.4), on the blocking thread pool, never on an async worker (ADR 0078, ADR 0087); strip `Set-Cookie` and `Set-Cookie2` (the proxy keeps no cookie jar; a session cookie would be a standing credential for the agent, N6); count replacements into `UseDetails.redactions`. A response with `Content-Encoding` other than `identity` has its body withheld (`body_withheld: "encoded"`) — the redactor cannot see inside it. 6. **Return** `{status, headers, body (UTF-8 text or base64), redactions, truncated, body_withheld?}`. ### 8.2 `helper` [Deferred] git `credential` (`get` with protocol/host checked against an egress rule), AWS `credential_process`, `docker-credential-*`, `--password-stdin`. The value reaches the tool process only, never an environment variable. ### 8.3 `inject` [Deferred] Only with an approver-set pin: absolute path + SHA-256 + argv prefix; child in a sandbox (no network except the proxy, private tmp, read-only home) and in its own process group / cgroup (`cgroup.kill`) / Job Object, killed at grant end. UI label: "the command can read the value". No `reveal` mode in C1. ### 8.4 Redaction (accident prevention, not a boundary — ADR 0016) `core::redact::Redactor` builds needles per value (it sees identity-encoded bodies only, §8.1) (values < 4 bytes: exact match only): exact bytes; standard and URL-safe base64 in all 3 alignments (only fully determined characters); lower- and upper-case hex; `encodeURIComponent` and form encoding (`+`); JSON string escaping (incl. `\/` and `\uXXXX`); **nested** base64∘base64 and hex∘base64 (CHANGE-16); any maximal run of ≥ 12 consecutive characters of the value (TS#29), found in time linear in the response by a suffix automaton of the value (ADR 0078). Longest needle first; replacement `«custos:»`. The value, every needle and every intermediate copy of the response are held in buffers that are wiped before they are freed (ADR 0008 §5, ADR 0069). gzip detection and entropy flagging [Deferred]. --- ## 9. Bridges: 1Password and Azure Key Vault as primary stores (ADR 0021, 0022, 0026) Existing vaults stay the system of record. Custos is the gate between them and LLM sessions. ``` agent ─handle─► custosd ─ grant (phone UV / lease) ─► open bridge credential (Tessera) ─► fetch item ─► zeroize credential ▲ │ per fetch, never cached value in daemon memory └─scrubbed resp──┘◄────────────── proxy injects item value toward the policy's egress rule ◄──── for the grant only ``` ### 9.1 Custody rules 1. **No standing access on agent hosts.** No `az login`, no `OP_*` variables, no authorized `op` desktop integration in agent terminals, no managed identity with Key Vault roles on agent VMs. `custos doctor` detects them (§9.5). 2. **The bridge credential is a Tessera secret** (proposed by the host, adopted by an approver), released only as the backing secret of a bridged handle's grant, used for exactly one fetch, then zeroized (ADR 0022 §2, 0026 §6). "Zeroized" covers daemon memory only: a 1Password token handed to `op` is exposed to whatever can read that child, and an Azure secret to whatever can read daemon memory (N14), which is why **every bridge runs only with `isolation = 1`** (ADR 0043). 3. **The reference lives in approver-signed policy** with a strict grammar (§5.5); nothing from the request is templated into it (BR-3). 4. **Same gate as native handles**: tier, egress, header template and lease rules are identical. 5. **Upstream scope = handle set** (ADR 0022 §1, 0040: handles on one credential share tier and env): Azure role assignment at individual-secret scope for tier-2 handles, otherwise one vault per app per env; 1Password: dedicated agent vaults per env/tier, service account read-only on those vaults. ### 9.2 Comparison | | 1Password | Azure Key Vault | | --- | --- | --- | | Credential (Tessera value) | service-account token `ops_…` | JSON `{"tenant_id","client_id","client_secret"}` of a service principal (certificate, workload identity, Entra device-code [Deferred]) | | Reference (policy `Source`) | `op://vault/item/field` + pinned `op` path and SHA-256 | vault name + secret name [+ version] | | Fetch | spawn `op read --no-newline ` | Entra client-credentials token, then Key Vault GET | | Upstream least privilege | service account read-only, vault-scoped | `Key Vault Secrets User` at secret scope (tier 2) or vault scope (one vault per app/env) | | Tests [PoC] | stub `op` executable via the `Runner` trait (§14.1) | fake `Upstream` transport asserting exact URLs/forms; recorded response shapes | | Live demo | optional, owner's test vault | optional, owner's test vault | ### 9.3 Fetch algorithms Both bridges run only after the `use` entry is appended (§7.5.6 step 5) and only with `isolation = 1`; otherwise `bridge_requires_service_mode` (ADR 0043). **Azure.** (1) Parse the credential JSON strictly (`deny_unknown_fields`; tenant = GUID or `[a-z0-9.-]{1,253}`; client id = GUID). (2) `POST https://login.microsoftonline.com/{tenant}/oauth2/v2.0/token` with form `client_id`, `client_secret`, `scope=https://vault.azure.net/.default`, `grant_type=client_credentials`. (3) `GET https://{vault}.vault.azure.net/secrets/{name}[/{version}]?api-version=2025-07-01` with `Authorization: Bearer `. (4) Take the JSON field `value`. (5) Zeroize credential, token and response buffers. Redirects disabled; 30 s timeout; any non-200 → error without echoing bodies. **1Password** (Linux: isolation 1 only, N14; macOS: **dev mode (isolation 2) only**, an owner decision with the stated limit N34 (ADR 0056) — macOS service mode refuses `op` sources with `bridge_unsupported_platform` because whether another uid can read the child's environment there is unverified, ADR 0055; isolation 3 is refused everywhere; implementation ADR 0060). (1) Canonicalise the policy's absolute `op` path (`std::fs::canonicalize`) and require it to **equal** the policy path (no symlink at any level); check that the file and every component of that path are not writable by any uid other than root and the daemon user (`stat`; Homebrew's user-owned `/opt/homebrew/bin` fails; in service mode also no extended ACL on any component, the ancestor-check rule; dev mode checks owner and mode bits only, because the agent is the daemon uid there and macOS puts a deny-only ACL on every home directory; ADR 0073); then that the file has the policy's SHA-256 (else refuse). The verified path is the one executed (AGENT-HOST-R2-6). The child runs as the daemon user, so the agent's uid cannot read its environment (ADR 0030). (2) Spawn it with a **cleared environment** plus only `OP_SERVICE_ACCOUNT_TOKEN=`, `HOME=`, `OP_CACHE=false`; no `OP_CONNECT_*` (they would take precedence); own process group; stdin closed; 30 s timeout then group kill. (3) stdout (≤ 64 KiB) is the value; stderr is discarded. (4) Zeroize the token buffer; delete the temp dir. Note: `op read` costs three service-account reads; pass vault/item ids in the reference to reduce it (1Password docs). The reference is visible in the child's argv to other local uids (N33); ids are opaque, so setup recommends them. ### 9.4 Setup helpers (print, never run; ADR 0022) `custos bridge setup azure --subscription S --resource-group G --vault V [--secret N]` prints `az ad sp create-for-rbac` / `az role assignment create --role "Key Vault Secrets User" --scope …/vaults/V[/secrets/N]`. `custos bridge setup 1password --vault V` prints how to create a read-only service account limited to `V`, how to install `op` to a root-owned path (e.g. `/opt/custos/bin/op`), how to write the reference with vault and item ids, and warns never to use desktop integration in agent terminals. Then `custos bridge add azure …` / `custos bridge add 1password …` read the credential from a no-echo prompt and propose it (§7.3 b) with the references as `SecretMeta` hints. ### 9.5 `custos doctor` (CHANGE-19, BR-4, BR-6) Split by where a fact can be observed (ADR 0042). **Client-side** — run by the `custos` CLI in its own process, i.e. in the environment, home and cwd of whoever runs it (run it from the agent's shell to check the agent's environment): environment variables, home files, `./.env`, and privilege escalation of the calling uid (uid 0, `sudo -n true` succeeds, member of `docker`; ADR 0043). **Daemon-side** — RPC `doctor`: isolation mode, state/runtime directory permissions and the ancestor check of the executable, unit/plist, state and runtime directories (ADR 0048), current `docker`/uid-0 status of every client uid, `op` path checks of current policies, IMDS reachability. The CLI also runs the same ancestor check itself (it needs no privilege), so a fake daemon cannot hide it. The CLI merges both. Reports **standing access** with a remediation line each, exit code 1 if any: environment variables `OP_SERVICE_ACCOUNT_TOKEN`, `OP_SESSION_*`, `OP_CONNECT_TOKEN`, `AZURE_CLIENT_SECRET`, `AZURE_CLIENT_CERTIFICATE_PATH`, `AWS_ACCESS_KEY_ID`, `AWS_SECRET_ACCESS_KEY`, `AWS_SESSION_TOKEN`, `GITHUB_TOKEN`, `GH_TOKEN`, `NPM_TOKEN`, `OPENAI_API_KEY`, `ANTHROPIC_API_KEY`; files `~/.azure/msal_token_cache.*`, `~/.aws/credentials`, `~/.config/gcloud/application_default_credentials.json`, `~/.npmrc` with `_authToken`, `~/.config/gh/hosts.yml` with `oauth_token`, `./.env` with values that are not `custos:` handles; IMDS reachable (TCP connect to `169.254.169.254:80`, 300 ms timeout); daemon running in same-user dev mode or enrolled with isolation 3 (ADR 0030, 0043); calling uid can become root (uid 0, passwordless sudo via `/usr/bin/sudo -k -n true`, `docker`); a client uid joined `docker` after enrollment; daemon executable or any ancestor of state/runtime directory writable by a non-root uid or carrying an ACL; `op` path not canonical or writable by a non-root uid. Detection of MCP configs, shell history and browser sync [Deferred]. --- ## 10. Relay API (v2) JSON over HTTP. Signed and sealed objects are base64url strings. **Encodings** (IMPLEMENTABILITY-R3-6): every id in a path (`:acct`, mailbox `:id`) and in a header (`x-custos-account`, `x-custos-device`, `x-custos-sig`) is unpadded base64url (§3.1); `x-custos-time` is decimal ms; JSON bodies are sent with `content-type: application/json`. Error body `{ "error": "", "message": "" }` — messages never echo request content (CHANGE-15). The relay serves **no** static files or code (ADR 0019) and has **no admin or support endpoints** that can enrol, grant, reset or read (CHANGE-22). | Method & path | Auth | Request → response | | --- | --- | --- | | `GET /v1/relay` | none | → `{suite: 1, relay_pub, vapid_pub, version}` (`vapid_pub`: the VAPID `applicationServerKey`, base64url, `null` when push is off; ADR 0085) | | `POST /v1/accounts` | none, rate-limited | `{genesis}` → `{account_id, checkpoint}`; verifies genesis with `LogState` | | `POST /v1/accounts/:acct/log` | device | `{entry, expect_size}` → `200 {checkpoint}` · `409 {checkpoint}` (head moved) · `422 {error}` (invalid per §5.6) | | `GET /v1/accounts/:acct/log?from=n&wait=s` | device | → `{entries: [..], checkpoint}`; returns when size > n or after `min(s, 25)` s; ≤ 512 entries per page | | `POST /v1/accounts/:acct/push` | device (active approver only) | `{endpoint: url \| null}` → `{ok: true}`; sets, replaces or (null/absent) removes this device's push endpoint · `403 not_approver` · `404 push_disabled` · `422 bad_endpoint` (not `https`, host not allow-listed, user info, fragment, > 2048 B as given or as serialised); ADR 0085, ADR 0116 | | `POST /v1/mailbox/:id` | none, rate-limited | `{msg}` (a §5.7 `Frame`) → `{index}`; creates on first post; TTL 10 min; ≤ 8 messages; not deleted on read | | `GET /v1/mailbox/:id?after=n&wait=s` | none | → `{msgs: [..]}` (long-poll ≤ 25 s) | | `GET /v1/health` | none | → `{ok: true}` | **Device auth** (ADR 0023): headers `x-custos-account`, `x-custos-device` (kid), `x-custos-time` (ms), `x-custos-sig` = composite signature, ctx `custos/C1/req-auth`, over `det-CBOR(["custos-req", account_id, kid, method, path_and_query, time, H(body)])` with `account_id` and `kid` as bstr, `method` and `path_and_query` as tstr — `path_and_query` is the exact request-target bytes as sent (percent-encoding unchanged) — `time` as uint and `H(body)` over the exact body bytes on the wire; a request without a body (GET) uses `H(b"")`. One HTTP-level golden (method, URL, headers, body of a signed `POST …/log`) is reproduced by relay, daemon and wasm. Relay checks: |time − now| ≤ 120 s; `H(M)` of the signed request-auth message (not the signature bytes, which a malleable verifier could let an attacker re-encode; ADR 0004 amendment) not in the replay cache (kept 240 s); device active in the relay's `LogState`; per-device rate limit. Because `time` is in `M`, a client signs with a strictly increasing time (the approver adds 1 ms when two requests fall in one millisecond, ADR 0080), so a repeated identical request is never refused as a replay. No credential in any URL (NW-1). **Relay behaviour:** keeps one `LogState` per account (rebuilt from SQLite at start); verifies every append fully (an honest relay never stores an invalid entry); signs a fresh checkpoint for every append response and every long-poll response, **built while holding the per-account lock**: `(size, head)` and time are read together and `time = max(last_signed_time_for_account, now)`, so checkpoint time never decreases with size, even under concurrent handlers or a backward wall-clock step (IMPLEMENTABILITY-R2-7; otherwise an honest relay could trip §6.2 rule 3); applies the hygiene checks of §5.6. **Storage** (SQLite, `rusqlite` bundled): `accounts(id, genesis)`, `entries(account, seq, bytes)`, `mailbox(id, idx, bytes, expires)`, `replay(msghash, expires)`, `push(account, device, endpoint)`. **Push** (ADR 0085, on with `--push-subject`): every appended `request` entry marks its account; one worker sends each **active approver** with an endpoint an **empty** RFC 8030 push (`TTL: 600`, `Urgency: high`, `Topic: custos-pending`, `Authorization: vapid t=, k=`; JWT reused per audience until < 1 h remains; a JWT is cached only after a push service answered it, a reusable one is never evicted, and with 256 reusable entries a new audience's JWT stays uncached, so no account can force another's refresh, ADR 0116), at most one per account per 30 s plus one deferred trailing ping (never dropped); endpoint hosts allow-listed (`*.push.apple.com`, `fcm.googleapis.com`, `updates.push.services.mozilla.com`, `*.notify.windows.com`; `--push-host` replaces), no redirects, 10 s timeout; a `device.revoke` entry deletes the device's endpoint under the account lock (subscribing checks the role and writes under the same lock), a `404`/`410` from the push service deletes **that** endpoint only (not a newer one the device posted meanwhile), `403`/`3xx`/`5xx`/no answer keep it, and listing the targets deletes rows of devices that are not active approvers (ADR 0116). **Inspect:** `custos-relay inspect --db relay.sqlite` dumps every row (CBOR diagnostic); `--search NEEDLE`… / `--search-stdin` searches every row's raw bytes, the dump and the raw database files (WAL, free pages) for each needle in raw, hex, base64/base64url (3 alignments), percent and JSON forms, prints `HIT needle #n … in ` without the needle and exits 1 on a hit — a `bstr` is printed as hex, so grepping the dump alone misses plaintext inside it (ADR 0085). **Logs:** method, route template, status, latency only — no bodies, no headers. **CORS:** exact allow-list of approver origins (`--cors-origin`), methods `GET, POST`, allowed request headers `content-type` plus the four `x-custos-*` headers (a JSON `content-type` is not CORS-safelisted, so the preflight must allow it), no exposed headers; test `relay::cors_preflight_allows_content_type`. **TLS:** none in-process; PoC binds `127.0.0.1` or sits behind a TLS tunnel (ADR 0028). Binary: `custos-relay --listen 127.0.0.1:8787 --db relay.sqlite --key relay.key --cors-origin [--push-subject mailto:ops@example.org [--push-host HOST]…]`. --- ## 11. Daemon, CLI and MCP ### 11.1 Daemon (`custosd`) - **Deployment** (ADR 0030): **service mode** — `custosd` runs as its own OS user (`_custos` macOS LaunchDaemon with `UserName`; `custos` Linux systemd unit with `User=`). Executable `/opt/custos/bin/custosd` (root-owned, 0755, in a root-created `/opt/custos`; never a Homebrew prefix or `~/.cargo/bin`; ADR 0048). State directory `` = `/opt/custos/state/` (macOS) or `/var/lib/custos/` (Linux), owner the daemon user, 0700. `custos service install --print` prints the user, group, `install -o root -m 0755` of the binary, directories, plist/unit, the recommended sudoers line `Defaults: timestamp_timeout=0`, and `sudo` commands; nothing runs privileged from our code. **Dev mode** — the daemon runs as a client uid only with `--dev-same-user`, warns on every start and in `status`, uses `~/.custos/`, enrolls with `isolation = 2`, and refuses every bridge except 1Password on macOS (owner override, ADR 0056 §1; residual risk N34). A service-mode host whose client uid can become root enrolls with `isolation = 3` and also refuses bridges (ADR 0043). Switching mode means re-enrolling, and native values survive only on hosts that keep their state (N28): `custos service install --print` warns before a dev → service switch. `CUSTOS_HOME` overrides the state path; profiles let two hosts run on one machine for demos. - **Socket** (ADR 0042): in a runtime directory **outside** `` (a client needs search permission on every path component, path_resolution(7)). Linux: systemd `User=custos`, `Group=custos-clients`, `RuntimeDirectory=custos/`, `RuntimeDirectoryMode=0750`, `UMask=0077` → `/run/custos//custosd.sock`. macOS: LaunchDaemon `UserName=_custos`, `GroupName=custos-clients`, `Umask=63`; `/opt/custos/run//custosd.sock`, directory created by the printed `install -d -o _custos -g custos-clients -m 0750 …` inside the root-created `/opt/custos`. After `bind` the daemon sets the socket to 0660 and checks its group. Dev mode: `~/.custos//run/` 0700, socket 0600. Directory permissions are the portable control (unix(7)). macOS and Linux. Windows (owner decision, ADR 0056): a named pipe `\\.\pipe\custos-` behind `cfg(windows)` with `reject_remote_clients`; the daemon **compiles** for `x86_64-pc-windows-gnu` and is **not run or tested on Windows**; the default pipe security descriptor applies and every local client is accepted (no uid check). There is **no Windows client**: `custos-mcp` and `custos-cli` use Unix sockets and Unix-only APIs and do not compile for Windows (ADR 0068), so no agent on Windows can reach the pipe in the PoC. - **Protocol:** JSON-RPC 2.0, one request per line, ≤ 2 MiB per line, `deny_unknown_fields`. The session token travels as `params.session` (optional member of every method's params; never a top-level member). Types live in `custos-rpc` (§2.2). - **Peer identity:** `peer_cred()` uid must be in `client_uids` (config; default the installing user; uid 0 is refused at start) — in service mode the daemon's own uid is not a client. pid → comm-based lineage label; cwd and exe path are client-reported params (labels only, never authentication; ADR 0015 §3, 0023 §1, 0042). - **Paths:** no RPC takes a filesystem path that the daemon opens. The daemon opens only ``, its runtime directory, and the `op` binary named by approver-signed policy after the canonical-path check (§9.3) (ADR 0042). - **Sessions** (ADR 0035): `custos launch [--label L] -- ` calls `session.open`, then execs `` with `CUSTOS_SESSION=` and `CUSTOS_SOCKET=`; the MCP server forwards the token. A request records `H(token)` (zero hash for none); its grant is usable only with the same token (or none). Labels are host-reported. - **State files** (0600, daemon user): `device.cbor` (seeds), `config.cbor` (relay URL, pinned `account_id`, `relay_kid`, label, `client_uids`), `view.cbor` (last verified checkpoint only; written only by the sync path), `locked` / `frozen` (flag files with time, reason, evidence; ADR 0051), `lock` (empty; `flock` target), `log.cbor` (the verified log, append-only, persisted after a page applies on a clone; read by `custosd verify`, §6.5, §6.8). Local config holds no policy fields, so it can only narrow (CHANGE-26). Grants, `a_i`, request copies, caches and ephemeral keys are memory-only. Fatigue state is not stored: it is derived from the log (§7.5.5). `config.cbor` present but `view.cbor` missing ⇒ `frozen` created at start (§6.3). - **Hardening at start** (ADR 0023, 0028, 0030): Linux `set_dumpable_behavior(NotDumpable)`; `setrlimit(CORE, 0)` on all Unix; panics abort with a static message and never format secret types; refuse to start unless `` is mode 0700 owned by the daemon uid and every entry in it is owned by the daemon uid with no group/other bits, and (service mode) the runtime directory is owned by the daemon uid, group `custos-clients`, mode exactly 0750 (a stale socket in it is removed); refuse uid 0 in `client_uids`; **ancestor check** (ADR 0048): the canonical executable path, the plist/unit file, `` and the runtime directory, and every ancestor of each up to `/`, must be owned by root or the daemon user, carry no group/other write bit, and show no ACL (`/bin/ls -ld` prints no `+` after the mode) — else refuse to start, naming the path. - **Local subcommands** (no socket, run as the daemon user: `sudo -k -u _custos /opt/custos/bin/custosd …`; dev mode as the user; each first runs the ancestor check): `unfreeze`, `unlock`, `lock` (create or remove `/locked` / `frozen` under the short state lock, §6.3, ADR 0051), `ack-report --at S` (acknowledges the new approvers and egress hosts `status` listed at log size `S` by writing `/report-mark`; S must not exceed the persisted log; ADR 0067 §2) and `verify --at S --words "…"` (replays `log.cbor`; MATCH / NOT YET / FORK / CORRUPT, FORK and CORRUPT set the lock; §6.8, ADR 0041). All take `--profile P`. Every rendered host command (PWA, CLI hints, `service install --print`) starts with `sudo -k` (test `docs::every_host_command_has_sudo_k`). - **Tasks:** sync loop (long-poll, §6; narrowing check and share opening at grant acceptance), grant manager (1 s tick independent of sync: flag files, deadlines, zeroize on any invalidation, pending freeze-flag write; ADR 0079), RPC server. | Method | Params | Result | | --- | --- | --- | | `status` | — | account fingerprint, host id + 4-word device fingerprint, isolation mode, relay, log size + 4-word `fp(size)` (informational; the fork check is `custosd verify`, §6.8), freshness age, frozen/locked, active grants (handle, expires, uses left, session label and session ref = first 4 bytes of `H(token)` in hex — **never the token**, AGENT-HOST-R3-7), new approvers and new egress hosts of this host's handles since the last acknowledgement (`report_mark`; only the local `custosd ack-report` moves it, never an RPC, so an agent cannot clear them — ADR 0067 §2; `ack_command` shows the command while a list is non-empty), escalation alarm (client uid now uid 0 or in a root-equivalent group, ADR 0048, 0067; re-checked by `status` when the 60 s cache is missing or stale, and at every grant acceptance, ADR 0084), sync warning (§6.3) | | `enroll.start` | `{relay, label, escalation: {uid0, sudo_nopasswd, docker}}` | `{enroll_id, code}`; any `escalation` flag ⇒ `isolation = 3` (ADR 0043) | | `enroll.wait` | `{enroll_id, wait_s ≤ 120}` | `{state: "waiting" \| "sas" \| "added" \| "aborted", sas?}` | | `enroll.confirm` | `{enroll_id, confirm: bool}` | `{account_fingerprint, isolation}` after m4 and the log sync (waits ≤ 120 s; else `{state: "waiting"}` and the CLI calls `enroll.wait`). Enrollment state is daemon memory: a daemon restart drops it, an abandoned `enroll_id` expires with the mailbox TTL (10 min), a second `enroll.start` while one is open fails `busy` (IMPLEMENTABILITY-R3-5) | | `session.open` | `{label}` | `{token}` | | `handles.list` | — | `[{name, env, tier, description, egress: [method host path], expires_at?, grant: {expires, uses_left} \| null}]` — never values | | `access.request` | `{handles, reason, mode, duration_s?, until?: "HH:MM", max_uses?, op?, number_match?, wait_s? ≤ 120}` | `{request_id, fingerprint, number?, status: pending\|granted\|denied\|expired}` | | `access.status` | `{request_id, wait_s?}` | `{status, fingerprint, handle? (granted drop: `custos:`), grant?: {expires, uses_left}, hint?}` (ADR 0077) | | `http.request` | `{handle, method, url, headers?, body_b64?, reason, wait_s?}` (`reason` required: recorded in the `use` entry) | `{status, headers, body, body_b64?, redactions, truncated, body_withheld?}` or error `needs_approval{request_id}` · `egress_denied` · `stale` · `frozen` · `locked` · `quarantined` · `invalid_value` · `bridge_requires_service_mode` | | `drop.request` | `{name, reason, egress_hint?, wait_s?}` | `{request_id, fingerprint, status, handle?}` | | `secret.propose` | `{name, env, kind, value_b64, egress_hint?, references?, op_path?, op_sha256?}` | `{secret_id}` (value supplied by the CLI from a no-echo prompt or its own `.env` parse; `op_path` is a hint string the daemon never opens, `op_sha256` computed by the CLI) | | `audit.tail` | `{n ≤ 200}` | public entry summaries (kind, actor label, time, handle names this host knows) | | `audit.verify` | — | `{ok, size, head, log_fingerprint, checkpoint_time, frozen, locked, evidence?}` | | `audit.report` | `{since_ms}` | **public fields only** (ADR 0052): entry kinds, times, actor labels, ids, use counts per grant, handle names and egress hosts of policies this host can open, new devices, log fingerprint, and the line "reasons and agent details are on the approver (Audit → Report)". Reasons and agent facts are sealed to `AK`, which hosts never hold. Read-only: it does not acknowledge the `status` lists (ADR 0067 §2) | | `audit.exposure` | `{host_id?}` | exposure ledger (§7.9) from the public log, naming only handles this host can open; the full ledger is the PWA's | | `doctor` | — | daemon-side findings only (§9.5) | | `lock` | `{}` | `{ok}` — persisted local lock; unlocking needs `custosd unlock` (§6.3); convenience only — the documented mitigation is `custosd lock` (N25) | | `monitor.status` | — | value-free status (ADR 0172): daemon mode, isolation and flags; relay URL, freshness state, reachability, checkpoint time, log size and fingerprint; enrolled devices; handle **metadata** (never values); active grants (handles, mode, approver, session label and ref, reason, window, remaining time and uses); pending requests; push (`observable: false`); alarms; journal counters. Typed (`custos_rpc::monitor`), read-only | | `monitor.events` | `{log_from?, local_from?, alarm_from?, since_ms?, limit? ≤ 500, newest?}` | `{boot, events, next_log, next_local, next_alarm, more}` — log entries (kind, host, approver, handles, request fingerprint, decision, denial code, grant window; reason, session and bound op for this host's requests still in memory), the daemon's in-memory journal of agent calls (≤ 1 024: method, egress host, path without query, reason, session, decision, upstream status, redactions, refusal kind) and alarm lines; never a value, token, header or body (ADR 0172) | **Call deadline** (ADR 0087): `http.request` answers within `min(wait_s, 120) + 150 s`, 30 s under the clients' `wait + 180 s` timeout (`custos-mcp`, `custos-cli`), so a client timeout (`outcome_unknown`) means a hung or dead daemon. The request and the `use` must be in the log 90 s before that end (the append queue wait, each relay append and each 409 re-sync fetch stop there; the approval wait ends there too); a send that could end after it (now + 30 s upstream timeout) is not started. A call that reaches its deadline fails `internal` with "call deadline passed: the request was not sent upstream (a use may be logged: check audit_recent)". The apply after an append is never cancelled (it persists, ADR 0051). Every method's params may carry `session` and `client: {cwd, exe}` (client-reported, ADR 0042). **No RPC or MCP result ever carries a session token** other than `session.open`'s own; sessions are shown as label + session ref (test `daemon::status_never_returns_session_token`; the canary scanner also searches RPC outputs for issued tokens). `access.request` without `duration_s` or `until` in lease mode, or with `until` on a host whose zone is unknown, fails with `invalid_params` (ADR 0045). An expired unanswered request returns the §6.7 hint text. ### 11.2 CLI (`custos`) `daemon [--profile P] [--dev-same-user]` · `service install --print [--profile P]` · `enroll --relay URL --label NAME` (runs the escalation check as the calling uid, then `enroll.start` → prints code; `enroll.wait` → prints SAS, asks y/N; `enroll.confirm` → prints account fingerprint and isolation) · `launch [--label L] -- cmd…` · `status [--json]` · `monitor [--since T] [-n N] [--interval-ms MS] [--polls N]` (status + events as value-free JSON lines, ADR 0172; schema in `crates/cli/README.md`) · `handles` · `request … --reason R (--once | --lease 2h | --until 07:00) [--uses N]` (writes fingerprint **and the 6-digit number to match** to the TTY) · `http --reason R [-H k:v]… [-d @file]` · `secret add --env E [--egress-hint URL]` (value via no-echo prompt or stdin, at most 1 MiB, held in wiped buffers only; ADR 0082) · `import --env E` (the CLI parses, proposes and rewrites the file itself, keeping owner and mode) · `bridge setup azure|1password …` · `bridge add azure --tenant T --client-id C --ref vault/secret[/version]…` · `bridge add 1password --ref op://…` · `doctor` · `audit tail|verify|report --since 22:00|exposure` (host-side: public fields, ADR 0052; `audit tail --json [--follow] [--since T]` prints one value-free JSON line per log, journal or alarm event, ADR 0172) · `inspect ` (CBOR diagnostic notation of any Custos object, ADR 0007) · `lock` (convenience). (`unfreeze`/`unlock`/`lock`/`verify`/`ack-report` are `custosd` subcommands run as the daemon user, §6.8.) Exit codes: 0 ok, 1 findings/denied, 2 usage, 3 needs approval, 4 stale/frozen/locked, 5 outcome unknown (the call was sent but no answer came back within `wait_s` + 180 s; it may have completed, so check `custos audit tail` before retrying; ADR 0070). ### 11.3 MCP server (`custos-mcp`, stdio, rmcp) Server instructions and every tool description state: *"Never ask the user to paste a secret into the chat. Use request_secret_from_human. Values are never returned to you."* | Tool | Input | Output (never a value) | | --- | --- | --- | | `list_secrets` | — | handles with env, tier, description, egress summary, active grant | | `request_access` | `{handles, reason, mode: "once"\|"lease", duration_minutes? (× 60), until?: "HH:MM" (next occurrence in the host time zone), max_uses?, wait_seconds? ≤ 120, request_id?}` — a lease needs `duration_minutes` or `until` | `{status, request_id, fingerprint, expires_at?}`; with `request_id` it polls | | `http_request` | `{handle, method, url, headers?, body?, reason, wait_seconds? (default 60, ≤ 120)}` | `{status, headers, body, redactions}`; `reason` is required and logged with the use; if no grant, the daemon creates a request for exactly this operation and waits `wait_seconds`; on timeout it returns `needs_approval` with the `request_id` to poll via `request_access{request_id}` and the request `fingerprint` (§7.5.6 step 4, ADR 0077) | | `request_secret_from_human` | `{name, reason, egress_hint?, wait_seconds? ≤ 120, request_id?}` | `{status: pending\|delivered\|denied\|expired, request_id, fingerprint, handle?}`; with `request_id` it polls and creates nothing (ADR 0077) | | `audit_recent` | `{n ≤ 50}` | recent public log summaries | `run_with_secrets` is not offered (ADR 0016, 0028). MCP Tasks / URL elicitation [Deferred]. --- ## 12. Approver PWA (`poc/approver`) - **Delivery** (ADR 0019): static build served from its **own origin**, never by the relay. Demo: `vite preview` on a second port (or a static host) with headers `Content-Security-Policy: default-src 'self'; script-src 'self' 'wasm-unsafe-eval'; connect-src ; frame-ancestors 'none'; base-uri 'none'; form-action 'none'; require-trusted-types-for 'script'; trusted-types custos-sw` (Trusted Types: Baseline 2026, ADR 0036; the one policy `custos-sw` returns exactly `/sw.js` for `serviceWorker.register`, a script-URL sink, ADR 0085), `X-Content-Type-Options: nosniff`, `Referrer-Policy: no-referrer`. The build emits the same headers itself, from the one `headers` object in `vite.config.ts` (SECURITY-C4-4, ADR 0075): a CSP `` first in `` (every directive but `frame-ancestors`, which a meta CSP ignores) and `dist/_headers` with the full set for a static host; it fails if the emitted CSP differs. The build writes `dist/manifest.sha256` (SHA-256 of every asset) and adds SRI `integrity` to every script/style tag. Both are served by the same origin as the HTML, so they do **not** protect against a malicious static host or tunnel (N9); they separate code delivery from the relay software only. Publishing the manifest hash out of band (git tag, docs site) and checking it from `custos doctor` is [Deferred] with release engineering. Phone demo needs an HTTPS origin (WebAuthn secure context): a TLS tunnel or static host for the PWA, a second hostname for the relay. - **Architecture** (ADR 0001 §4): TS renders, calls WebAuthn, stores bytes in IndexedDB and does `fetch`; **all** parsing, verification, sealing, signing and request-auth headers happen in `custos-wasm`, which returns plain JSON view models. - **Screens:** Onboarding (create account; join as approver by typing the code shown on an existing approver, then SAS (ADR 0049); iOS only as Home Screen app; persistence result) · Inbox (pending requests; storm banner and per-host storm card, §7.5.5; mute) · Request (three zones — verified, host-reported, "Agent says:" — §7.5.2; envelope-limited scope picker with narrowest default and Night-shift preset; typed number matching; hold-to-approve → WebAuthn) · Pending secrets (adopt: policy editor incl. egress rules, header template, tier, lease ceiling, bridge source) · Handles (edit/disable → new policy version) · New secret / Drop response · Active grants (countdown, uses left, session, revoke, "This wasn't me", **Revoke everything**) · "This wasn't me" + exposure report · **Check a host** (after every reducing action and from Audit: `S`, `fp(S)`, kind of entry `S−1`, the exact `sudo -k … custosd verify` command, and every affected host with its own checklist state plus a persistent "N hosts not yet checked" banner; §6.8) · **Refused-revocation alarm** (§6.1) · Devices (Add asks the role first; host add: type the host's code; approver add: this phone shows the code for the new approver to type; SAS with approver warning naming the AI agent, ADR 0049; 4-word device fingerprints, enrolled date, isolation badge incl. isolation 3; revoke; account and relay fingerprints; one-approver warning naming loss and theft) · Audit (timeline from the verified log incl. per-use agent facts and reason; **Report** — the morning report, ADR 0052; chain-verified badge, "Check a host", "This wasn't me" on use rows, red freeze alarm with evidence) · Settings (software-key notice, storage persistence state, iOS Stolen Device Protection checklist item). - **Keys:** device seeds and `AS_0` in IndexedDB, labelled "software key"; PRF-wrapped storage [Deferred]. Per-entry UV (ADR 0027) is the gate against a stolen unlocked phone whose thief does not know the passcode (A6a); UV may be the device passcode, so it is not a gate against A6c (ADR 0044, N26). It is **not** a gate against script on the origin (A6b), which can substitute the body the user approves (ADR 0036, N16). Storage can be evicted (N21): `navigator.storage.persist()` is requested and its result shown; on iOS the PWA runs only as a Home Screen web app (WebKit grants persistence by heuristics such as Home Screen installation). - **Live updates:** long-poll while open (ADR 0025). **Push** (ADR 0085): Settings → Notifications (from a tap: permission, `serviceWorker.register('/sw.js')`, `pushManager.subscribe({userVisibleOnly: true, applicationServerKey: vapid_pub})`, signed `POST …/push`); the worker (`/sw.js`, its own build entry) shows the fixed text "An approval request is waiting. Open Custos to review it." with a per-push tag for every push, never reads a payload, and a tap focuses or opens the app at its own scope. At start, in every sync round and on each "Mute all" change, the PWA sends the current endpoint, or none while all hosts are muted. If the browser lost the subscription, it removes the endpoint and turns push off locally (ADR 0085 §9, §11). Settings and the Inbox say which case applies, including "paused while all hosts are muted"; without push, approvals need the app open (N15). --- ## 13. PoC scope cut ### 13.1 Built tonight [PoC] | Area | Built | | --- | --- | | Crypto | Suite C1 complete (§3) incl. own HPKE, AEAD-C1, composite signatures, WebAuthn ES256 verify, words; external vector corpus (hpke-pq, composite draft, ACVP ML-DSA-65, Wycheproof) — C1 frozen only when it passes (ADR 0039) | | Protocol | Envelope; log kinds 0–10 with full `LogState`; checkpoints, gossip, misbehaviour freeze, freshness, anti-rollback; enrollment with commit-reveal SAS; per-host Tessera with `AS` epoch 0; policies/handles; request → grant → use with narrowing, UV, number matching; leases with relay-anchored deadlines; revocation (grant, epoch, device, policy); secret drop with expiring handles; "this wasn't me" + exposure report; `verify_self_signed`; log-derived fatigue control; session-bound grants; prefix check (`custosd verify`, per affected host) and refused-revocation alarm; lock (flag files, ADR 0051); single policy ciphertext (ADR 0053); role-first enrollment with approver-shown code (ADR 0049) | | Exposure | Proxy via `http.request` (`https` only, identity encoding, method overrides dropped, `Set-Cookie` stripped, once grants bound to their operation); redactor with nested encodings; provider registry (prefix, name and credential-minting entries) | | Bridges | Azure Key Vault (SP client secret, per-fetch Entra token; macOS and Linux); 1Password (service account via pinned, canonical, root-owned `op`; Linux hosts with isolation 1; macOS in dev mode only, N34, ADR 0056); Azure only with isolation 1; Azure also tested against the Lowkey Vault emulator (`make it-azure`, ADR 0056) | | Host tooling | Daemon (macOS, Linux) in service mode (own OS user, runtime socket directory) and dev mode; escalation check (isolation 3); persisted verified log; `custos-rpc`; CLI incl. `service install --print`, `enroll`, client-side `import` and `doctor`; `custosd unfreeze/unlock/lock/verify`; MCP (5 tools), `audit verify/report/exposure`, `inspect`; `until` via jiff | | Relay | §10 complete | | Approver | PWA (§12) over `custos-wasm` | ### 13.2 Deferred (reasons in ADR 0028) Inject and helper modes and `run_with_secrets` · loopback proxy listener and MITM proxy · tier 0 · AS rotation (`as.epoch`) · recovery (`root.recover`) · add-host share grant · k-of-n quorum, approver cooling-off, veto window · Web Push and WebSocket · QR camera pairing, CPace · running and testing the daemon on Windows and an owner-only pipe DACL (it compiles, ADR 0056) · Windows agent clients: named-pipe transports for `custos-mcp` and `custos-cli` (neither compiles for Windows, ADR 0068; **flagged to the owner**) · mlock, `PT_DENY_ATTACH`, WER exclusion · agent sandbox, IMDS blocking · hardware binding of host seeds (TPM 2.0, Secure Enclave, OS keystore; N20) · witnesses and Merkle consistency proofs (N17) · out-of-band PWA manifest check (N9) · moving `atransit`/`grant-share` out of the log (N18) · host-side confirmation of new egress hosts and k-of-n co-signing of `device.add`/egress changes (N16) · session-end binding of expiring handles · Public Suffix List rendering · NFC normalisation in the sanitizer · signed `host.mute` entry · Entra device-code, SP certificates, workload identity, 1Password Connect · `bridge scan` · derived credentials beyond Entra · provider rotation adapters, signed checklist, age policy · anomaly scoring · Merkle tree, witnesses, Roughtime · metadata padding, sealed device labels · hybrid TLS to the relay · release engineering (reproducible dual-signed builds, TUF, SLSA) · attestation/AAGUID policies · PRF-wrapped approver keys · Tamarin model · debug bundle · suite-floor raise · MCP Tasks/URL elicitation · 1Password bridge in macOS service mode (needs a real two-uid check there, ADR 0055; dev mode is built, ADR 0056) · batched `use` entries and signed snapshots so new devices need not replay the whole log (N31; **flagged to the owner**) · recording the upstream TLS group and a "require hybrid" egress flag (N30) · host-typed confirmation for tier 2 (`custosd confirm`, ADR 0050 alternatives). ### 13.3 Work split (5 agents) and order | Agent | Owns | Depends on | | --- | --- | --- | | 1 Core primitives | `core::{b64, cbor, hash, kdf, aead, kem, hpke, sig, envelope (incl. `verify_self_signed`), words, webauthn, redact, text (sanitizer)}`, KATs, external vector corpus (ADR 0039), oracles | — | | 2 Core protocol | `core::{ids, device, log, checkpoint, enroll (incl. mailbox frames, code normalisation), tessera, policy (egress matcher, expiry, shared-credential rule), providers, fatigue, request, grant (build, verify, narrow, envelope), time, audit (report, exposure, log fingerprint), approver (builders used by wasm and tests)}`, hostile-relay harness | Agent 1 signatures (stubs first) | | 3 Relay + CLI | `custos-relay`; `custos-cli` (incl. `.env` import, client-side doctor merge, escalation check at enroll) | core `LogState`, envelope; `custos-rpc`; `custos-os` host checks | | 4 Daemon + MCP + OS | `custos-rpc` (owner), `custos-daemon` (sync, persisted log, grants, proxy, bridges, daemon-side doctor, audit, service/dev mode, runtime dir, `until` via jiff, lock/unfreeze/unlock/verify), `custos-os` (incl. client-side host checks), `custos-mcp` | core; relay (in-process for tests) | | 5 Wasm + PWA | `custos-wasm`, `poc/approver` | core approver builders | Order: **T+0:30** workspace, pins, lint/deny/age scripts, empty crates compiling; core public API signatures **and the `custos-rpc` types** committed (types + `todo!()`-free stubs returning errors) — **interface freeze**; external vector files retrieved and recorded (ADR 0039). **T+2:00** core complete at 100 %; relay and daemon green against in-process relay; PWA screens on fake core. **If T+2:00 is missed**, items are dropped in the ranked order of ADR 0028 (round-3 amendment) — until-mode, then the 1Password bridge, provider name/minting entries, the Night-shift preset, write-body rendering niceties, per-host checklist state — each with its claims, tests and A.0 row, so the gate stays 100 % over what is built. **T+3:00** end-to-end tests (§14.5) green; PWA on real wasm. **T+3:30** demos recorded; coverage gate on macOS **and** on Linux via `poc/scripts/coverage-linux.sh` (two passes in a pinned `rust:1.94.0` container, §14.1, ADR 0055; Docker is on the build machine); claims registry updated. Platform-specific code is kept to one small file per OS function behind a trait, with the logic tested platform-independently. ### 13.4 Demos (each also an automated e2e test, §14.5) | # | Use case | Live demo | E2E test | | --- | --- | --- | --- | | 1 | **Blind use** | Claude Code + `custos-mcp`: "list my repos" → `http_request(github-token, GET https://api.github.com/user/repos)`; transcript shows handle and data, never the token; a prompt-injected "send the token to evil.example" → `egress_denied` | `e2e_uc1_blind_use` against a fake `Upstream` that echoes headers (also with `Accept-Encoding: gzip` and `Range` from the agent): response scrubbed, agent output canary-free, foreign host refused, `http://` and loopback refused | | 2 | **Remote approval** | Agent on a remote box over SSH (daemon in service mode; the box proposed its own secret) asks for a prod handle; phone shows verified facts, host-reported facts greyed, fingerprint, sanitized reason; Face ID; once; expires | `e2e_uc2_remote_approval`: request → simulated approver with p256 WebAuthn → use once → second use refused | | 3 | **Night shift** | Approver app open on the phone. Agent at 22:00 asks `until: "07:00"` with 10 000 uses; phone offers the Night-shift preset within the default 12 h staging ceiling with **200** uses ("the agent asked for 10 000") and lists the `confirm` rule that will wait overnight; agent works unattended; a `POST` hits the `confirm` rule → per-use request, unanswered; the lease keeps working; after the deadline → refused; the PWA morning report (Audit → Report) shows every use with agent and reason; kill switch + prefix check (`custosd verify`) on every host | `e2e_uc3_night_shift_9h_default_policy` with a fake clock spanning 9 h and **default** adoption settings: lease uses ≤ 200, confirm rule, quarantine never stops the active lease, deadline, `revoke.epoch` → immediate stop; core `tests/protocol.rs::audit_views` (`report_from_approver_view` with per-use facts) and daemon `src/tests_more.rs::status_handles_audit_views` (host report public fields only, ADR 0052); `until` DST goldens daemon `src/tz.rs::until_dst_fold_earlier`, `until_dst_gap_shifted` (ADR 0045) | | 4 | **Bridge, don't migrate** | `custos doctor` run from the agent's shell flags `az`/`OP_*` standing access; `bridge add` both; approver adopts; agent uses an Azure KV secret (laptop) and a 1Password item (Linux remote box, ADR 0055) through the proxy (1Password also on the macOS laptop in dev mode, ADR 0056 §1, N34); `.env` import (by the CLI) rewrites to handles | e2e `uc4_bridges::e2e_uc4_bridge_azure`, `e2e_uc4_bridge_1password` (stub `op` as a real child: macOS dev mode allowed and service mode refused, Linux the reverse; isolation 3 refused), `e2e_uc4_import_env_and_doctor` (owner and mode kept); daemon `e2e_uc4_bridge_1password_linux`, `e2e_uc4_bridge_1password_macos_dev_mode`, `onepassword_refused_on_macos_service_mode_and_isolation_3`; cli `src/tests/doctor.rs::doctor_flags_client_env_not_daemon_env` | | 5 | **Proof, not trust** | `custos audit verify`; relay DB dump contains no names/reasons/values; hostile relay tampers → freeze with evidence | `e2e_uc5_proof` + hostile-relay suite (§14.4 P8) | | + | **Secret drop** | Agent asks for an OpenAI key and suggests a host; the phone hides the suggestion until the human picks `api.openai.com` from the registry (name entry `openai`), then shows it beside the choice as untrusted; the human pastes the key, keeps the 24 h expiry; agent gets `custos:openai-key` and uses it | `e2e_drop` (incl. drop narrowing rule, expiry, a tier-2/prod drop with a 600 s window, an invalid agent-supplied name normalised and validated); PWA `screens.test.ts` "drop-hint-after-host: …" and "a non-registry hint typed verbatim needs hold-to-confirm" (hint hidden until a host is chosen; `openai` name with a different typed host blocks; a non-registry hint typed verbatim needs hold-to-confirm) | | + | **Panic** | "This wasn't me" from a use row → host revoked, epoch bumped, pending requests denied, exposure report; prefix check and `custosd lock` on the host | e2e `uc5_proof.rs::e2e_panic_this_wasnt_me`; unlock only by the daemon user: daemon `src/local.rs` uid check, tested by `src/tests_more.rs::local_lock_unlock_unfreeze_and_refusals` (no e2e-layer test, **not built**); refused revocation alarm: PWA `e2e/flows.spec.ts` "a forking relay freezes the approver; a refused revocation raises the alarm" (mock relay; not against the real relay, **not built**) | --- ## 14. Test plan ### 14.1 Coverage policy and testability - **Gate:** `cargo llvm-cov --workspace --all-targets --fail-under-lines 100` on macOS and Linux; each platform must cover 100 % of the code it compiles (`cfg(target_os)` branches are covered on their platform). No test skips at runtime. - **Linux procedure** (`poc/scripts/coverage-linux.sh`, ADR 0055): in the pinned `rust:1.94.0` container create users `custos-test` (client) and `custos-svc` (daemon) and a 1777 directory for `LLVM_PROFILE_FILE`; pass 1 as `custos-test` (non-root, so dev-mode e2e daemons accept their client): `cargo llvm-cov --workspace --all-targets --no-report` excluding the `real_perms` test target; pass 2 as root: `cargo llvm-cov --no-report --test real_perms` (`custos-daemon/tests/real_perms.rs`, `cfg(target_os = "linux")`, spawns `custosd` as `custos-svc` and clients as `custos-test` via `setpriv`); then `cargo llvm-cov report --fail-under-lines 100` merges both. Every two-uid test lives in `real_perms.rs`; macOS has none (the macOS-only branch is the 1Password refusal). PWA: `vitest run --coverage` with thresholds 100 for lines, functions, branches and statements over `src/**/*.ts`, excluding only the generated `src/wasm/` bindings (their Rust side is covered natively). - **Gate record** (machine-checkable): every gate step is run through `poc/scripts/gate-json.mjs run -- `, which records the start and end time of the run (never file modification times), the exit code, the parsed counts and the poc tree hash before and after, into `landing/poc-output/gate.json`; `gate-json.mjs finish` adds the LLM proof runs and the review rounds by severity and sets `consistent` only when every step passed on one tree. The tree hash is `landing/src/lib/poc-tree.ts` `pocTree()` (sha256 over path and bytes of every `.rs` and `Cargo.toml` under `crates/`, `tests-e2e/`, `fuzz/fuzz_targets/`, plus `Cargo.toml` and `Cargo.lock`); the landing page, the docs (`docs/scripts/sync-poc-status.mjs`) and the showcase manifest compare that same hash with the tree at build time. The approver's TypeScript, scripts and docs are outside the hash. Parsers: `scripts/gate-parse.mjs`, 100 % lines (`make gate-json-selftest`, part of `make check`). - **No escape hatches:** no `#[coverage(off)]` (nightly-only anyway), no `--ignore-filename-regex`. Binaries' `main.rs` ≤ 10 lines delegating to a library `run(args, io) -> ExitCode`; covered by spawn tests using `env!("CARGO_BIN_EXE_…")` (cargo-llvm-cov instruments binaries started by tests; if a platform run shows otherwise, the fallback is an explicit, listed ignore of those ≤ 10-line files — reported to the owner, not silent). - **I/O behind traits** (production impl + test impl in the same crate): | Trait | Production | Test double | | --- | --- | --- | | `Rng` (core) | `getrandom::fill` | deterministic counter RNG; failing RNG (RNG-failure path, ADR 0008) | | `Clock` (daemon/relay) | `SystemTime` + `Instant` | settable wall and monotonic clocks | | `RelayApi` (daemon) | reqwest client | in-process `custos-relay` router (`tower::ServiceExt::oneshot`); `HostileRelay` wrapper | | `Upstream` (daemon) | reqwest with ADR 0029 config | scripted fake (status, headers, body; asserts exact URL/headers/form) | | `Runner` (daemon, `op`) | `std::process::Command` | stub returning stdout/exit/timeout; asserts env is exactly the allowed set | | `HostEnv` (cli/os: client-side doctor, `.env` import) | real env + fs | in-memory env and file map | | `PeerInfo` (os) | `/proc//stat` / `ps -o ppid=,comm=` | fixed lineage | | `HostIdentity` (os) | own uid/gid, `stat` of state dir, runtime dir and canonical `op` path | fixed service/dev mode, owners, modes, ancestor chains and ACL flags (so service-mode branches are covered without root); **plus** the root-only `real_perms.rs` pass in the Linux coverage container with two test users (ADR 0042, 0055) | | `Runner` (os: escalation and ACL checks) | `/usr/bin/sudo -k -n true`, `/usr/bin/id -Gn `, `/bin/ls -ld ` | stub exit codes, group lists and `ls` lines; asserts exact argv (ADR 0048) | | `TimeZone` (daemon) | jiff `TimeZone::system()` | fixed zone from a TZif file checked into the test data (`Europe/Zurich`, loaded with jiff `TimeZone::tzif`) so DST goldens do not depend on the machine's tzdata | | `Prompt` (cli) | `rustix::termios` no-echo | scripted input | - Core takes randomness and time as parameters; every function is deterministic under test. ### 14.2 Known-answer and differential tests (crypto) | Primitive | Vectors | Oracle | | --- | --- | --- | | SHA-256 | FIPS 180-4 "abc", empty | — | | HKDF-SHA-256 | RFC 5869 A.1–A.3; **Wycheproof** HKDF-SHA-256 (`kdf::tests::wycheproof_hkdf_sha256`, ADR 0065) | — | | ChaCha20-Poly1305 | RFC 8439 §2.8.2; **Wycheproof** ChaCha20-Poly1305 | RustCrypto chacha20poly1305 | | HPKE key schedule + seal seq 0 | RFC 9180 A.2.1 (shared secret taken from the vector, so the schedule is tested independently of the KEM) | hpke-rs 0.8.0 (libcrux, `0x647a`; implements draft-04) round-trips both directions | | HPKE `0x647a`/`0x0001`/`0x0003` | **hpke-pq test vectors** referenced by draft-ietf-hpke-pq-05 (`[TestVectors]`); if no entry exists for these exact ids, C1's HPKE is marked provisional (ADR 0039) | — | | X-Wing | draft-11 (= -06) vectors 1–3 | RustCrypto x-wing (byte-identical pk/ss) | | ML-KEM-768 (inside X-Wing; libcrux-ml-kem, the single version in `Cargo.lock`) | **ACVP** ML-KEM-768 keyGen + encapDecap (incl. modified-ciphertext decapsulation and both key checks); **Wycheproof** `mlkem_768_test.json` (implicit rejection, strcmp, malleable ciphertext) (`kem::tests::mlkem`, ADR 0065) | — | | Ed25519 | RFC 8032 §7.1 TEST 1–3; strict rejection of a small-order key; **Wycheproof** Ed25519 | libcrux-ed25519 | | ML-DSA-65 | **ACVP** ML-DSA-65 keyGen + sigVer subset; **Wycheproof `mldsa_65_verify_test.json`** (malformed / non-canonical hints, out-of-range `z`); sign with fixed seed + rnd → golden; cross-verify; `sig::verify_rejects_noncanonical_hint` (strong unforgeability, ADR 0004 amendment) | RustCrypto ml-dsa | | Composite | **draft-ietf-lamps-pq-composite-sigs** repository vectors for ML-DSA-65 + Ed25519 (SHA-512); golden; `M'` recomputed byte-by-byte from ADR 0004 in the test | — | | AEAD-C1 | frozen goldens; commitment tamper rejected before AEAD | RustCrypto chacha20poly1305 with derived key/nonce | | det-CBOR | RFC 8949 App. A subset (ints, bstr, tstr, arrays, maps) + non-canonical rejections | — | | WebAuthn | synthetic assertions (p256 signer) incl. every failure branch; one assertion captured from a Chromium virtual authenticator if time allows | — | | Envelopes | golden per entry kind, offer, binding, confirm, checkpoint, mailbox frame; `verify_self_signed` goldens (genesis, offer) | — | | Hash-prefix formulas | goldens for SAS, 6-digit number, mailbox id (incl. code normalisation), log fingerprint `fp(S)`, device fingerprint `devfp`, request auth with empty body (`H(b"")`), and one HTTP-level signed `POST` (method, URL, headers, body) (ADR 0033, 0049, 0050) | — | | Policy | goldens for fields 6, 7, 11 (single ciphertext under `PK`); `policy_at_every_limit_fits_64k` (ADR 0053) | — | Property tests (proptest): every decoder on random bytes never panics and accepts only canonical encodings (`decode(encode(x)) = x`, `encode(decode(b)) = b`); Tessera split/combine; narrowing invariant over random request/policy/grant triples; egress matcher over `Target` against a reference model (overlapping rules: any-match allows, confirm wins for unsafe methods); redactor (a value embedded in any supported encoding — raw, base64 std/url stream, lower/upper hex, URI, form, every JSON escaping variant, base64∘base64, hex∘base64 — with arbitrary neighbours inside the encoded stream and arbitrary surrounding text is always replaced and none of its needles survives, ADR 0065); `LogState` over random valid operation sequences followed by a random single-byte mutation, deletion, swap or insertion → rejection; `testkit::fuzz_log_rules` (authenticated, structure-aware entries against a fixed account: rejection leaves the state unchanged, acceptance extends it by one, replays refused, uses ≤ `max_uses`; ADR 0065). Fuzz entry points (`pub fn fuzz_(&[u8])`, driven by proptest with arbitrary bytes, §2.4) for: envelope (both verify paths), every body, mailbox frames, checkpoint, AEAD-C1 split, HPKE sealed split, WebAuthn parser, JSON-RPC line, relay JSON bodies, URL/egress parser, `.env` parser, text sanitizer; `libfuzzer-sys` wrappers built and each run ≥ 60 s (ADR 0056 §4). Sanitizer proptest covers the full Default_Ignorable set and the Tags block. Goldens become normative for C1 only after the external vectors pass (ADR 0039). ### 14.3 Canary rule (REQ-06, P1, P13) The e2e suite uses canary values `CUSTOS-CANARY-` and records every `K`, `h_i`, `a_i`, `AS`, DEK and seed it creates. After each e2e test, a scanner searches the relay SQLite file, relay and daemon log output, every RPC/MCP response returned to the agent, panic output and the `.env` after import for those values in 12 encodings (raw, base64 ×3 alignments ×2 alphabets, hex ×2, URL, JSON-escaped, base64∘base64) — **0 hits** required (handle markers `«custos:…»` excepted). ### 14.4 Security properties → tests Naming rule (ADR 0068): every name below is a test that exists, written ` ::` (`grep -n "fn "` finds it); `[name]` is a comment marker inside the named combined test; a property without a test says **not built** and names the nearest test. PWA entries are test titles (quoted) in the named file under `poc/approver/tests/`. | Property | Tests (` />::`; e2e paths under `poc/tests-e2e/tests/`; PWA = test titles in `poc/approver/tests/`) | | --- | --- | | P1 Relay blindness | relay `tests/plaintext.rs::db_holds_no_plaintext`; the §14.3 canary rule over the relay DB, log and `inspect` after every e2e flow (values, names, reasons, URLs, egress hosts, tiers), plus `custos-relay inspect --search` for every needle (finds plaintext inside a stored `bstr`; positive control `canary_rule_finds_plaintext_inside_a_stored_bstr`, ADR 0085); push pings carry nothing (`relay tests/push.rs`); core `tests/spec_properties.rs::stolen_relay` (the relay's bytes + relay key cannot open any sealed field: the test tries every key-shaped value the relay has on every sealed field kind, after a positive control by the real holder; ADR 0064) | | P2 Two-of-two | core `src/tessera.rs::host_share_alone_fails_and_approver_share_alone_fails`, `src/tessera.rs::share_for_host_i_useless_for_host_j`; core `tests/spec_properties.rs::revoked_approver_with_relay_cannot_decrypt`, `tests/spec_properties.rs::revoked_approver_plus_host_seed_reads_post_revocation_secret` (**asserts the documented N11 limit**; core level: the bytes are those the relay stores, ADR 0064) | | P3 Approval authenticity | core `tests/log_state.rs::grant_rules` [markers `grant_by_host_rejected`, `grant_without_uv_rejected`], `tests/log_state.rs::grant_by_revoked_approver_rejected_and_invalidated`, `tests/log_state.rs::device_add_rules` [`device_add_without_uv_rejected`, `approver_platform_must_be_mobile_or_web`], `tests/log_state.rs::policy_rules` [`policy_without_uv_rejected`]; core `src/webauthn.rs::uv_wrong_challenge_origin_rpid_flags_sig_rejected`; a grant offered outside the log: **not built** (by construction: grants reach the daemon only as verified log entries and no RPC method takes one, rpc `src/tests.rs::every_method_parses_with_session_and_client`); daemon `tests/real_perms.rs::service_mode_state_unreadable_by_client_uid` (Linux, not yet run, ADR 0063); daemon `src/tests_more.rs::dev_mode_enrolls_isolation_2_and_persists`, `src/tests_more.rs::enroll_with_escalatable_client_not_service_mode` (isolation 3); daemon `src/start.rs::service_mode_refuses_uid0_client_and_own_uid` and `tests/real_perms.rs::service_mode_refuses_uid0_client`; PWA `csp-header.test.ts` "CSP carries Trusted Types, …"; PWA `screens.test.ts` "welcome → create → account ready → inbox; onboarding-sdp on iOS"; **round 3:** daemon `src/start.rs::start_refuses_user_writable_exe_or_parent` and `tests/real_perms.rs::start_refuses_user_writable_exe_or_parent`; the ancestor check that every `custosd` subcommand (`verify` included) runs first: daemon `src/tests_more.rs::local_lock_unlock_unfreeze_and_refusals` (shown with `lock`; no `verify`-specific test), `src/tests_gaps.rs::service_mode_subcommand_passes_ancestor_check`; daemon `tests/real_perms.rs::acl_plus_refused`, `src/sys.rs::real_ls_sees_acl_hidden_behind_xattr_marker`; cli `src/tests/units.rs::every_host_command_has_sudo_k`; cli `src/tests/cmds.rs::escalation_argv_exact` (`[/usr/bin/sudo, -k, -n, true]`); daemon `src/tests_flow.rs::bridge_refused_after_client_joins_docker_and_fetch_failure`, `src/tests_flow.rs::bridged_grant_refused_at_acceptance_when_client_can_become_root`; PWA `screens.test.ts` "add-device-role-first, …", "approver-add-code-on-approver: …" | | P4 Request binding | core `tests/log_state.rs::grant_for_unknown_or_decided_request_rejected_and_denials`; core `tests/log_state.rs::grant_rules` (a grant naming another `request_hash` is rejected; the hash is the request entry's leaf, which covers the sealed details; no separate `request_hash_binds_details` test); daemon `src/tests_flow.rs::once_grant_bound_to_op_and_session` (method, URL and body hash; [`grant_unusable_from_other_session`]); narrowing against the daemon's own request copy: daemon `src/tests_flow.rs::widened_grant_ignored_and_alarmed_not_frozen`, `src/tests_flow.rs::restart_drops_grants_and_request_copies` [`restart_loses_request_copy_grant_ignored`]; PWA `request-view.test.ts` "three zones; hidden characters stripped from every host-reported field; zone 1 has no host-reported string" (bidi/zero-width/Tags through every host-reported field, a deceptive session label); client-reported peer facts labelled on the daemon side: **not built** (the phone side is the PWA test above); **round 3:** core `tests/spec_properties.rs::relay_substitutes_colliding_number_matched_request` (the relay's ≤ 10 real candidates from `MAX_PENDING` and forced re-signs; bound ≤ 10⁻⁵, ADR 0050; collision only by brute force); PWA `request-view.test.ts` "number-six-digits, number-typed, number-no-trust-styling: …", "write-body-shown: …", "long-path-not-truncated: …" (op path rendered outside zone 1, decisive segment past 64 characters visible) | | P5 Narrowing | core `src/grant.rs::narrow_lease_rules`, `src/grant.rs::narrow_tier2_once_and_window`, `src/grant.rs::narrow_drop_rule`, `src/grant.rs::narrow_until_binds_instant_not_duration`, `src/grant.rs::calls_bound_to_op_and_session`; `src/grant.rs::envelope_values`, `src/grant.rs::envelope_never_exceeds_narrow` (proptest: the PWA envelope never produces a grant `narrow` rejects); expired handle: daemon `src/tests_flow.rs::policy_change_and_expiry_invalidate`; daemon `src/tests_flow.rs::widened_grant_ignored_and_alarmed_not_frozen`, `src/tests_flow.rs::multi_handle_lease_grant_all_shares_opened`; PWA `request-view.test.ts` "tier 1 default once 15 min; tier 2 default once 10 min with lease options disabled", "lease with 10 000 uses pre-fills 200; night shift lists confirm rules; …"; phone clock skew: core `src/time.rs::deadline_monotonic_vs_wall_forward_and_back` (`not_before` with the 300 s early tolerance; a daemon-level ±5 min test is **not built**); daemon `src/tz.rs::until_dst_fold_earlier`, `src/tz.rs::until_dst_gap_shifted`, `src/tests_flow.rs::until_mode_and_unknown_zone` [`until_unknown_zone_refused`], `src/tests_flow.rs::access_request_validation` [`lease_without_duration_refused`]; core `tests/protocol.rs::request_details_goldens_per_mode`; PWA `screens.test.ts` "env-choice: production pre-selects prod; …"; e2e `drop.rs::e2e_drop` (tier-2 window); core `tests/log_state.rs::single_use_grant_multi_handle_rejected_and_lease_multi_handle_ok` (ADR 0054) | | P6 Proxy confidentiality | core `src/policy.rs::egress_component_checks` (scheme, host, port, method, prefix boundary, `_method` query), `src/policy.rs::egress_overlapping_rules_any_allows_confirm_wins` (any match allows, confirm wins, a GET under a confirm rule is not confirmed), `src/policy.rs::matcher_matches_reference_model` (proptest), `src/policy.rs::http_scheme_and_loopback_rejected`, `src/policy.rs::encoded_method_override_query_refused`, `src/policy.rs::method_override_body_refused` (ADR 0065); daemon `src/proxy.rs::egress_parse_accepts_plain_https`, `src/proxy.rs::egress_parse_rejections` (encoded separators, userinfo, IDNA, the sent `Url` is the parsed one); PWA `screens.test.ts` "rule-overlap-warning: …"; core `src/providers.rs::prefix_mismatch`, `src/providers.rs::name_entries_sourced`, `src/providers.rs::minting_paths_flagged_and_wildcards_sourced`; daemon `src/proxy.rs::agent_header_rules` (agent `Authorization`, `Proxy-Authorization`, `Cookie`, the credential header and `X-HTTP-Method-Override` dropped, `Range` dropped, `Accept-Encoding` forced to `identity`), `src/proxy.rs::template_and_header_values` (the value goes only into the configured header template), `src/proxy.rs::scrub_strips_cookies_redacts_and_withholds_encoded` (`Set-Cookie` stripped, `Location` scrubbed, encoded body withheld); daemon `src/upstream.rs::reqwest_upstream_sends_caps_and_never_follows_redirects`, `src/upstream.rs::system_proxy_env_ignored`; daemon `src/tests_flow.rs::invalid_header_value_rejected_without_echo`; daemon `src/tests_gaps.rs::method_override_in_body_or_encoded_query_refused` (ADR 0065); e2e `uc1_blind_use.rs::e2e_uc1_blind_use` (no substitution in URL or body: `{{custos:…}}` is sent literally; foreign host, `http://`, IP literal, localhost refused); 1Password: daemon `tests/real_perms.rs::op_env_not_readable_by_client_uid` (Linux, not yet run), `src/tests_flow.rs::onepassword_refused_on_macos_service_mode_and_isolation_3`, `src/tests_flow.rs::e2e_uc4_bridge_1password_macos_dev_mode` (ADR 0056 §1), `src/tests_flow.rs::e2e_uc4_bridge_1password_linux` (Linux dev mode refused), `src/tests_flow.rs::op_path_writable_by_user_refused_and_fetch_failure`, `src/perm.rs::op_path_rules` (a symlink gives "op path is not canonical"; user-owned directories refused), `src/bridge.rs::op_env_is_exactly_allowed_set`; daemon `src/tests_flow.rs::azure_refused_unless_isolation_1`; the RPC never opens a client-supplied path: **not built** | | P7 Policy integrity | core `tests/log_state.rs::policy_rules` [`policy_by_host_rejected`, `host_and_approver_open_same_ciphertext`, `wrong_policy_key_rejected_by_commitment`]; daemon `src/tests_flow.rs::host_proposal_unusable_until_adopted`; core `tests/log_state.rs::policy_change_invalidates_old_grants`; daemon `src/tests_flow.rs::bridge_reference_only_from_policy_and_shared_credential_conflict`; core `src/policy.rs::shared_credential_tier_mismatch_rejected`; expired handle: daemon `src/tests_flow.rs::policy_change_and_expiry_invalidate`; daemon `src/tests_more.rs::status_handles_audit_views` (new approvers and egress hosts in `status`); core `tests/protocol.rs::policy_at_every_limit_fits_64k_and_over_limit_refused` (ADR 0053); core `src/policy.rs::env_and_names` (env normalisation; a test that the compare uses the normalised env is **not built**) | | P8 Log integrity / forks | hostile relay, core: `tests/log_state.rs::hostile_relay_modify_drop_reorder_insert` [`modify_entry`, `drop_entry`, `reorder`, `insert_forged`], `tests/log_state.rs::mutated_logs_are_rejected` (proptest); hostile relay, daemon (reject or freeze with stored evidence): `src/tests_more.rs::invalid_entry_covered_by_checkpoint_freezes_with_evidence`, `src/tests_more.rs::equal_size_different_head_proof_freezes`, `src/tests_more.rs::checkpoint_time_regression_freezes`, `src/tests_more.rs::embedded_checkpoint_equivocation_freezes` (careless relay, cross-branch **request**; the cross-branch **grant** case is **not built**; non-adversarial merge only, ADR 0041), `src/tests_more.rs::missing_view_cbor_starts_frozen_and_rollback_and_corrupt_log` [`rollback_after_restart`], `src/tests_more.rs::garbage_sync_page_is_stale_not_frozen_and_not_persisted` (rejected page not persisted); N17 (**the documented behaviour until the check runs**): e2e `uc5_proof.rs::e2e_uc5_fork_found_by_prefix_check` (the fork hides the revocation; the devbox keeps its lease until `custosd verify --at S` prints FORK and locks); a relay serving a stale size with a fresh time: **not built** (nearest daemon `src/tests_review1.rs::withheld_entries_with_ahead_checkpoint_go_stale`); a non-forking relay replaying old checkpoints or an old append receipt: daemon `src/tests_review3.rs::{replayed_append_receipt_is_refused, replayed_sync_checkpoint_does_not_keep_host_fresh, stockpiled_checkpoints_age_out}`; fingerprints converge after an honest sync: e2e `uc5_proof.rs::e2e_uc5_proof`; no unfreeze RPC: by construction (`custos-rpc` has no such method; rpc `src/tests.rs::line_level_errors` maps an unknown method to MethodNotFound; a dedicated test is **not built**); daemon `src/tests_more.rs::local_lock_unlock_unfreeze_and_refusals` [`unfreeze_subcommand_requires_daemon_user`]; **prefix check (ADR 0041):** core `tests/prefix_check.rs::fork_with_host_growth_detected_by_prefix_compare` (host log larger than S: honest prefix MATCH, forked prefix FORK; ADR 0068), core `tests/protocol.rs::prefix_check_match_not_yet_fork`, daemon `src/tests_more.rs::local_verify_match_not_yet_fork_corrupt` (MATCH names the entry, NOT YET when the host is behind, a typo prints FORK and locks, a corrupt `log.cbor` prints CORRUPT and locks; `verify` reads the persisted `log.cbor`, not the relay); the documented check never uses a client path: **not built** (nearest cli `src/tests/units.rs::every_host_command_has_sudo_k`); **state files (ADR 0051):** daemon `src/tests_more.rs::lock_survives_concurrent_sync`, `src/tests_review1.rs::lock_subcommand_during_approval_wait_stops_the_use` (the lock subcommand runs while the daemon runs), `src/store.rs::stat_error_on_flag_means_locked`, `src/store.rs::lock_is_exclusive_and_times_out`, `src/tests_gaps.rs::freeze_without_flag_file_fails_closed_and_unfreeze_takes_effect_live`; relay `tests/log.rs::checkpoint_time_monotone_under_concurrency` (concurrent appends and long-polls, backward clock step), `tests/log.rs::replay_cache_keyed_on_message_hash` | | P9 Bounded revocation | daemon `src/tests_flow.rs::revocation_epoch_and_device_revoke_stop_use` (revocation zeroizes the cache immediately, epoch bump stops all, device revoke stops the host), `src/tests_flow.rs::lock_stops_use_and_requests`; core `tests/log_state.rs::device_revoke_stops_host`; withheld revocation: daemon `src/tests_review1.rs::withheld_entries_with_ahead_checkpoint_go_stale` (lease refused as stale after 60 s, fake clock), `src/tests_flow.rs::stale_and_deadlines_monotonic_vs_wall`; forking cases under P8; refused revocation append (409 at the current size and 5xx ⇒ red alarm with the lock command): PWA `infra.test.ts` "sync offline, append 422, refused reduction after 5xx and after a 409 that does not move, background retry" and `e2e/flows.spec.ts` "a forking relay freezes the approver; a refused revocation raises the alarm" (mock relay; not against the real relay); a reducing policy change prompts the prefix check: PWA `e2e/flows.spec.ts` "wrong number denies; …; edit is reducing; check a host"; e2e `uc5_proof.rs::e2e_uc5_fork_found_by_prefix_check` (the hostile relay forks only the devbox, then `revoke.epoch`; the laptop stops and its check prints MATCH, the devbox keeps its lease until `custosd verify` prints FORK and locks); PWA `screens.test.ts` "grants list, revoke, check-a-host-lists-affected-hosts with per-host states, …" | | P10 Enrollment | core `tests/enroll.rs::sas_binds_offer_and_binding_and_mitm_swapped_offer_changes_sas`, `tests/enroll.rs::commit_mismatch_rejected_and_junk_frames_skipped`, `tests/enroll.rs::wrong_ak_fingerprint_and_other_account_rejected_after_add`, `tests/enroll.rs::run_aborts_after_8_slots`, `tests/enroll.rs::code_normalisation_golden`, `tests/enroll.rs::forged_added_frame_skipped_and_added_hash_bound_in_device_add`, `tests/enroll.rs::as_mismatch_after_valid_added_aborts`, `tests/enroll.rs::binding_from_unknown_signer_and_mismatched_hashes_rejected`; core `src/envelope.rs::verify_self_signed_paths` (kid ≠ H(sig_pub), non-canonical payload, wrong type); core `tests/log_state.rs::device_add_rules` [`duplicate_active_label_rejected`, `confusable_label_rejected`]; daemon `src/tests_more.rs::junk_frames_skipped_then_mailbox_exhausted_and_ttl`; golden `devfp`: core `src/words.rs::formula_goldens` | | P11 Time | core `src/time.rs::deadline_monotonic_vs_wall_forward_and_back` (incl. `not_before`), daemon `src/tests_flow.rs::stale_and_deadlines_monotonic_vs_wall`, `src/tests_flow.rs::restart_drops_grants_and_request_copies` | | P12 PQ | KATs and external vectors (§14.2, ADR 0039); core `tests/protocol.rs::no_classical_only_path` (every seal is X-Wing HPKE; every Custos signature composite; `verify` fails if either half is broken; the WebAuthn factor is the documented classical exception, N27) | | P13 Local hygiene | daemon `src/start.rs::harden_sets_core_limit_zero` (+ Linux dumpable); daemon `src/tests_more.rs::unix_socket_dev_mode_0600_and_service_bind` (dev mode: runtime directory 0700, socket 0600; service mode: socket 0660); daemon `src/tests_more.rs::server_refuses_other_uid_and_uid0`, `tests/real_perms.rs::rejects_peer_with_other_uid`; canary rule (also over RPC outputs for issued session tokens); `status` never returns the session token: daemon `src/tests_flow.rs::once_grant_bound_to_op_and_session`, e2e `uc3_night_shift.rs::e2e_uc3_night_shift_9h_default_policy` (a 4-byte reference, never the token); core `src/secret.rs::secrets_have_no_debug` (compile-time + `Debug` output check); **service mode, real permissions** (Linux container as root, two users; written, not yet run, ADR 0063): daemon `tests/real_perms.rs::socket_reachable_by_client_uid_state_dir_not`, `tests/real_perms.rs::start_refuses_wrong_runtime_dir_mode`, `tests/real_perms.rs::start_refuses_group_accessible_state_entry`, `tests/real_perms.rs::start_refuses_user_writable_parent`, `tests/real_perms.rs::daemon_process_not_readable_by_client_uid`; cli `src/host/tests.rs::import_env_keeps_owner_and_mode` | | P14 Audit | daemon `src/tests_flow.rs::use_appended_before_upstream_call_with_agent_and_reason` (fake upstream asserts the log already holds the use; the use details carry agent and reason); core `tests/log_state.rs::revocation_epoch_and_use_rules` [`use_beyond_max_uses_rejected`]; daemon `src/tests_flow.rs::upstream_not_called_when_append_fails`; daemon `src/tests_flow.rs::http_request_without_reason_rejected_and_wait_creates_request` [`http_request_no_wait_creates_request`]; daemon `src/tests_review1.rs::bridge_fetch_not_made_when_append_fails` (fake Azure transport and stub `op` assert the `use` entry already exists); core `tests/protocol.rs::audit_views` (`report_from_approver_view`), daemon `src/tests_more.rs::status_handles_audit_views` (host report has public fields only, ADR 0052); daemon `src/tests_more.rs::sync_loop_retries_and_appends_serialised_through_one_queue`, core `tests/protocol.rs::sizes_estimates` (N31) | | Fatigue (HF-1, ADR 0035) | core `src/fatigue.rs::expired_unanswered_counts_and_backoff_per_host_and_handle`, `src/fatigue.rs::quarantine_after_three_outcomes_and_pending_cap`, `src/fatigue.rs::drop_keyed_host_drop_pseudo_handle`, `src/fatigue.rs::coalescing_key_includes_scope_and_skips_number_match`; daemon `src/tests_flow.rs::fatigue_backoff_and_quarantine_survive_restart` [`fatigue_survives_daemon_restart`], `src/tests_flow.rs::backoff_after_one_denial_new_session_does_not_reset`, `src/tests_flow.rs::coalescing_and_number_match_never_coalesced` (a number-matched terminal request is never coalesced into an agent request; [`coalescing_key_includes_scope`]), `src/tests_flow.rs::expired_request_text_suggests_prefix_check`; e2e `uc3_night_shift.rs::e2e_uc3_night_shift_9h_default_policy` (quarantine never stops an active lease); PWA `screens.test.ts` "empty, cards, storm banner, deny all, mute and unmute; storm-card for a quarantined host", `request-view.test.ts` "number-six-digits, number-typed, …", "number-per-card: …"; **not built:** a flood with restarts and new sessions at the e2e layer, and an agent storm that must not block a terminal request's approval | ### 14.5 Landing claims (`landing/CLAIMS.md`) → tests, and required rewording | Claim | Test that proves it | Wording change required by v3 | | --- | --- | --- | | C01 value never in model context | `e2e_uc1_blind_use`, canary rule over MCP outputs | "goes into the child process" → "the daemon adds it to the outgoing request; it never enters the agent's process or output (proxy mode, with the Custos daemon running as its own OS user)". Dev mode is not claimed (N19) | | C02 output scrubbed | `core::redact` proptests, daemon `proxy::scrub_strips_cookies_redacts_and_withholds_encoded`, e2e `uc1_blind_use::e2e_uc1_blind_use` | Present as a safety net, not a guarantee (ADR 0016) | | C03 only toward allowed hosts | P6 tests | No `{{custos:name}}` placeholders; "only toward the host, method and path you approved" | | C04 servers can't read; halves alone open nothing | P1, P2 tests | "Our servers can't read the key" → "Our relay can't read your secrets or your answers; the approver app's code is trusted when it loads (see limits)". The H2 "Our servers carry the question. They can't read it, or the answer." (index.astro:538) and "We can't read" (index.astro:574) get the same qualifier, because whoever serves the approver origin can obtain values (N9, N16). Owner decision: self-host the approver origin or operate it separately from the relay (research/13) | | C05 relay can/can't read | `relay::db_holds_no_plaintext` | "The relay sees ids, kinds, sizes, times, device labels, the number of handles per request and grant, and each grant's window and use limit; it cannot read names, reasons, URLs, egress rules or the requested scope; it can infer one-time and high-tier approvals from a grant's window and use limit" (ADR 0032, COMPLETENESS-R3-5) | | C06 account key never uploaded | canary rule over DB + mailbox for `AS`; `core spec_properties::as_transfer_sealed`; e2e `enroll_second_approver::e2e_enroll_host_and_second_approver` (approver-to-approver transfer over the real relay) | "account key" → "account secret"; "moves directly … never uploaded" → "passes through our relay only sealed to the new device and expires there after 10 minutes" (§7.2) | | C07 nothing on the agent machine can approve | P3 tests | Add "and every approval needs your Face ID, fingerprint or phone passcode" (needs Custos's own OS user on the host and a client user that cannot become root, ADR 0030, 0043). Never say "biometric" alone (ADR 0044); never claim the phone "shows exactly what you sign" (ADR 0036) | | C08 scoped, time-boxed, ends on its own | `e2e_uc3_night_shift`, P11 tests | Add "enforced by the Custos daemon; provider-issued tokens also expire on their own" (ADR 0018) | | C09 narrow, never widen; picker | P5 tests; PWA `scope-picker.test.ts` (default once/15 min) | — | | C10 unanswered request expires after 10 min | `core spec_properties::request_expiry_bound`, daemon `tests_spec::eph_key_wiped_after_expiry` | Ledger row is derived (no `request.expired` entry needed) | | C11 signed, chained, verify detects drops; under-report caveat | P8 suite incl. core `tests/prefix_check.rs::fork_with_host_growth_detected_by_prefix_compare` (ADR 0068), core `tests/log_state.rs::revocation_epoch_and_use_rules` [`use_beyond_max_uses_rejected`] | Add: "a relay that shows your devices different histories is caught when you type the phone's 4 check words into the host (`custosd verify`), which then locks itself" — no claim that exchanging requests or grants catches it (ADR 0031, 0041) | | C12 reason shown verbatim | `core::text::sanitize_*`; PWA `request-view.test.ts` | "verbatim" → "shown as the agent's words, cleaned of hidden characters (with a badge when any were removed), kept sealed in the log, and recorded with every use" | | C13 revoking a lost phone stops its grants | core `tests/log_state.rs::device_revoke_stops_host`, daemon `src/tests_flow.rs::revocation_epoch_and_device_revoke_stop_use`; e2e `uc5_proof.rs::e2e_uc5_fork_found_by_prefix_check` (documents the limit); the P9 refused-revocation PWA tests | Add "within a minute, unless the relay hides or refuses it — the app then tells you to run the host check, which locks the host if it missed the revocation" (N17, ADR 0041). Revoking a lost *approver* does not re-protect secrets created later until account-secret rotation exists (N11) | | C14 1Password and Azure stay the store | `e2e_uc4_bridge_*`, daemon `tests_spec::bridge_credential_zeroized_after_fetch`, `bridge::op_env_is_exactly_allowed_set` | Tests use a fake transport and stub `op`; the Azure bridge also passes against the Lowkey Vault emulator (`make it-azure`, ADR 0056). 1Password: "on Linux hosts; on macOS in dev mode" (ADR 0056, N34) | | C15 import replaces values with handles | e2e `uc4_bridges.rs::e2e_uc4_import_env_and_doctor` | Add the rotate notice | | C16 suite | KATs | Replace "XChaCha20-Poly1305" with "ChaCha20-Poly1305 with key commitment"; add "HPKE" and "HKDF-SHA-256"; any primitive whose external vectors did not pass is labelled "draft, conformance vectors pending" (ADR 0039). Never say values are post-quantum protected end to end: "secrets stored in and moved by Custos are post-quantum encrypted; the last hop to each provider uses that provider's TLS" (N30) | | C17 any MCP client; macOS/Linux/Windows; iOS/Android/browser approve | mcp `tests/stdio.rs::stdio_roundtrip`; e2e `uc2_remote_approval.rs::e2e_uc2_remote_approval` (agent on the remote devbox through the CLI) | Agents: "macOS and Linux" only. `custos-mcp` and `custos-cli` do not compile for Windows (ADR 0068), so the page must not name Windows for agents; the most it may say is "the Custos daemon compiles for Windows; no Windows agent client yet" (ADR 0056 §2, ADR 0068) — never "runs on Windows" | | C19 relay JSON on the page is real PoC output | regenerate the fixture with `custos inspect` from an e2e relay DB; `parity.test.ts` re-pointed | Fixture becomes CBOR-diagnostic of real entries | | — `run_with_secrets npm test` turn (landing index.astro) | none (not offered, ADR 0016/0028) | Remove, or replace with an `http_request` turn | | — `custos bridge scan 1password\|azure` | none (deferred, §13.2) | Remove; show `custos bridge add` instead | | — `{{custos:handle}}` placeholders (landing, `brand.ts`) | none (removed by ADR 0016) | Replace with handle names used by the proxy (`custos:github-token`) | | — supported credential types | N22 | Say "HTTP APIs that take the credential in a header" for the PoC; SSH keys, certificates, database passwords marked "planned" | | — night story commands (story.ts:15-47, 60; index.astro 392–520: `npm test`, `node e2e/run.mjs`, `node e2e/webhooks.mjs --env prod` as 02:14 "Runs"/"Command") | none (inject/`run` deferred, §8.3) | Rewrite the night log as `http_request` calls against HTTP APIs (GitHub, a staging API) | | — `psql` / `staging-db` / migration 0042 rows | none (database wire protocols excluded, N22) | Remove, or label "planned" | | — "npm, node, psql only" (index.astro:414) | none (command constraints removed with `run`, A.3 TS#22) | Replace with the egress list (host, method, path) | | — 02:14 request on a locked phone (index.astro:430-457) | partial (N15, ADR 0085: an opt-in, content-free ping where Web Push exists and is enabled; the app must be opened to see and decide) | Show the approval in the open approver app; a ping may only say that a request is waiting | | C13 "A password-protected recovery kit is the last resort." (index.astro:737) | none (recovery deferred, §7.10; designed with **no password**; N21) | Remove, or "Planned: a printed recovery kit (no password)". `claims.test.ts` fails on "recovery kit" not marked planned | | C08 "Staging, these commands, until 07:00 is one tap at bedtime. Only something new wakes you." (index.astro:716) | none (no command constraints, A.3 TS#22; push is only a content-free ping, N15, ADR 0085; approval = hold + UV + typed number in the open app) | "Staging, these hosts and paths, until 07:00 — one approval at bedtime." `claims.test.ts` fails on "wakes you": a ping says only that something is waiting | | C03/C09/C12 "your phone shows that request, with the command" (index.astro:709) | none (no command in proxy mode) | Drop "with the command"; "with the host and path the key would go to" | | C04/C11 "it can't hide events your phone signed" (index.astro:731) | none (a forking relay can hide a revocation from a host until the prefix check, N17) | Replace with the C11 prefix-check wording | | C14 `akv://kv-prod/ledgerline-live` (index.astro:647) | none (not a §5.5 Source format) | Show the Source fields: vault `kv-prod`, secret `ledgerline-live` | | — untagged FAQ claims (index.astro:723: "Grants that end on a clock … a cloud that holds only sealed boxes") | CLAIMS.md rule: claim wording only in tagged elements | Tag with C08/C04/C12 or reword; "sealed boxes" gets the C04 qualifier | ### 14.6 End-to-end tests In `custos-daemon/tests/`: in-process relay + one or two daemons (profiles) + a simulated approver built from `core::approver` with a p256 WebAuthn authenticator + fake upstreams. One test per row of §13.4, plus the list below. **As audited in review round 2 (ADR 0068)**, each planned name is mapped to the test that exists, or marked **not built**: | Planned | Test that exists | | --- | --- | | `e2e_enroll_host_and_second_approver` | e2e `enroll_second_approver.rs::e2e_enroll_host_and_second_approver` | | `e2e_two_hosts_per_host_shares` | e2e `uc1_blind_use.rs::e2e_uc1_blind_use` (the devbox has no share of the laptop's secret; `handles.list` → []) | | `e2e_daemon_restart_requires_new_grant` | daemon `src/tests_flow.rs::restart_drops_grants_and_request_copies` (daemon level, in-process relay; no e2e-layer test) | | `e2e_multi_handle_grant_two_handles_used` (a **lease**; single-use grants name one handle, ADR 0054) | daemon `src/tests_flow.rs::multi_handle_lease_grant_all_shares_opened` (daemon level; no e2e-layer test) | | `e2e_flood_with_restarts_and_new_sessions` | **not built** at the e2e layer; daemon `src/tests_flow.rs::fatigue_backoff_and_quarantine_survive_restart`, `src/tests_flow.rs::backoff_after_one_denial_new_session_does_not_reset`; e2e `uc3_night_shift.rs::e2e_uc3_night_shift_9h_default_policy` (quarantine) | | `e2e_fork_hides_revocation_documented` | e2e `uc5_proof.rs::e2e_uc5_fork_found_by_prefix_check` (the devbox keeps its lease until `custosd verify`) | | `e2e_fork_found_by_prefix_check_with_host_growth` | core `tests/prefix_check.rs::fork_with_host_growth_detected_by_prefix_compare` (ADR 0068); at the e2e layer only incidentally, in `uc3`/`uc5`, where hosts append after the revoke | | `e2e_refused_revocation_alarms` | **not built** against the real relay; PWA `e2e/flows.spec.ts` "a forking relay freezes the approver; a refused revocation raises the alarm" (mock relay) | | `e2e_expiring_drop_handle` | e2e `drop.rs::e2e_drop` (the 24 h handle expires on every host) | | `e2e::revoked_approver_plus_host_seed_reads_post_revocation_secret` | core `tests/spec_properties.rs::revoked_approver_plus_host_seed_reads_post_revocation_secret` (ADR 0064) | | `e2e_fork_one_of_two_hosts_after_revoke_epoch` | e2e `uc5_proof.rs::e2e_uc5_fork_found_by_prefix_check` (ADR 0064) | | `e2e_lock_survives_concurrent_sync` | daemon `src/tests_more.rs::lock_survives_concurrent_sync` (daemon level; no e2e-layer test) | | `e2e_panic` | e2e `uc5_proof.rs::e2e_panic_this_wasnt_me` | | `e2e_lock_needs_daemon_user_to_unlock` | **not built** at the e2e layer; daemon `src/tests_more.rs::local_lock_unlock_unfreeze_and_refusals` (another uid is refused) | Daemons in e2e run in dev mode with `HostIdentity` injecting service mode where a test needs it; the service-mode layout itself (two uids, runtime directory, socket reachability, state unreadability, ancestor checks, `op` child environment) is tested with real permissions in `real_perms.rs`, the root-only second pass of the Linux coverage gate (§14.1, ADR 0042, 0055). **Status (ADR 0063):** the two-uid run is executed for `custos-daemon` (`COV_PACKAGES="-p custos-daemon" scripts/coverage-linux.sh`; result and tree hash in `landing/poc-output/gate.json` `linux`, `poc/STATUS.md`). It covers the daemon only, not the other crates, and Linux only (macOS service mode: ADR 0055). The PWA gets component tests with a fake core and, if time allows, one Playwright smoke test using a Chromium virtual authenticator. **As built (ADR 0062):** the use-case flows live in the workspace member `poc/tests-e2e` (`custos-e2e`, everything in `tests/` and `examples/`, so the coverage report is unchanged): a real relay over TCP with a SQLite file, two daemons `laptop` (dev) and `devbox` (service mode simulated in-process, isolation 1) on real Unix sockets, a scripted approver on `core::approver`, agents over MCP, the CLI and raw JSON-RPC, a warpable shared clock, fake upstreams and the §14.3 canary rule after every flow — tests `e2e_uc1_blind_use`, `e2e_uc2_remote_approval`, `e2e_uc3_night_shift_9h_default_policy`, `e2e_uc4_bridge_azure`, `e2e_uc4_bridge_1password`, `e2e_uc4_import_env_and_doctor`, `e2e_uc5_proof`, `e2e_uc5_fork_found_by_prefix_check`, `e2e_uc5_relay_vouching_for_tampered_entry_freezes`, `e2e_panic_this_wasnt_me`, `e2e_drop`, `e2e_credential_in_header_name_and_binary_body_is_scrubbed`, `e2e_request_carries_the_cli_process_pid` (the CLI re-executed as its own process); env-gated `e2e_uc4_bridge_azure_lowkey_vault` (`make e2e-azure-lowkey`) and `e2e_uc4_onepassword_live_custos_poc_vault` (`make e2e-1password-live`); the real PWA against the real stack in `approver/tests/real` (`make e2e-pwa`). Demos: `make demo-1` … `demo-5`, `demo-drop`. Showcase artefacts (terminal transcript, phone screenshots, relay inspect dump, manifest with the poc/ git tree, both numbers and a wrong-number negative control showing the core's number check refusing in the same build; the canary scan covers every base64 alignment after a positive control) come from one run of `node poc/scripts/capture-showcase.mjs` into `poc/demo/showcase/` (stack: `custos-e2e --example showcase`, a scenario clock shared by relay, daemons and the browser); `--verify` re-hashes them against the manifest and prints the manifest's SHA-256 (recorded in `poc/demo/README.md`). The script checks are tested without a model or container by `make e2e` (`tests-e2e/llm/checks.test.mjs`: an LLM-proof variant whose attack did not happen is a failure or "not exercised", never a pass; `demo/service-mode/test-checks.sh`: a walkthrough refusal counts only with its stated error and exit code). **Review round 1 (ADR 0064):** `e2e_enroll_host_and_second_approver` is built in `tests-e2e/tests/enroll_second_approver.rs` (a host, then a second approver joining over the real relay mailbox; the tablet's own grant is accepted and used); `e2e::revoked_approver_plus_host_seed_reads_post_revocation_secret`, `stolen_relay`, `revoked_approver_with_relay_cannot_decrypt` and `relay_substitutes_colliding_number_matched_request` are core tests in `crates/core/tests/spec_properties.rs` over the exact entry bytes a relay stores (no network needed to state them); `e2e_fork_one_of_two_hosts_after_revoke_epoch` is `uc5_proof::e2e_uc5_fork_found_by_prefix_check`. Review round 2 audited the other names (the table above, ADR 0068); `e2e_refused_revocation_alarms` exists only as the PWA Playwright test against the mock relay (`approver/tests/e2e/flows.spec.ts`, "a refused revocation raises the alarm"), not against the real relay (recorded in research/14). --- ## Appendix A — Traceability (nothing silently dropped) ### A.0 BRIEF requirements (owner) Items marked **owner** are cuts or partial deliveries of an owner requirement; they are listed in research/13 for the owner's sign-off. | BRIEF requirement | v3 location | Status | | --- | --- | --- | | Values never in model context: credential proxy | §8.1, P6, ADR 0016, 0030 | PoC (service mode) | | … injection into processes | §8.2–8.3 | Deferred (needs sandbox, ADR 0016) — **owner** | | … placeholder substitution | ADR 0016 | Removed by decision (AG-2): no substitution in URL/body | | … output redaction | §8.4 | PoC (accident prevention, N5) | | Full access log: what, when, agent/session/machine, why | §5.5 UseDetails (agent facts, session, per-call reason), §6, PWA Audit → Report (ADR 0052) | PoC: the "why" and agent facts are readable on approvers only; host-side `audit.report` shows public fields. The relay lets a host make up to ≈ 10 proxied calls/s (ADR 0058) and every device stores the full log, so a flooding agent can grow it by ≈ 200 MB/h (N31, ADR 0064) — **owner** | | Remote approval from phone or any enrolled device | §7.5, §12 | Partial: content-free push where Web Push exists and is enabled (ADR 0085); otherwise only while the app is open; no real-device push test yet (N15) — **owner** | | Grants for scope and time ("overnight until 07:00") | §7.5.3–7.5.4, ADR 0038 | PoC | | One-time retrievals that expire | once grants (§7.5.3) | Redefined: one-time *use* through the proxy; nothing retrieves a value, so non-HTTP consumers have no one-time path (N22). Planned delivery: one-time retrieval to a process via helper mode (§8.2); a reveal to the model is excluded by decision (ADR 0016) — **owner** | | Limited/scoped access; instant revocation | §7.7, P5, P9 | PoC; fork + withholding limit N17 | | Bridge from existing managers: 1Password, Azure Key Vault, .env | §9, §7.3 d | PoC; 1Password bridge on Linux hosts, and on macOS in dev mode by owner decision (N34, ADR 0056) | | … Bitwarden, AWS Secrets Manager, HashiCorp Vault | — | Deferred: one bridge per custody model proves the design (ADR 0021) — **owner** | | Session-based storage | PolicyBody field 10 expiring handles (ADR 0038) | Partial: time-limited usability, no erasure (N24); session-end binding deferred — **owner** | | Long-term storage | secret.version + policy | Partial: native values survive only while at least one host of their host set keeps its state; host loss, reinstall or a dev → service switch loses them (N28); bridged values stay in 1Password / Key Vault — **owner** (blocks a launch as primary store, with recovery) | | Zero-knowledge cloud | P1, P2, N9, N16 | PoC against the relay; whoever serves the approver code can obtain values (N9, N16; C04 reworded) — **owner** (self-host or separately operate the approver origin) | | Key parts device-to-device, never stored in the cloud | §7.2 `as-transfer` | Partial: `AS` passes the relay mailbox sealed to the new device for ≤ 10 min (C06 reworded) — **owner** | | Optional hardware keys (FIDO2/WebAuthn, PRF, Secure Enclave, TPM, StrongBox) | ADR 0027, 0044 (platform authenticator UV on approvers) | Partial: UV built (biometric or device passcode, A6c/N26; classical ES256, N27); PRF-wrapped approver keys and host TPM/SE binding deferred (N20) — **owner** | | Credential types: passwords, API keys, tokens, private keys, certificates, secure notes | N22 | PoC for HTTP-header credentials (API keys, tokens, bearer passwords); others deferred with helper/inject — **owner** | | Remote sessions (SSH, cloud dev boxes, cloud agents, headless runners) | UC2; §7.11; ADR 0043 | PoC for a provisioned box whose agent user cannot become root; CI runners, cloud agents and dev boxes with passwordless sudo, `docker` or uid 0 enroll as isolation 3 and get dev-mode security (N19); adding a host to existing secrets deferred (N23) — **owner** | | Platforms: macOS, Linux | §11.1 | PoC | | Platform: Windows | §11.1 Socket, §13.2; DeviceRecord platform `"windows"` | Partial by owner decision (ADR 0056 §2, amended by ADR 0068): only the daemon compiles for `x86_64-pc-windows-gnu`, with a named-pipe transport behind `cfg(windows)` (`make check-windows`); it is not run or tested on Windows. The agent side (`custos-mcp`, `custos-cli`) does not compile for Windows, so **agents cannot run on Windows in the PoC** and the BRIEF's "Windows (where agents run)" is not met. The landing may not name Windows for agents (C17) — **owner**: build the Windows agent clients or accept the gap | | iOS / Android as approvers | §12 PWA | PoC (iOS as Home Screen app, N21) | | Post-quantum, crypto agility | §3, P12, ADR 0009, 0039 | PoC; HNDL residual N18 (values, policy keys, `AS`); classical WebAuthn factor N27; the hop to each upstream is only as PQ-safe as its TLS (N30) | | Primary credential store (long term), browser autofill | — | Out of scope (BRIEF) | ### A.1 research/10 CHANGES | CHANGE | v3 location | Status | | --- | --- | --- | | 01 Number matching + request fingerprint | §3.3, §7.5.1, §7.5.3, ADR 0027 §6, ADR 0035 | PoC: typed 6-digit number entered per card (ADR 0047, 0050; overlap block removed; no trust styling), TTY-only output; scope stated (not proof a human started the request) | | 02 Rate limiting, lockout, "this wasn't me" | §7.5.5, §7.9, ADR 0035 | PoC: log-derived per host+handle, expiry counts, PWA storm card; "not me" on grants and uses; `custosd lock` (ADR 0041) | | 03 No bulk approve; prod individual; no "until" for tier 2 | §7.5.3 | PoC | | 04 Verified facts vs agent claims; `frame-ancestors 'none'` | §7.5.2 (three zones; host-reported facts labelled), §12 | PoC | | 05 Mandatory device-bound UV | ADR 0027, 0044, §5.4 | PoC for UV (may be the device passcode: A6c, N26); attestation / synced-passkey rejection deferred: needs attestation parsing and an AAGUID policy (ADR 0028) | | 06 Gated approver enrollment | §7.2 (role first, approver-shown code for approver adds, SAS, UV, warning naming the AI agent, log notification, new-device banner for hosts too, host-side listing; ADR 0049) | Partial: 24 h cooling-off and veto deferred — single-owner demo (ADR 0013, 0028). **Does not mitigate script on the approver origin (A6b, N16, ADR 0036)** | | 07 Approver code not served by relay | §10, §12, ADR 0019 | Partial: separates relay software from code delivery; does **not** protect against a malicious or compromised static origin or tunnel (N9); out-of-band manifest check and signed release key deferred | | 08 Authenticate every public key | §5.4 genesis (AK pub, relay_kid), §5.6, §6.7 | PoC | | 09 Approver-signed policy bound to ciphertext | §5.4 policy (`SecretRef` with entry hash), §7.4, ADR 0026 | PoC | | 10 Encrypt metadata | §5.4/§5.5 (names, references, egress, tier, reasons sealed) | PoC; padding and device labels deferred (N10) | | 11 No negotiable crypto; KDF floor | §3, §5.1 (suite floor), §7.10 (no password) | PoC; floor raise deferred | | 12 Panic rotate | §7.9 | Partial: revoke/epoch/exposure report; provider adapters deferred (ADR 0028) | | 13 Exposure ledger, age policy | §7.9, `audit.exposure` | Ledger PoC; age policy deferred | | 14 Memory hygiene | §11.1 hardening, ADR 0008 | Partial: zeroize, dumpable, RLIMIT_CORE; mlock/PT_DENY_ATTACH/WER deferred (no unsafe, ADR 0028) | | 15 Debug artifact hygiene | §10 logs/errors, §14.3 canary rule | PoC; debug bundle command deferred | | 16 Redaction hardened, proxy preferred | §8.1, §8.4 | PoC; identity encoding forced and encoded bodies withheld (ADR 0037); entropy flagging deferred | | 17 Own supply chain | §2.4, ADR 0029 (pins, age on lockfile, deny) | Partial; reproducible dual-signed releases, SLSA, `cargo vet` deferred | | 18 Update channel (TUF) | — | Deferred: no release ships tonight | | 19 Host containment, broader doctor | §7.4 (prod → tier 2), §9.5, §11.1 service mode (ADR 0030) | Partial; anomaly scoring, agent sandbox and MCP-config/history scans deferred | | 20 Destructive-operation guard | §5.5 `confirm`, §7.5.6 step 3 | PoC; preset covers POST/PUT/PATCH/DELETE and binds H(body) (ADR 0037) | | 21 Bridge credentials least-privilege | §9.1, §9.4 | PoC (guidance + custody); egress-IP correlation with provider logs deferred | | 22 Zero-power operator | §10 (no admin API) | PoC | | 23 Witnessed, complete audit | §6.7 gossip, §6.6, §6.8 log fingerprint, P14 | Partial: prefix check run on the host as the daemon user (`custosd verify`, ADR 0041); gossip only catches a careless relay; witnesses deferred; N17 states the fork gap | | 24 Recovery without escrow | §7.10 | Deferred (lockout only; ADR 0028) | | 25 Hardware-flaw agility | §5.4 WebAuthnCred | Deferred: AAGUID/firmware revocation needs attestation | | 26 Local config can only narrow | §11.1 state files | PoC | | 27 Rotatable trust roots | §4 relay key | Deferred: relay key rotation entry and drill (product) | ### A.2 research/11 findings | Finding | v3 location | Status | | --- | --- | --- | | MR-1 enrollment trusts relay's account | §7.2, ADR 0013 | PoC | | MR-2 timestamp-ordered roster | §5.6 (position), ADR 0012 | PoC | | MR-3 revocation withheld | §6.1, §6.4, §7.7, P9 | PoC against a non-forking relay; refused append raises a red alarm (ADR 0041); fork + withholding is N17 | | MR-4 split view | §6.2, §6.7, §6.8, P8 | Partial: detected by the host-side prefix check (ADR 0041); device cosigning/witnesses deferred | | MR-5 relay serves PWA | §12, ADR 0019 | PoC | | MR-6 plaintext scope/metadata | §5.4/5.5 sealing | PoC for requested scope; granted window and use limit plaintext by decision (ADR 0032); residual N10 | | MR-7 rollback / resurrection | §5.6 versions, §6.5 | PoC | | MR-8 40-bit code recoverable | §7.2 (code not secret) | PoC | | MR-9 AK never rotates | ADR 0014 (AS epochs) | Partial: epoch field; rotation deferred, residual N11 | | AG-1 `run` exfiltration | §8 (proxy only) | PoC by removal of `run` | | AG-2 placeholder substitution anywhere | §8.1 | PoC | | AG-3 persistence via files / background processes | §8.3 | Not applicable in PoC (no child gets a value); inject deferred | | AG-4 reason social engineering | §7.5.2 | PoC | | AG-5 default = requested | §7.5.3 | PoC; lease/night-shift uses pre-filled with min(requested, 200) (ADR 0047) | | AG-6 client-reported pid | §11.1 peer creds + sessions, host-reported zone (§7.5.2) | PoC incl. separate daemon UID (ADR 0030); cwd and exe path client-reported and labelled (ADR 0042) | | AG-7 host-signed policy | §5.6 policy actor = approver, ADR 0015/0026 | PoC | | AG-8 redaction bypass | §8.4 framing, N5 | PoC | | LH-1 environ / ptrace | proxy only; §11.1 hardening; service mode (ADR 0030) | PoC (separate daemon user, Linux dumpable); mlock deferred | | LH-2 pipe DACL / loopback proxy | §11.1 socket perms (group runtime dir outside ``, ADR 0042); no loopback listener; no loopback egress (ADR 0037) | PoC; Windows deferred (escalated) | | LH-3 key files / `.env` shred | §7.3 d rotate notice; §4 key files owned by the daemon user (ADR 0030) | PoC; OS keystore / TPM / Secure Enclave deferred (N20) | | PH-1 UV none | ADR 0027 | PoC | | PH-2 last approver / recovery | §5.6 (allowed), §7.10 | Recovery deferred | | HF-1 fatigue | §7.5.5, §7.5.3, ADR 0035 | PoC (log-derived, PWA-enforced; was daemon-memory in v2) | | HF-2 social-engineered enrollment | §7.2 warning, UV on device.add, unique labels, device fingerprint, new-host banner, "nobody — including your AI agent — will ask for this code", role first and approver-shown code (ADR 0049), 4-word devfp | PoC (residual N32); veto deferred | | NW-1 auth in URL; classical TLS | §10 header auth, ADR 0025 | PoC; relay hybrid TLS deferred | | CL-1 clock | §7.6 | PoC; Roughtime deferred | | QC-1 HNDL at rest | §4, P12, N18 | **Accepted residual** (ADR 0034): values rest on X-Wing; `atransit`/`grant-share` stay in the log | | IN-1 supply chain | §2.4, ADR 0029, N13 | Partial | | DS-1 DoS / 409 livelock | §6.1 bounded retries, §7.5.5 | PoC | | BR-1 vault-wide credential | §9.1 rule 5, N7 | PoC (guidance + per-fetch) | | BR-2 `op` token in child env | §9.3 (isolation 1 only, canonical root-owned `op` path, cleared env, own group), N14 | PoC via separate daemon UID (ADR 0030); 1Password Connect deferred | | BR-3 handle → reference | §5.5 grammar, ADR 0026 | PoC | | BR-4 az/op local standing access | §9.1, §9.5 | PoC; checks run client-side in the caller's environment (ADR 0042) | | BR-5 Entra tokens unrevocable | ADR 0026 §6 (never cached) | PoC | | BR-6 IMDS | §9.5 detection | Partial; blocking deferred | | BR-7 Tessera value for bridges | N7, ADR 0022 §5 | PoC (claim stated) | | TS-1 grant binds request id only | §5.4 `request_hash` | PoC | | TS-2 canonical JSON | ADR 0024 | PoC | ### A.3 TypeScript prototype decisions (TS#1–29) | TS# | Decision | v3 | | --- | --- | --- | | 1 | Hybrid signature encoding (Ed25519 ‖ ML-DSA, label prefix) | Replaced by composite (ADR 0004, 0024) | | 2 | Seeds only | Kept (§4) | | 3 | Key commitment | Kept as AEAD-C1 commitment (ADR 0005) | | 4 | Random nonces inside AEAD | Superseded: AEAD-C1 derives key and nonce from a random salt | | 5 | Tagged-array AAD | Kept as det-CBOR arrays (§3.1) | | 6 | Sub-labels per signed kind | Replaced by envelope type ctx + `kind` (§3.2) | | 7 | Canonical JSON | Removed (ADR 0024) | | 8 | Integer ms timestamps | Kept (§5) | | 9 | C1 = XChaCha + HKDF-SHA-512 | Replaced by ADR 0009 table | | 10 | Account id definition | `leaf_hash(genesis)` (ADR 0024) | | 11 | AccountRecord binds AK pub | Kept inside genesis (§5.4) | | 12 | Time-ordered chain verification | Replaced by log position (ADR 0012) | | 13 | Grants of later-revoked approver invalid | Kept (§5.6) | | 14 | Pairing code, mailbox id, SAS | Replaced by commit-reveal (ADR 0013); code format kept | | 15 | Mailbox delete/TTL | TTL 10 min, ≤ 8 messages kept (§10); delete-on-read **dropped** (unauthenticated mailbox: a reader could delete before the device; C06 reworded) | | 16 | Sealed metadata | Extended: policy bodies, scope, tier sealed (§5.5) | | 17 | DEK and value AAD | Kept with per-host `K` (ADR 0014) | | 18 | Listing returns full records; no add-host | Full log to all devices (§6.1); add-host deferred (§7.11) | | 19 | Sealed request details | Extended with scope and op (§5.5) | | 20 | Narrowing rules (host + relay) | Host-side only (§7.5.4); relay cannot see scope | | 21 | 1000-use default cap | Replaced by explicit `max_uses` ≤ 10 000 (§5.4) | | 22 | `commands` constraint by argv[0] | Removed with `run`; inject pinning deferred (§8.3) | | 23 | Revoke `"*"` + timestamp | Replaced by `revoke.epoch` (§7.7) | | 24 | SecretDrop object | Replaced by secret.version + policy + grant (§7.8) | | 25 | Audit genesis, 409 rebuild | Kept (§6.1), generalised to all entries | | 26 | Extra routes | Collapsed into log + mailbox (§10) | | 27 | Signer = caller; freshness; replay cache | Kept (§10); freshness hygiene only | | 28 | Plaintext device names | Accepted for PoC (N10) | | 29 | Redaction encodings, maximal run | Kept + nested encodings (§8.4) | ### A.4 v1 sections | v1 | v2 | | --- | --- | | §1 Principles | §1.1 (principle 1 narrowed to proxy mode) | | §2 Components (relay serves PWA; TS daemon) | §2 (Rust crates; relay serves no code) | | §3 Suite, seal, keys, Tessera, tiers | §3, §4, §5.4, ADR 0003/0005/0006/0014; tier 0 deferred | | §3.4–3.5 device chain, enrollment | §5.6, §7.2 | | §3.6 request/grant | §5.4, §7.5 | | §3.7 audit chain | §6 (account log) | | §3.8 request auth | §10 | | §4 relay API | §10 | | §5 daemon (`run`, placeholders, pid trust) | §11.1, §8 (proxy only; peer creds) | | §6 MCP tools | §11.3 (`run_with_secrets` removed) | | §7 PWA | §12 | | §8 use cases | §13.4 | | §9 bridges ("local" column) | §9 (Custos-owned credentials only) | ### A.5 Spec review round 1 (research/13) | Finding | v3 location | Disposition | | --- | --- | --- | | CRYPTO-R1-1 XSS swaps the UV body | §1.2 A6a/A6b, P3, P7, N16, §12 CSP, ADR 0036 | Fixed (claims corrected; Trusted Types; host-side listing); host-local egress gate rejected; k-of-n deferred | | CRYPTO-R1-2 / AGENT-HOST-R1-1 same-UID key theft | §1.1, §4, §11.1, P6, P13, N19, ADR 0030 | Fixed (service mode; dev mode labelled in signed DeviceRecord) | | CRYPTO-R1-3 HNDL of log ciphertexts | P12, N18, ADR 0034 | Fixed by correcting claims; side table deferred | | CRYPTO-R1-4 self-signed objects, H(offer) | §5.1, §5.7, §3.3, ADR 0033 | Fixed | | CRYPTO-R1-5 host cannot open its sealed details | §7.5.1 step 4, §7.5.4 | Fixed (in-memory copy) | | CRYPTO-R1-6 external vectors | §2.3, §14.2, ADR 0039 | Fixed | | MALICIOUS-RELAY-R1-1 / IMPLEMENTABILITY-R1-1 fork hides revocation | P8, P9, P14, §6.4, §6.7, §6.8, N17, ADR 0031 | Fixed by restating + log fingerprint; witnesses deferred | | MALICIOUS-RELAY-R1-3 plaintext grant scope | P1, N10, C05, ADR 0032 | Fixed (decision recorded) | | MALICIOUS-RELAY-R1-4 sticky freeze on garbage | §6.3 | Fixed | | MALICIOUS-RELAY-R1-5 SRI on same origin | N9, §12, A.1 CHANGE-07 | Fixed (wording); out-of-band check deferred | | AGENT-HOST-R1-2 / HUMAN-FACTORS-R1-3 drop egress from hint | §7.3 c, §7.4, §7.8, ADR 0037 | Fixed | | AGENT-HOST-R1-3 op token in environ | §9.1, §9.3, N14, ADR 0030 | Fixed (service mode only; root-owned op path) | | AGENT-HOST-R1-4 shared credential across tiers | §7.4, §7.5.4, ADR 0040 | Fixed | | AGENT-HOST-R1-5 unfreeze / view.cbor | §6.3, §11.1, ADR 0030 §7 | Fixed (no RPC; missing view ⇒ frozen); alarm mirroring to approver deferred | | AGENT-HOST-R1-6 op not enforced | §5.5, §7.5.6, ADR 0037 | Fixed (once binds op + body hash; POST in confirm preset) | | AGENT-HOST-R1-7 Accept-Encoding / Range | §8.1, ADR 0037 | Fixed | | AGENT-HOST-R1-8 loopback egress | §5.5, §8.1, ADR 0037 | Fixed (removed) | | AGENT-HOST-R1-9 / HUMAN-FACTORS-R1-6 session binding | §4, §5.5, §7.5.2, §7.5.6, N4, ADR 0035 | Fixed (host-honest binding + plain-words scope) | | AGENT-HOST-R1-10 / HUMAN-FACTORS-R1-1 fatigue state | §7.5.5, ADR 0035 | Fixed | | AGENT-HOST-R1-11 / HUMAN-FACTORS-R1-7 number matching | §7.5.1, §7.5.3, ADR 0035 | Fixed | | HUMAN-FACTORS-R1-2 / -R1-8 unsanitized facts, denylist | §7.5.2 | Fixed (three zones; Default_Ignorable allowlist); NFC deferred | | HUMAN-FACTORS-R1-4 / COMPLETENESS-R1-3 / IMPLEMENTABILITY-R1-12 overnight | §5.5 Scope, §7.4, §7.5.3, §7.5.4, §13.4, ADR 0038 | Fixed | | HUMAN-FACTORS-R1-5 storage eviction | §7.1, §12, N21 | Fixed for PoC; recovery deferred (launch blocker) | | HUMAN-FACTORS-R1-9 kill switch | §7.9, §11.1 `lock` | Fixed | | HUMAN-FACTORS-R1-10 host labels | §5.4, §5.6, §7.2, §7.5.2 | Fixed | | HUMAN-FACTORS-R1-11 narrow defaults | §7.5.3 | Fixed (pre-select same-tier handles) | | IMPLEMENTABILITY-R1-2 precedence | header, §3.1, ADR 0033 | Fixed | | IMPLEMENTABILITY-R1-3 verify-before-decode | §5.1, ADR 0033 | Fixed | | IMPLEMENTABILITY-R1-5 drop narrowing | §5.5 Scope, §7.5.4, §7.8 | Fixed | | IMPLEMENTABILITY-R1-6 mailbox framing / code | §5.7 | Fixed | | IMPLEMENTABILITY-R1-7 fuzz harness vs deps | §2.4, §14.2 | Fixed | | IMPLEMENTABILITY-R1-8 RPC types | §2.2 `custos-rpc`, §11.1, §13.3 | Fixed | | IMPLEMENTABILITY-R1-9 header value | §8.1 | Fixed | | IMPLEMENTABILITY-R1-10 Linux coverage | §13.3 | Fixed | | IMPLEMENTABILITY-R1-11 hash ambiguities | §3.2, §3.3, §10 | Fixed | | COMPLETENESS-R1-1 per-use who/why | §5.5 UseDetails, §7.5.6, §11.1 | Fixed | | COMPLETENESS-R1-2 session storage | PolicyBody 10, §7.8, ADR 0038, A.0 | Partial (expiring handles); session-end binding deferred, owner flagged | | COMPLETENESS-R1-4 Windows | §13.2, §14.5 C17, A.0 | Escalated to owner; landing copy fix | | COMPLETENESS-R1-5 credential types | N22, A.0, §14.5 | Fixed (honest limit + rewording) | | COMPLETENESS-R1-6 C06 / delete-on-read | §7.2, §10, §14.5 C06, A.3 TS#15 | Fixed (wording) | | COMPLETENESS-R1-7 multi-handle eph key | §4, §7.5.4 | Fixed | | COMPLETENESS-R1-8 add-host | §7.11, N23, A.0 | Deferred with honest limit, owner flagged | | COMPLETENESS-R1-9 host hardware keys | N20, §13.2, A.0 | Deferred with honest limit | | COMPLETENESS-R1-10 BRIEF traceability, landing items | A.0, §14.5 | Fixed | ### A.6 Spec review round 2 (research/13) | Finding | v4 location | Disposition | | --- | --- | --- | | MALICIOUS-RELAY-R2-1 / HUMAN-FACTORS-R2-1 "same or larger size" passes a forked, growing host | §3.3, §6.8, P8, N17, §12, §14.4 P8, C11/C13, ADR 0041 | Fixed: prefix compare at `S`, host compares typed words and locks on FORK | | MALICIOUS-RELAY-R2-2 gossip is relay-controlled | P8, §6.7, §14.4 test renames, C11, ADR 0041 §5 | Fixed (restated; expired-request hint) | | MALICIOUS-RELAY-R2-3 fork checks via shadowable user CLI | §6.3, §6.8, §7.9, §11.1, N25, ADR 0041 §3 | Fixed (`sudo -u /usr/local/bin/custosd verify` / `lock`); shell-startup residual stated (N25) | | MALICIOUS-RELAY-R2-4 refused revocation append | §6.1, P9, §7.7, ADR 0041 §6 | Fixed (red alarm, background retry) | | MALICIOUS-RELAY-R2-5 P7 vs forks; no prompt after policy edits | P7, §7.4, §6.8 | Fixed | | MALICIOUS-RELAY-R2-6 P12 "symmetric" metadata; AS via as-transfer | P12, N18, ADR 0034 amendment | Fixed (claims corrected) | | AGENT-HOST-R2-1 / IMPLEMENTABILITY-R2-1 socket unreachable; start check contradiction | §2.1, §11.1, P13, §14.1, §14.4, ADR 0042 | Fixed (runtime dir outside ``; real-permission tests in the Linux container) | | AGENT-HOST-R2-2 `import.env {path}` confused deputy | §7.3 d, §11.1, §11.2, ADR 0042 | Fixed (CLI does import; no client paths opened by the daemon) | | AGENT-HOST-R2-3 doctor inside the daemon | §9.5, §11.1, §2.2, ADR 0042 | Fixed (client-side checks + daemon facts) | | AGENT-HOST-R2-4 method-override headers | §8.1, P6, ADR 0037 amendment | Fixed (drop list + `_method` refused); allow-list rejected (breaks provider version headers) | | AGENT-HOST-R2-5 Azure bridge in dev mode | §9.1, §9.3, N14, ADR 0043 | Fixed (every bridge needs isolation 1) | | AGENT-HOST-R2-6 `op` symlink | §9.3, ADR 0030 amendment | Fixed (canonical path required) | | AGENT-HOST-R2-7 minted credentials, Set-Cookie | §8.1, §7.4, §7.9, N6, ADR 0037 amendment | Fixed (Set-Cookie stripped; minting paths flagged; report text) | | AGENT-HOST-R2-8 / HUMAN-FACTORS-R2-3 session label in Verified zone | §7.5.2, ADR 0047 §1 | Fixed | | HUMAN-FACTORS-R2-2 passcode satisfies UV | §1.1, §1.2 A6c, P3, N26, §7.1, §12, C07, ADR 0044 | Fixed (claims corrected; SDP guidance); k-of-n deferred | | HUMAN-FACTORS-R2-4 night-shift = requested uses | §7.5.3, ADR 0047 §2 | Fixed | | HUMAN-FACTORS-R2-5 agent storm locks out the human | §7.5.3, §7.5.5, N29, ADR 0047 §3 | Partly fixed (per-card numbers); per-session buckets and number-match exemption rejected; residual N29 | | HUMAN-FACTORS-R2-6 free-text `prod` | §5.5, §7.4, ADR 0047 §4 | Fixed | | HUMAN-FACTORS-R2-7 drop hint shown first; no OpenAI entry | §7.4, §7.8, §13.4, ADR 0047 §5 | Fixed | | HUMAN-FACTORS-R2-8 overnight confirm stalls | §7.5.3, §7.5.5, §13.4 UC3 | Fixed (card lists confirm rules; quarantine never stops the lease) | | IMPLEMENTABILITY-R2-2 no time-zone library | §2.3, §5.5, §7.5.1, ADR 0045, ADR 0029 amendment | Fixed (jiff, DST rule, goldens) | | IMPLEMENTABILITY-R2-3 peer exe/cwd across uids | §5.5, §7.5.1, §11.1, ADR 0042 §5 | Fixed (client-reported, labelled; no exe hash) | | IMPLEMENTABILITY-R2-4 drop grant window, form fields | §7.8 | Fixed | | IMPLEMENTABILITY-R2-5 `duration_s` defaults | §5.5, §7.5.1, §11.1, §11.3, ADR 0045 | Fixed | | IMPLEMENTABILITY-R2-6 `not_before` source | §5.4, §5.6, §6.1, §7.5.4, §7.6, ADR 0045 | Fixed | | IMPLEMENTABILITY-R2-7 honest relay trips time monotonicity | §10 | Fixed (checkpoint under per-account lock, monotone time) | | IMPLEMENTABILITY-R2-8 drop fatigue key | §7.5.5 | Fixed ((host, DROP)) | | COMPLETENESS-R2-1 service mode on escalatable hosts | §1.2 A3, §5.4, §7.2, §9.5, N19, A.0, ADR 0043 | Fixed (isolation 3); owner flagged | | COMPLETENESS-R2-2 C04 / zero-knowledge overclaim | §1.1, N9, C04, A.0 | Fixed (wording); owner decision on approver origin | | COMPLETENESS-R2-3 landing night story | §14.5 | Fixed (rewording rows) | | COMPLETENESS-R2-4 bridge fetch before `use` | §6.6, §7.5.6, §9.3, P14 | Fixed | | COMPLETENESS-R2-5 long-term storage durability | N28, A.0, §11.1 | Fixed (honest limit); owner flagged | | COMPLETENESS-R2-6 remote approval needs open app | N15, §12, A.0 | Owner flagged | | COMPLETENESS-R2-7 one-time retrievals redefined | A.0 | Owner flagged | | COMPLETENESS-R2-8 "windows" platform value | §5.4 | Fixed | | CRYPTO-R2-1 policy copies unbound | §3.2, §3.3, §5.4, §5.5, §5.6, §7.4, ADR 0046 | Fixed | | CRYPTO-R2-2 revoked approver keeps a_i | N11, §7.9, §14.4 P2 | Fixed (claims, PWA text, test); AS rotation deferred as launch blocker | | CRYPTO-R2-3 SUF of composite; replay cache | §10, §14.2, ADR 0004 amendment | Fixed | | CRYPTO-R2-4 classical WebAuthn factor | P12, N27, ADR 0009 amendment | Fixed (claims) | ### A.7 Spec review round 3 (research/13) | Finding | v5 location | Disposition | | --- | --- | --- | | AGENT-HOST-R3-1 daemon binary and parents writable | §1.2, P13, §6.8, §9.5, §11.1, §2.2, §14.4, ADR 0048 | Fixed (root-created `/opt/custos`; ancestor + ACL check at start and in subcommands; doctor) | | AGENT-HOST-R3-2 cached sudo ticket | §1.2 A3, N25, §6.1, §6.8, §7.9, §11.1, ADR 0048 | Fixed (`sudo -k` in every host command; `timestamp_timeout=0` recommendation; test) | | HUMAN-FACTORS-R3-1 agent enrolled as approver | §5.4, §5.6, §5.7, §7.2, §12, N32, ADR 0049 | Fixed (role first, approver-shown code, warning names AI agent, approver platform); residual N32 | | HUMAN-FACTORS-R3-2 number-match banner backwards | §7.5.2, §7.5.3, ADR 0050 | Fixed (no trust styling; zone-2 text); host-typed tier-2 confirmation deferred | | IMPLEMENTABILITY-R3-1 lock flag lost update | §6.3, §11.1, ADR 0051 | Fixed (flag files, short flock) | | IMPLEMENTABILITY-R3-2 Linux gate as root | §13.3, §14.1, §14.6, ADR 0055 | Fixed (two-pass gate) | | COMPLETENESS-R3-1 host morning report unreadable | §11.1, §12, §13.4, A.0, ADR 0052 | Fixed (option b: PWA report; host public fields) | | CRYPTO-R3-1 upstream TLS HNDL | P12, N30, C16, A.0 | Fixed (claims); group recording deferred | | CRYPTO-R3-2 / IMPLEMENTABILITY-R3-4 policy overflows 64 KiB | §3.2, §5.4, §5.5, §5.6, §5.8, ADR 0053 | Fixed (single ciphertext under `PK`; bounds; size test) | | CRYPTO-R3-3 grindable device fingerprint | §3.2, §3.3, §5.6, ADR 0049 | Fixed (devfp from the device.add leaf, 4 words; skeleton labels) | | CRYPTO-R3-4 unsigned Added | §5.4, §5.7, ADR 0049 | Fixed (hash in device.add) | | MALICIOUS-RELAY-R3-1 colliding number | §3.3, P4, §7.5.3, ADR 0050 | Fixed (6 digits; bound ≤ 10⁻⁵) | | MALICIOUS-RELAY-R3-2 per-host prefix check | P8, P9, N17, §6.8, §7.9, §12 | Fixed (affected-host list, checklist, banner) | | AGENT-HOST-R3-3 overlapping rules | §5.5, §7.4, §7.5.6, §8.1, ADR 0054 | Fixed | | AGENT-HOST-R3-4 stale escalation | §7.2, §9.5, §11.1, ADR 0048 | Fixed (daemon re-checks `docker`/uid 0) | | AGENT-HOST-R3-5 `sudo -n` after install | §7.2, §14.1, ADR 0048 | Fixed (`sudo -k -n true`) | | AGENT-HOST-R3-6 coalescing | §7.5.5, ADR 0050 | Fixed | | AGENT-HOST-R3-7 session token in RPC | §11.1 | Fixed | | AGENT-HOST-R3-8 `op` argv | §5.5, §9.3, §9.4, N33 | Fixed (residual stated, ids recommended) | | HUMAN-FACTORS-R3-3 write body unseen; URL in zone 1 | §5.5, §5.8, §7.5.2, ADR 0054 | Fixed | | HUMAN-FACTORS-R3-4 deploy keys, collaborators | §7.4, §7.9, ADR 0054 | Fixed (wildcard entries, sourced) | | IMPLEMENTABILITY-R3-3 per-grant max_uses | §5.6, §7.5.3, §7.5.4, §14.6, ADR 0054 | Fixed (single-use grants name one handle) | | IMPLEMENTABILITY-R3-5 multi-step enroll | §11.1, §11.2 | Fixed (three methods) | | IMPLEMENTABILITY-R3-6 relay encodings, CORS | §10, §14.2 | Fixed | | IMPLEMENTABILITY-R3-7 URL parser in core | §8.1, §14.4 | Fixed (`Target`) | | IMPLEMENTABILITY-R3-8 no cut order | §13.3, ADR 0028 amendment | Fixed (ranked cut list) | | IMPLEMENTABILITY-R3-9 written before applied | §6.5, §6.8, ADR 0051 | Fixed (apply on clone; CORRUPT) | | IMPLEMENTABILITY-R3-10 http_request without wait | §7.5.6, §11.3 | Fixed | | COMPLETENESS-R3-2 landing sentences | §14.5 | Fixed (rows added) | | COMPLETENESS-R3-3 macOS 1Password untested | §9.3, §13.1, §13.4, C14, A.0, ADR 0055 | Fixed (Linux-only until a macOS check) | | COMPLETENESS-R3-4 log growth, 2 calls/s | §6.1, §7.5.5, N31, §13.2, A.0 | Fixed (honest limit, one append queue); remedy deferred, owner flagged. Rate restated in review round 1 to the relay as built: ≈ 10 calls/s per host (ADR 0058, ADR 0064) | | COMPLETENESS-R3-5 C05 tier | §14.5 C05 | Fixed | ## Appendix B — ADR index 0001 Rust · 0002 crypto libraries · 0003 X-Wing + HPKE · 0004 composite signatures · 0005 AEAD-C1 · 0006 KDF/hash · 0007 det-CBOR · 0008 RNG + memory · 0009 agility · 0010 *(superseded by 0024)* · 0011 account log · 0012 roster by position · 0013 enrollment SAS · 0014 AS epochs + per-host Tessera · 0015 approver-signed policy · 0016 exposure modes · 0017 request/grant hardening · 0018 lease time · 0019 approver not served by relay · 0020 push + WebSocket (product) · 0021 bridge custody · 0022 bridge scoping · 0023 local hardening · **0024 wire format** · **0025 long-poll** · **0026 handles** · **0027 WebAuthn UV** · **0028 PoC scope cut** · **0029 non-crypto pins** · **0030 daemon OS user** · **0031 fork honesty + log fingerprint** · **0032 plaintext grant window** · **0033 bytes normative + self-signed** · **0034 HNDL residual** · **0035 fatigue + sessions** · **0036 approver origin / XSS** · **0037 egress hardening** · **0038 lease envelope + expiring handles** · **0039 external vectors** · **0040 shared credential tier** · **0041 prefix fork check** · **0042 service-mode layout, client-side host work** · **0043 escalatable clients, bridges need isolation 1** · **0044 UV includes passcode (A6c)** · **0045 jiff time zones, durations, not_before** · **0046 policy body commitment** *(superseded by 0053)* · **0047 approver defaults round 2** · **0048 root-owned daemon paths, `sudo -k`, live escalation check** · **0049 enrollment role first, approver-shown code** · **0050 six-digit number, no trust styling** · **0051 daemon state flag files, apply on clone** · **0052 host audit public, morning report on phone** · **0053 single policy ciphertext** · **0054 write guard round 3** · **0055 two-pass coverage gate, 1Password Linux-only** · 0056 owner-priority scope overrides · 0057 lockfile security exceptions, licences, dev pins · 0058 relay implementation choices · 0059 CLI and MCP client choices · 0060 daemon implementation choices · 0061 wasm bindings and approver PWA choices · 0062 end-to-end proof harness · 0063 daemon review round 1 fixes · 0064 spec conformance review round 1 · 0068 spec conformance review round 2 (Windows daemon-only, real test names, host-growth prefix check).