| T1 Registry serves a tampered release |
A1 |
C1 Release signature verified with the pinned ed25519 key before any use; fail closed |
internal/bundle/sign.go, bundle.PullRing |
| T2 Registry replays an older signed release, or one built for another ring or org |
A1 |
C2 Signed ring pointer with org, ring, seq, expiresAt; halod checks all four and refuses a seq regression. The signer side has its own guard, see T28 |
internal/bundle/pointer.go, cmd/halod/agent.go |
| T3 Registry freezes clients on a stale release |
A1 |
C3 7-day pointer expiry plus a scheduled halo release refresh; halod warns 24h out and stops updating after expiry |
pointer.go (DefaultPointerTTL), halod warn window |
| T4 Binary swapped in transit or at the vendor |
A2 |
C4 Manifest carries https URL, exact size, sha256 or sha512; https-only redirects; size cap; hash check before rename |
cmd/halod/install.go |
T5 Install script runs as root (curl | bash, npm lifecycle scripts) |
A2, A6 |
C5 No shell install by default (allowShellInstall: false); npm --ignore-scripts, empty npmrc, root-owned node only |
install.go, config.go |
| T6 Signed manifest names an arbitrary path, so the key alone gives root file write |
A1, A7 |
C6 Per-harness path allowlist; clean absolute paths only; 0644 mode ceiling; stale-file removal uses the same allowlist |
cmd/halod/paths.go |
| T7 Non-root user swaps config, key, token or state, or races a directory |
A3 |
C7 Ownership preflight: root-owned, not group or other writable, no user-planted symlinks, whole ancestor chain; device token file must be 0600 |
paths.go (checkChain), main.go (preflight) |
T8 Artifact overwrites halod |
A1, A7 |
C8 binPath may not name halod; shims never replace a foreign file |
install.go |
T9 Client forges x-halo-* or identity headers to pick a cohort |
A4 |
C9 Every inbound x-halo-* stripped; cohort from verified identity only; an unverified caller is refused with 401 (or, only with anonymous access enabled, gets default routing and no experiment) |
internal/gateway/prepare.go, decision.go |
| T10 Forged or wrong-audience token at the gateway |
A4 |
C10 OIDC JWT verification: issuer, audience, exp, nbf, RS256/ES256/EdDSA only; JWKS refetch on unknown kid, throttled to once per 30s per issuer; trusted_header only from CIDR-pinned peers, fail closed without CIDRs |
internal/identity/oidc.go, cmd/halo-proxy |
| T11 Access to unallowed models, batch API or unknown endpoints; oversize bodies |
A4 |
C11 Fail-closed allowlist: exact model paths, 404 for other paths under model prefixes, 400 for batches and non-alias models, 413 for oversize, no forward on rewrite failure |
internal/gateway/prepare.go (admit), rewrite.go |
| T12 Enrollment token stolen or replayed |
A5 |
C12 32 random bytes, stored as SHA-256, single-use (burned on first presentation), 15-minute default TTL; device token shown once, stored as SHA-256, revocable; failed-auth rate limiting per IP |
internal/server/portal.go, devices.go |
| T13 Scripts or portal config used for injection |
A6 |
C13 Portal config validated as shell-safe; halod checksums pinned per OS and arch; enrollment refuses to install without a pinned sum |
portal.go (Validate), scripts.go |
| T14 Dangerous permission modes shipped |
A6 |
C14a Guardrails: no bypassPermissions, disableBypass required on every ring. C14b One case-insensitive check (policy.BypassToken) for bypass modes and CLI flags (--dangerously-skip-permissions, --yolo, …): validate scans every policy string (hooks, status line, MCP commands), the release backstop every byte of every rendered file, parseable or not |
internal/policy/guardrails.go, internal/release/check.go |
| T15 Overrides smuggle security-relevant keys after rendering |
A6 |
C15 Per-harness overrides ALLOWLIST (only cosmetic/behavioural keys pass; everything else, including new security knobs, is rejected); harnesses with no allowlist accept no overrides; reserved env prefixes |
guardrails.go |
| T16 Literal secrets baked into world-readable bundles |
A6 |
C16 Secret detector on MCP headers and env; only ${VAR} references pass |
guardrails.go (looksSecret) |
| T17 Malicious names inject into scripts, paths, headers |
A6 |
Strict name regex on every identifier |
internal/policy/names.go |
| T18 Agent uses MCP to publish, promote or merge |
A8 |
No such tool exists; write tools need --allow-writes, default dry_run, require a reason, and only commit to a local branch or open a PR; promotion needs a promote verdict |
internal/mcpserver |
| T19 Shadow token aims the shadow mirror at an arbitrary URL |
A9 |
Jobs carry only experiment and variant; halo-shadow resolves upstreams from its own policy; client credentials never forwarded; pairs optionally AES-256-GCM encrypted; budget and retention |
internal/shadow, cmd/halo-shadow |
| T20 Insider merges a malicious change |
A6 |
Not prevented by Halos: the merge gate is yours. Mitigated by review, halo plan diffs, no auto-merge, guardrails as a floor |
process |
| T21 Signing key stolen |
A7 |
Partially: key held in CI or KMS; path allowlist and ownership limit the blast radius; rotation procedure. No threshold signing or revocation list |
production deployment |
| T22 Forged kill list (disable or un-kill experiments) |
A10 |
Ed25519 signature over a domain-separated payload, verified with a public key pinned in the gateway config. The kill key is separate from the release key, so a stolen release key cannot forge kills and a stolen kill key cannot sign releases |
internal/gateway/killswitch.go (VerifyKillList), internal/server/killswitch.go |
| T23 Stale or replayed kill list hides a kill |
A10 |
issuedAt more than 10 minutes old or more than 1 minute in the future is rejected; a list is accepted only if issuedAt is strictly newer than the current one, so an older envelope cannot undo a kill |
killswitch.go (Refresh) |
| T24 Control plane down or blocked, kill lapses |
A10 |
A failed fetch or verification keeps the last accepted list; kills never auto-expire. Residual: a gateway that has never fetched a valid list kills nothing, so a kill issued while halo-server is unreachable takes effect only once the gateway can fetch |
killswitch.go |
| T25 Anyone reads or probes the gateway kill endpoint |
A11 |
Bearer gateway token (32+ characters and 128+ bits of estimated entropy, enforced at startup; constant-time compare), 404 when unconfigured, failed attempts rate-limited per IP. Kill and unkill endpoints are admin-only, same-origin and audited |
internal/server/killswitch.go, auth.go |
| T26 Audit history rewritten or erased |
A12 |
Hash-chained entries (seq, prev, hash); GET /api/v1/audit re-verifies the whole chain and reports verified. Residual: removing the newest entries is undetectable from the log alone, and a writer who can recompute hashes can forge a whole chain; pin head externally |
internal/server/audit.go (VerifyAuditChain) |
| T27 Forged controller webhook; client credentials reaching Bedrock |
A4, A10 |
Generic webhook signed X-Halo-Signature: sha256=<HMAC-SHA256(secret, X-Halo-Timestamp + "." + body)> under a shared secret; receivers reject timestamps more than 5 minutes off (replay) (Slack uses a secret URL; URLs are read from files, never logged). Bedrock requests are SigV4-signed with the gateway’s own identity; client Authorization, x-api-key and every x-amz*/x-amzn* header are always dropped, and signing happens only for AWS endpoint hosts (*.amazonaws.com, *.amazonaws.com.cn, *.api.aws) or halo-proxy signHosts; no credentials gives 502, never an unsigned forward |
internal/controller/notify.go, internal/gateway/upstreamauth/bedrock.go |
T28 Registry serves an old signed pointer and the signer re-signs it, laundering the replay into a fresh seq and expiry |
A1 |
refresh, promote, publish and rollback consult the signer state file ($XDG_STATE_HOME/halos/pointers.json, --state-file) and refuse a served pointer with a lower seq, the same seq with another digest, or none where one was written. New seq = max(served + 1, recorded + 1, unix now). promote takes its source from --from-ring’s signed pointer (signature, ring, org, expiry, state), never the unauthenticated ring-<name> tag. refresh refuses an expired pointer; recover with halo rollback --to <version>. rollback --to <version> requires the signed manifest to carry that version (a retagged v tag is refused); --to sha256: is content-addressed. --expect-digest is the stateless check. Residual: a stateless CI runner has no state file, so the replay check is off unless the file is cached or --expect-digest is passed; the signer prints Signing ring X → version V (digest D, seq S) to stderr before every signature |
internal/bundle/state.go, pointer.go (preflight, currentPointer, resolveSource), cmd/halo/cmd_release.go |
| T29 Forged experiment evidence auto-kills an experiment, or hides a regression |
A13 |
Two collector receivers. The CLI receiver (4317/4318) drops every halo.gateway.* metric, overwrites halo.source=cli, caps requests at 4 MiB and can require per-device bearer tokens (halo telemetry collector-config --cli-token-file). Only the gateway receiver (4319, bearer token HALO_OTLP_GATEWAY_TOKEN, collector will not start without it) stamps halo.source=gateway. Analysis counts gateway rows only with that stamp, and the controller auto-kills only on gateway-sourced evidence; CLI-sourced rollbacks open the pause PR and notify. halo.unit is an HMAC of the verified subject, never a client id. Residual: forged CLI cost can expire an experiment early (a conclude PR, never a kill); the gateway token is a shared secret between halo-proxy replicas and the collector |
internal/telemetry/collector.go, internal/promote/source.go, internal/controller/controller.go, internal/telemetry/gwmetrics |
| T30 A privileged action happens with no audit record |
A12 |
Kill, unkill, device enrollment, session and device revocation and experiment-status PRs append with fsync before the response and fail (HTTP 500) if the append fails. A kill whose audit fails stays applied (fail safe); a failed unkill is reverted; a revocation stays in force. halo controller run appends through the same chain (single writer: never against a live server’s data dir) |
internal/server/audit.go (AuditStrict), killswitch.go |
| T31 Kill switch silently inert (no key, bad URL, cleartext token) |
A10, A11 |
The server serves no kill list and answers 501 to kill/unkill without --killswitch-key-file, which itself requires --data-dir; GET /api/v1/capabilities reports killSwitch only when a key is set; the in-process controller then reports rollbacks as not enforced (“merge urgently”) instead of tripping an unread switch. Gateways refuse plain http:// to a non-loopback kill URL unless killSwitch.allowInsecureInCluster (halo-kong: killswitch_allow_insecure_in_cluster) is set. A misconfigured halo-kong logs at ERROR and tags upstream requests x-halo-killswitch: misconfigured, while still serving traffic. Kill requires a reason (422 without one) |
cmd/halo-server/main.go, internal/server/killswitch.go, capabilities.go, internal/gateway/killswitch.go, cmd/halo-kong/main.go |
| T32 A client-axis variant profile weakens the ring (extra hooks, another telemetry endpoint, wider models, a more permissive mode) and ships to a slice of the fleet |
A6 |
Variant profiles are validated against the ring profile as errors: hooks subset, telemetry.otlpEndpoint and logPrompts equal, models.allowed subset, permissions.mode not more permissive, instructions and env equal, plus the version-pin, MCP, deny-list and sandbox rules (list) |
internal/policy/guardrails.go (variantProfileIssues) |
T33 Root stops halod, edits managed config or runs an unpinned CLI and keeps model access |
A14 |
Per-ring posture and versionGate at the gateway (ADR-0011). posture: enforce refuses (403) callers whose enrolled devices have not reported within --posture-max-age, reported drift, or run another release; it fails closed only on a server verdict (grace window, then unknown passes). versionGate: enforce refuses a CLI version the ring does not pin and an unknown User-Agent. Optional immutable: true flags managed files. Residual: posture is halod’s self-report (root can forge one with the device token); it is per user, not per request; the User-Agent is client-controlled |
internal/server/posture.go, internal/gateway/enforce.go, cmd/halo-proxy/proxy.go (gate), cmd/halo-kong/main.go |