Skip to content

Security model

The full asset list, attacker models and per-control mapping are in the threat model. This page is the design.

Untrusted clients and networks; the gateway boundary that verifies OIDC JWTs, strips x-halo headers and fails closed on the model allowlist; a supply chain where a CI or HSM key signs releases into an OCI registry; halod as root verifying pointer and release before writing managed config; and halo-server issuing device tokens and a kill list signed with a separate key. Untrusted clients and networks; the gateway boundary that verifies OIDC JWTs, strips x-halo headers and fails closed on the model allowlist; a supply chain where a CI or HSM key signs releases into an OCI registry; halod as root verifying pointer and release before writing managed config; and halo-server issuing device tokens and a kill list signed with a separate key.
Boundary Threat Control
Registry to halod Tampered release; replay of an older signed release; frozen registry; release built for another org or ring Signed ring pointer {org, ring, digest, seq, expiry}; release signature; (ADR-0005, ADR-0008)
Vendor download to halod Compromised or swapped CLI binary; install script run as root Hash-pinned artifacts from the signed manifest; no curl | bash; npm --ignore-scripts
Manifest to root filesystem Signing key alone writing arbitrary files as root Per-harness path allowlist, 0644 mode ceiling, root-ownership and symlink checks
Client to gateway Client claims a ring, variant or identity; unknown or batch model endpoints; other models OIDC JWT verified at the gateway; every inbound x-halo-* stripped; fail-closed model allowlist
Policy to harness Dangerous permission modes, non-allowlisted overrides, secrets in bundles Go guardrails at validate and again at release, plus a rendered-output backstop
Workspace to internet Exfiltration by agent Egress firewall to egress.allowedDomains plus the harness sandbox
Laptop enrollment Stolen or replayed enrollment token Single-use, short TTL, stored only as SHA-256; device token stored only as SHA-256 and revocable
Shadow store Prompt leakage; shadow token aiming replays at arbitrary URLs Upstreams resolved from policy; AES-256-GCM at rest; retention; budget
Signer to registry A registry serving an old signed pointer that refresh or promote would re-sign into a fresh one Signer state file, promote from the source ring’s signed pointer, expired pointers refused (below)
Developer machines to the collector Forged metrics that trip, promote or hide an experiment Up to three receivers (CLI, gateway, optional eval); only the authenticated gateway receiver may drive a kill (below)
halo-server to gateway Forged, stale or replayed kill list; a stolen release key forging kills; an attacker on the path un-killing an experiment Kill list signed with a dedicated ed25519 key; gateway token; freshness and strictly-newer checks (below)
Console and API to privileged actions One over-broad admin group; a new route reachable by everyone; silent or rewritten history of who killed, revoked or changed what Five roles from IdP groups, one deny-by-default route table, audited denials (below); hash-chained audit log (below)
IdP to halo-server (SCIM) A departed user keeping sessions, devices or cohort; a session or leaked gateway token provisioning users; groups injected by a client Dedicated SCIM token stored as SHA-256; deprovisioning revokes at once; directory groups keyed by the verified subject (below)
halo-proxy to Bedrock Client credentials reaching AWS; unsigned requests Client auth stripped, request SigV4-signed with the gateway’s own AWS identity; no credentials means 502, never unsigned (below)
CI / cloud workload to gateway A long-lived provider key in CI; another repository or branch posing as a trusted pipeline; a replayed or forged CI token Federated OIDC token exchanged at halo-server for a 15-minute (at most 1 hour) credential signed with a dedicated ed25519 key; per-issuer claim rules; jti replay cache (below)

Releases are OCI artifacts signed with ed25519. The primary signer must be ed25519 because halod verifies only ed25519 and never shells out to cosign as root. cosign (key, KMS URI or keyless) can be added as a co-signature (--cosign-key, --cosign-keyless); keyless uploads the release digest to the public Rekor transparency log.

Protect the signing key: anyone who can sign can ship hooks to every developer. Use a CI-only or KMS-backed key, and require PR review on ring changes. See production deployment for rotation.

A ring tag alone is unauthenticated: whoever controls the registry could retag it to an older signed release or one built for another ring. So each ring has a separately signed pointer with org, ring, digest, seq, issuedAt and expiresAt. halod trusts the pointer, checks the fields against its config, refuses a lower seq than it last applied, refuses an expired pointer (7-day TTL), and only then fetches the release the pointer names by content digest, never by a mutable tag.

Consequences: a compromised registry cannot roll a ring back to an older signed release, cannot serve a release for another org or ring, and cannot freeze clients indefinitely without them noticing (they stop updating and warn after 7 days). Rollback is a new pointer with a higher seq. Pointers must be refreshed on a schedule (halo release refresh, daily) or every client stops updating after seven days; halod warns when fewer than 24 hours remain.

This is not full TUF: there is no root key rotation protocol, threshold signing or delegated roles. A key compromise is handled by rotating the key in every halod config (see production deployment).

halod checks pointers it reads. The signer needs the same discipline, because a registry can serve an old but validly signed pointer, and a signer that refreshes “what the registry serves” would re-issue it with a higher seq and a fresh expiry. The release commands close that:

  • Promote follows the source ring’s signed pointer. halo release promote --from-ring A --to-ring B reads A’s signed pointer (signature, ring, org, expiry, continuity), fetches the release it names and checks its digest against the pointer. The ring-A tag, which is unauthenticated, is never used as the source.
  • Rollback needs a matching version. halo rollback --to <version> resolves tag v<version> and refuses the release unless its signed manifest carries that version, so a retagged v tag cannot redirect it. --to sha256:<manifest digest> is the OCI manifest digest and is content-addressed.
  • Refresh refuses expired pointers. An expired pointer cannot be told apart from a replayed one. Recover explicitly with halo rollback --to <version>.
  • Signer state file. The last pointer written per registry repo and ring (seq, digest, issuedAt) is kept in $XDG_STATE_HOME/halos/pointers.json (--state-file). A served pointer with a lower seq, the same seq and another digest, or no pointer where one was written is refused. New seq = max(served + 1, recorded + 1, unix now).
  • --expect-digest sha256:<release digest> on refresh, promote and rollback refuses unless the release involved is exactly that one: the check that works without state.
  • Every signature is announced. Before signing, the CLI prints Signing ring X → version V (digest D, seq S) to stderr.

Limit: stateless CI has no state file, and a signer with no record for a ring refuses to re-sign a pointer the registry already serves unless you confirm it with --expect-digest (the served release digest) or --adopt-existing (use it only on a first run or after a cache eviction: it trusts what the registry serves, so that run has no replay protection). Cache pointers.json between runs (production deployment). The file has one writer; an unparseable file blocks signing rather than disabling the check.

Experiments decide rollouts, so telemetry is an attack surface. Developer machines must reach the collector, so the CLI receiver is client-controlled. The collector (halo telemetry collector-config) therefore has two receivers by default, plus an optional third: the CLI receiver (4317/4318) drops every halo.gateway.* metric, stamps halo.source=cli, caps requests at 4 MiB and can require per-device bearer tokens (--cli-token-file); the gateway receiver (4319) requires HALO_OTLP_GATEWAY_TOKEN, which halo-proxy presents (--telemetry-token-file), and stamps halo.source=gateway. The optional eval receiver (--eval-receiver, 4320) accepts only halo.eval.* metrics, stamps halo.source=eval and requires its own token, HALO_OTLP_EVAL_TOKEN (never the gateway token), so halo eval online can publish without gateway credentials; nothing auto-kills on eval-sourced rows. Analysis trusts gateway rows only with that stamp, and the controller auto-kills only on gateway-sourced evidence; a CLI-sourced rollback still opens the pause PR and notifies. Units are HMAC(salt, verified subject), so a client cannot choose or inflate its own unit. Details: evidence plane.

The kill switch lets halo-server (the controller on a rollback verdict, or an operator) switch an experiment off at the gateways in seconds. Because it can change live routing without a merge, it has its own trust chain:

  • Separate key. The kill list is signed with its own ed25519 key (halo keys generate --name killswitch, private half in halo-server --killswitch-key-file). It is not the release key. A stolen kill key can kill experiments (routing everyone to control) but cannot sign a release or a ring pointer; a stolen release key cannot forge a kill list. The signature covers a domain prefix (halo-killswitch-v1\n) plus the payload, so it cannot be confused with any other signature.
  • Gateway token. Gateways fetch the list with a bearer token (--gateway-token-file, or a dedicated --gateway-killswitch-token-file), compared in constant time, with failed attempts rate-limited per client IP. The endpoint returns 404 unless both the key and the token are configured. The token gates who can read the list, the signature gates what a gateway believes. halo-server refuses to start with any gateway token (shared or per capability) shorter than 32 characters or under 128 bits of estimated entropy (length times log2 of its distinct characters); generate one with openssl rand -hex 32.
  • Freshness. A list is rejected if its issuedAt is more than 10 minutes old or more than 1 minute in the future.
  • Replay. A list is accepted only if its issuedAt is strictly newer than the list the gateway already holds, so replaying an older envelope cannot undo a kill.
  • Fail toward keeping kills. If the fetch or verification fails, the gateway keeps the last accepted list. If it never accepted one, it kills nothing and the policy snapshot decides. Kills do not expire.
  • Operator actions are audited, fail-closed. Kill and unkill (by an operator or admin, or by the controller as halo-controller) write experiment.kill and experiment.unkill (toggle.kill and toggle.unkill for feature toggles) audit entries with the reason (audit log). A kill needs a reason (HTTP 422 without one).
  • No key, no kill. Without --killswitch-key-file (which requires --data-dir and --gateway-token-file) the server serves no list, answers 501 to kill and unkill, and GET /api/v1/capabilities reports killSwitch: false. The in-process controller then does not pretend: it reports the rollback as not enforced and asks humans to merge the pause PR urgently.
  • The controller holds killed experiments. A running experiment that is already killed is not evaluated (its evidence is all control); humans are told once to unkill before resuming.
  • TLS for the token. Gateways refuse plain http:// to a non-loopback kill URL, since the bearer token would travel in the clear, unless killSwitch.allowInsecureInCluster (halo-kong: killswitch_allow_insecure_in_cluster) is set for a trusted in-cluster URL. A misconfigured halo-kong still serves traffic but logs at ERROR and adds x-halo-killswitch: misconfigured to the upstream request.
  • Notification webhooks are signed and timestamped. The controller’s generic webhook carries X-Halo-Timestamp and X-Halo-Signature: sha256=<hex>, an HMAC-SHA256 over timestamp + "." + body under a shared secret. Receivers must verify it and reject timestamps more than 5 minutes off, which stops replay of a captured request (snippets). Delivery is retried per channel on later ticks. Slack uses its own incoming-webhook URL, which is itself a secret.

Limits: a kill affects gateways (routing and mirroring). It reaches client-axis variants on machines only where halod has killSwitch configured (portal enrollment sets it when halo-server has a kill key); otherwise those revert on pause and republish (details). If halo-server is down when a kill is issued, nothing is killed until it is back. Decision record: ADR-0009.

halo-server grants authority by role, not by one admin flag (ADR-0015). Policy maps IdP groups to roles in identity.roles; identity.adminGroups still grants admin.

Role May
viewer Read fleet, devices, releases, rollouts, experiments, toggles, the kill list and policy
operator Viewer, plus kill and unkill experiments and toggles, and open experiment status PRs
approver Viewer, plus approve and deny access requests
auditor Read and export the audit log, nothing else
admin Everything, including device, session and credential revocation and toggle proposals
  • One table, deny by default. Every cookie-authenticated route is in routePerms (internal/server/rbac.go) with the one permission it needs. A route registered without an entry answers 403 to everyone, admins included, and a test walking the server’s routes fails until the entry is added.
  • Roles come from current policy. They are recomputed from the session’s groups on every request, never read from the cookie, so removing a group from a role takes effect at once. A misspelled role name in policy is a load error.
  • Denials are audited. A 403 for a missing permission appends authz.deny with the user, path, route, needed permission and the user’s roles.
  • The console only hides. /api/v1/me and /api/v1/capabilities report the caller’s roles and permissions so the console can hide what it may not do; the server enforces either way.

Privileged actions (login, logout, authorization denials, device enroll and revoke, session and credential revoke, access-request decisions, experiment status PRs, kill and unkill) append to audit.jsonl in --data-dir. Each entry carries seq, prev (the previous entry’s hash) and its own hash over every other field. With rotation on, full segments become audit-<seq>.jsonl and the chain runs on across them: a segment’s first prev is the previous segment’s last hash. GET /api/v1/audit verifies the whole chain, every segment, and returns verified plus a head hash; halo audit verify does the same offline. Editing, reordering or removing any entry except the newest breaks the chain from that point on, and the console shows it.

Appends are fsynced before the action is acknowledged, and privileged actions fail closed: kill and unkill, device enrollment, session, device and credential revocation, access-request decisions and experiment-status PRs return HTTP 500 if the append fails. A kill whose audit append fails stays applied (fail safe), a failed unkill is reverted, a revocation stays in force, and an access-request decision stays recorded (one whose save fails is a 500 and stays pending); the audit gap is what the error reports. halo controller run appends through the same chain, so it is a single writer: never run it against a data dir a live halo-server is writing.

Limit: tail truncation is undetectable from the log alone. Dropping the newest entries leaves a chain that still verifies. SIEM export is the external copy that closes this: every entry, with seq, prev and hash, leaves the host at least once and in order, so the SIEM holds entries a truncated log lacks. The forwarder ships only entries that continue the chain it already shipped (same seq + 1, matching prev and hash) and halts on the first that does not, so an edit made on disk after an entry was exported is never exported as if it were the original. Export never delays or fails an action; credentials are environment or file references, never in the config or logs (ADR-0016). The log is also only as trustworthy as the host’s file permissions: someone who can rewrite the file can recompute every hash. Without --data-dir the log lives in memory and is lost on restart.

halo-server is a SCIM 2.0 service provider at /scim/v2 for Okta and Entra ID (ADR-0022, setup).

  • Own credential. The IdP authenticates with a dedicated bearer token. The server holds only its SHA-256 (--scim-token-sha256-file; two hashes during a rotation), compares in constant time against each, and rate-limits failures per client IP in a budget of its own. The token is checked before the budget, so a valid token is accepted even from a blocked IP (an IdP behind a NAT a guesser shares is not locked out). Session cookies, the gateway token and the fleet token are never accepted on /scim/v2.
  • Failed-auth budgets are per endpoint class, per address, shared across replicas. An IPv6 client is counted by its /64, which one host usually controls, so rotating addresses inside it buys no fresh budget. With the Postgres backend every failure is also counted in the database, so N replicas grant one budget, not N (ADR-0021); a database error leaves each replica’s own budget in force. Token exchange, SCIM, gateway tokens, device tokens and enrollment, and OIDC callbacks each have their own per-IP budget, so garbage sent to a public endpoint from an IP a gateway shares cannot lock the gateway out of its directory, posture, budget or kill-list endpoints. A valid gateway token is accepted even from a blocked IP; gateway tokens have at least 32 characters and 128 bits of estimated entropy (enforced at startup), so the oracle this gives a blocked guesser does not make guessing feasible.
  • Deprovisioning is immediate. Deactivating, deleting or renaming a user bumps their session epoch (every console session dies), revokes every device token bound to them and drops their unconsumed enrollment tokens before the change is stored. A user id is compared and keyed in one canonical form everywhere (lowercased, surrounding space trimmed: session epochs, devices, enrollments, posture, directory lookups, access-request self-approval, the whoami self check and per-user budget counters), so an IdP that provisions Alice@Corp.com still ends the sessions of alice@corp.com, and cannot approve its own request, or get a fresh budget, under another case. Only real case pairs fold: a lookalike such as the Kelvin sign (\u212A) or a dotted capital I stays a different user, where strings.ToLower would have made \u212Aelly share kelly’s sessions and budget. A deactivated user is refused at login, on console requests and on device endpoints.
  • Authoritative groups never come from the client. With --scim-authoritative, users the directory does not list as active are refused, and admin rights, ring resolution and toggle targeting use the directory’s groups. Gateways with directory.url replace a verified caller’s groups with the directory’s and refuse (403, in the caller’s protocol) a verified caller the directory does not list as active, rather than demoting them to an anonymous caller who would skip the posture gate and per-user budgets; the lookup key is the verified subject. Workload credentials are not in SCIM and skip the lookup.
  • An outage never makes a caller anonymous. When the directory cannot be asked past grace (or its config is invalid), directory.failMode: open (the default; directory_fail_mode on halo-kong) keeps the verified subject with no groups: default routing and no experiments, with the posture gate and per-user budgets still applied to that subject. A delegated agent keeps its delegation chain on that path too, so the agent policy (deniedTools, chain models, maxDepth) still applies; it is never treated as its human root. closed answers 503 in the caller’s protocol. Fail open is for an outage only: a transport error or a 5xx. Any other answer from halo-server that is not 200 (a subject it will not look up, a rejected token) refuses the caller as not provisioned, and a caller whose last cached entry was inactive stays refused however long the outage lasts. halo-server answers an oversized subject (over 256 characters) as inactive, not with an error.
  • workload: is reserved. A SCIM userName, an OIDC user claim or a trusted identity header starting with workload: (any case) is refused, so only a verified workload credential can carry a workload subject.
  • Every mutation is audited with actor scim, fail-closed like other privileged actions.

Limit: the gateway caches directory entries (cacheTTL, default 1m; grace 5m while halo-server is unreachable), so a deprovisioned user keeps their cohort at a gateway that long. With halo-server unreachable past grace and nothing cached, a deprovisioned user whose IdP token is still valid gets default routing under their own identity (fail open), not a refusal; set failMode: closed to refuse instead. Whether their IdP token is still accepted at the gateway is the IdP’s decision.

For kind: bedrock upstreams halo-proxy signs with SigV4 using its own AWS identity (the AWS SDK default chain: env, SSO profile, EKS IRSA or Pod Identity, ECS task role, EC2 instance profile). The caller’s Authorization, x-api-key and every client x-amz* / x-amzn* header are always dropped, even with forwardAuth: true, so only gateway-set headers enter the signature. Signing happens only for AWS endpoint hosts (suffix .amazonaws.com, .amazonaws.com.cn or .api.aws) or hosts listed in signHosts; a kind: bedrock upstream with a region on any other host gets a 502 bad_upstream rather than the gateway’s signature. No credentials means a 502 upstream_auth_error; nothing is forwarded unsigned. The role should grant only bedrock:InvokeModel and bedrock:InvokeModelWithResponseStream on the models in policy. Setup: stack-agnostic.

The release manifest carries, per harness, OS and architecture, an https URL with an exact size and a sha256 (binary) or sha512 integrity (npm tarball). halod refuses artifacts that do not match. The old install path (installCommand: curl | bash, npm install -g running lifecycle scripts as root) is disabled unless allowShellInstall: true, which logs a warning on every use. An artifact cannot replace halod itself.

Two per-ring policies enforce in the traffic path, which developers do not control (ADR-0011; details in delivery):

  • posture. The gateway asks halo-server (GET /api/v1/gateway/posture, gateway token or --gateway-posture-token-file, rate-limited) whether every enrolled device of the verified caller reported recently, without drift, on its ring’s release. A device whose release halo-server cannot check (registry unreachable with no verified pointer cached), or a device registry it cannot read, makes the verdict unknown (not compliant), never current; with several replicas the check first catches up with the shared registry, so a device enrolled on another replica counts. Reports are authenticated with the device token halod already holds; no new credential or crypto. It fails closed on a verdict the server returned and on a lookup the server refused (any non-200 below 500: a wrong token, posture not configured): an unreachable server (transport error or 5xx) leaves the last verdict in force for a grace window, then the request passes and is counted as unknown. An anonymous caller is unknown too. A gateway whose posture config is set but invalid never turns the gate off: halo-proxy refuses to start, and halo-kong refuses every caller on enforce rings and flags posture=unknown on warn rings.
  • versionGate. The gateway parses the CLI version from the User-Agent and refuses one the ring does not pin. An unknown or missing User-Agent follows the same mode.

Both default to warn (log and count). In enforce they answer 403 with the reason and the fix (halod status). Neither reads a client x-halo-* header; halo-kong’s x-halo-gate and x-halo-killswitch upstream tags are in the always-cleared set, so a client copy is removed even when Kong’s header view did not include it.

halod runs as root and applies configuration delivered over the network, so it treats the signing key as necessary but not sufficient:

  • Path allowlist. Files may be written only under each harness’s admin config directories (see delivery). .., relative paths, backslashes on unix and alternate-stream characters on Windows are refused. Stale-file removal uses the same allowlist.
  • Mode ceiling. A manifest entry with a mode above 0644 is refused.
  • Ownership preflight. Before it starts, halod verifies the config, public key, device-token file and state file, and every ancestor directory, are owned by root (or SYSTEM/Administrators on Windows), not writable by group or other, and contain no symlink planted by a non-root user. The device-token file must be mode 0600. On Windows only the owner SID is checked, not the DACL, so harden the directory.
  • Atomic apply. Temp file, fsync, rename. On any failure before verification nothing on disk changes, and the last good release stays.
  • Bounds. Release size (default 256 MiB), artifact size and https-only redirects are capped. A failed ring lookup falls back to the last known ring, never to “no ring”.

The gateway verifies the caller’s OIDC JWT against the IdP’s discovery document and JWKS (cached, refetched on an unknown kid at most once per 30 seconds per issuer): iss, aud, exp, nbf, and an algorithm limited to RS256, ES256 or EdDSA (none and HS* are rejected). The ring, experiment and variant are derived from the verified user and groups. Failed verification returns 401 unless allowAnonymous is set, in which case the caller gets default routing and ring unknown, never an experiment.

trusted_header mode exists for gateways that authenticate for you. It honors the identity headers only when the TCP peer is inside trustedProxyCIDRs; with no CIDRs it fails closed with 503. Never use it without the CIDR pin, and only if the gateway overwrites the header and clients cannot reach halo-proxy directly.

Headless agents (GitHub Actions, GitLab CI, Buildkite, CircleCI, cloud workloads) get no provider API key. halo ci token sends the job’s own OIDC token to halo-server POST /api/v1/token/exchange (RFC 8693) and receives a gateway credential (ADR-0023, guide):

  • Subject token verified like an IdP token. The issuer must be listed in policy workloads.issuers; discovery and JWKS (cached, refetched when no cached key verifies), iss, aud, exp, nbf, iat not in the future, and the RS256/ES256/EdDSA allowlist (none and HS* are rejected).
  • Rules pin the tenant. A rule of that issuer must match every listed claim (exact or glob; * never crosses /). Public issuers sign tokens for every tenant, with any aud a job asks for, so each rule must also match one of the issuer’s tenant claims exactly, or as literal/*: GitHub repository_owner_id, repository_owner, repository or repository_id; GitLab.com namespace_id, namespace_path, project_id or project_path; Buildkite organization_id or organization_slug; CircleCI oidc.circleci.com/org-id; Google email or sub; Entra ID tid. ref: refs/heads/main alone, or repository_owner: acme*, fails halo validate. Any other issuer must name its tenantClaim. For a known issuer tenantClaim may only narrow the choice to one of these claims; any other value (ref, environment) fails halo validate and is ignored at runtime, so it can never make ref: refs/heads/main a tenant pin. GitHub, GitLab.com and Buildkite rules that pin only renamable names (no repository_owner_id/repository_id, namespace_id/project_id, organization_id), and GitHub rules with no environment or job_workflow_ref (so pull_request_target runs on the base branch match), validate with a warning. The subject must start with workload:, so a workload never takes a human’s id.
  • Enforced at runtime, not only by halo validate. halo-server and both gateways run the security validation (workloads, units, delegation) on every policy load and refuse to activate a policy with an error there, keeping the last good one with an error log and a counter (halo_server_policy_refused_total, halo_proxy_policy_refused_total). A refused policy still removes workload rules on halo-server: the rules in effect become the last good policy’s rules that the refused one still has unchanged, so an unrelated error cannot block the emergency removal of a compromised rule. Additions and changes are not applied; each such load is logged and counted (halo_server_policy_tightened_total), and the in-process controller sees the same policy. The exchange itself never matches a rule that pins no tenant (halo_server_workload_rules_skipped, a gauge set per load, counts any in effect). halo release build and publish refuse any invalid policy.
  • Single use. Each subject token is exchanged once while valid (by iss + jti, or by its hash when the issuer sends no jti).
  • Rate limited without a shared-runner lockout. Hosted CI runners and NATs put many tenants behind one IP, and any of them can mint a signed token for its own repository. So the exchange’s per-IP budget counts only subject tokens that are malformed, name an unlisted issuer or fail the signature check; a signed token that matches no rule, is replayed, expired or for another audience costs nothing. A signed token is accepted even from a blocked IP, verified from cached discovery and keys only (a blocked IP never makes halo-server call the issuer); anything else from a blocked IP is 429.
  • Short-lived, dedicated key. The credential is an EdDSA JWT (typ: halo-workload+jwt, kind: workload) signed with --workload-key-file, which is neither the release nor the kill-switch key. Default lifetime 15 minutes, never more than 1 hour; gateways reject it at exp with no leeway and refuse any lifetime over an hour.
  • Same gateway rules as humans. Every inbound x-halo-* is still stripped and the ring comes from the credential’s groups. Workloads get control routing and no shadow unless policy sets workloads.experiments: true.
  • Audited. Every exchange appends workload.token.exchange (fail closed) with the rule, issuer and matched claims, never a token. A verified token that no rule matches, or a replay, appends workload.token.denied. Unverifiable input is logged, not audited.

Limit: the replay cache is per halo-server process, so with several replicas a token can be exchanged once per replica within its lifetime.

A subagent, background agent or A2A caller gets its own short-lived credential, minted from its parent’s at POST /api/v1/token/exchange with an actor_id (ADR-0024, guide):

  • The subject stays the root principal. The credential’s sub and groups are the human’s or workload’s. Ring, toggles, posture and budgets are computed for the root, an agent of a deprovisioned user is refused at the gateway like the user, and delegated callers get no experiment unless agents.experiments (then exactly the root’s arm).
  • RFC 8693 act chain. The current actor is on top, prior actors nested. EdDSA with the workload key, typ: halo-agent+jwt. Unknown claims, a chain longer than five, a repeated agent (a cycle) or any edit under the signature are refused by the one verifier halo-server and both gateways share.
  • Scope only narrows. The agent must be in agents.actors, the chain within the depth limit (default 2, lowered by profile and unit), and models within the parent’s scope, allowedModels and the actor’s models. An empty intersection is refused, never widened. A group the parent does not hold is refused, and narrowing groups is refused because it could move the agent’s ring or budget. Lifetime is at most maxTTLSeconds and the parent’s remaining lifetime.
  • Dead roots kill the chain. A revoked session or a SCIM-deactivated root refuses the next exchange anywhere in the chain, and the next egress decision (the signed auth_time is compared with the session revocation epoch). Revoking the human’s id as a credential subject also reaches gateways (Credential revocation).
  • Re-checked per call. The gateway refuses a chain any of whose agents was removed from policy or no longer admits the root’s groups, a chain over the current maxDepth or the root’s profile and unit maxDepth, a model outside the credential scope, allowedModels or the models of any actor in the chain, or a deniedTools tool. Tools are read from an explicit list of request shapes (Anthropic tools[] and mcp_servers[], OpenAI Responses tools[], Gemini functionDeclarations and built-in tool keys, Bedrock Converse toolConfig.tools[] and additionalModelRequestFields), keys matched ignoring case and underscores and across duplicate keys. A hosted tool counts by its type (web_search_20250305, codeExecution, mcp:<server>) and a deny entry matches it as a prefix. With deniedTools set, a body whose tools cannot be read (too deep, malformed, an unknown shape) is refused. x-halo-actor and x-halo-actor-depth are gateway-owned like every x-halo-*.
  • Audited. agent.token.exchange and agent.egress.allow are fail closed: no credential and no allow without an audit record. agent.token.denied and agent.egress.deny (refusals, including a revoked or deprovisioned root and an unresolvable policy) record the chain, the root and the jti, best-effort; a failed append increments halo_server_audit_append_errors_total. Never a token. actor_harness and actor_session are self-asserted labels limited to [A-Za-z0-9._:-] (64 and 256 bytes), not controls.
  • Rate limited apart. Delegation and egress have their own failed-authentication budget per client IP, separate from CI token exchange: a delegation request is dispatched before the CI budget is consulted, so CI failures never block it. Only bad, forged or unverifiable credentials spend it; policy refusals of an authenticated caller, and a parent or agent credential this server signed that has expired or whose root is gone, do not. At the exchange a parent this server signed passes even from a blocked IP.

Remote A2A agents are an allowlist (agents.remote) with a pinned AgentCard digest, an optional signing key or JWKS, skills, and the rings and units that may call each. halo agents verify fetches cards over https only, with no off-host or downgrade redirects, a size cap and a timeout, and refuses non-public destinations (checked on the dialed address). It never follows a card’s jku. IPv6 transition ranges that embed an IPv4 address (NAT64, 6to4, Teredo, IPv4-compatible) are refused outright. Card digests reject invalid UTF-8 and lone surrogates, the card’s fields are read by exact key, and text printed from a card has control and format characters escaped. Ring releases carry the pinned digests; POST /api/v1/agents/egress answers allow or deny for a verified Halos credential, audited.

Limits: a gateway without directory.url has no revocation list, so there a revoked root’s agent keeps calling models until the credential expires (at most maxTTLSeconds) or SCIM deprovisioning reaches the gateway’s directory. The egress API is advisory until a proxy enforces it.

An admin revokes a workload or delegated agent credential before exp without rotating the workload key (ADR-0026): halo agents revoke --jti J kills one credential and every credential exchanged from it; --subject agent:<name>, workload:<subject> or a human id kills every credential issued so far for that subject, anywhere in a delegation chain (a human id also ends their sessions). admin only, a reason required, audited fail closed as credential.revoke.

  • halo-server refuses at once, on every replica. Every delegation exchange and egress decision checks the shared store with one indexed query; a store error refuses (503), never passes.
  • Gateways within 30 seconds. halo-proxy and halo-kong with directory.url fetch the list every 30 seconds and refuse a revoked credential with 401 credential_revoked. When halo-server is unreachable a list younger than grace keeps serving; past it, failMode: closed refuses every halo-server credential, and open keeps the last list it saw, however old, so an outage never forgets a revocation the gateway already had.
  • New credentials for a revoked agent or workload subject are refused for one minute (the replica clock-skew pad); a human may log in and delegate again at once.
  • Bounded. A revocation row is dropped an hour and a minute after its epoch, when nothing it matches can still be valid; at most 10,000 are active (409 past that: revoke the subject or rotate the key).

Limits: a gateway without directory.url, or one that cannot reach halo-server, does not see new revocations; there a credential dies at exp (workloads at most 1 hour, agents at most maxTTLSeconds). An IdP token is not a halo-server credential: deprovision the user through SCIM.

Clients can send any header. The gateway deletes every inbound x-halo-* (and the configured identity and groups headers once the subject is derived), then computes and sets the stamps. The names it stamps are cleared by name on every request, not only when seen. halo-kong sees at most 1000 header lines (Kong truncates silently), so a request that reaches that cap is answered 431 before identity is derived.

The gateway’s JWT verifier cannot be turned against the IdP: OIDC discovery runs once per issuer in the background, each request waits for it only as long as its own deadline, and a failed discovery is not retried for 30 seconds. Once the issuer’s JWKS is cached, a token no cached key verifies (an unknown kid, which anyone can mint) refetches the JWKS at most once per 30 seconds per issuer; a rotated key is accepted after that window. See gateway headers. This is exercised in the compose demo: a request claiming x-halo-ring: ring3-ga reaches the upstream with the ring derived from the token.

Only exact model-call paths are accepted (/v1/messages, /v1/messages/count_tokens, /v1/responses, Bedrock /model/{id}/invoke* and converse*). Any other path under those prefixes is 404, /v1/messages/batches* is 400, oversize or unreadable bodies are 413, and a model that is not a policy alias is 400 (invalid_request_error) with an error body in the caller’s wire format. Nothing rejected is forwarded, and a rewrite failure is never forwarded un-rewritten.

Default guardrails are Go code (ADR-0007) run by halo validate and again at release build: no bypassPermissions, disableBypass required on every ring, reserved non-allowlisted override keys, reserved env prefixes, literal-secret detection, strict names. The release build then parses the rendered files and rejects bypassPermissions or danger-full-access in any value, as a backstop for adapter bugs and for overrides, which are merged after rendering. See policy model.

Unit owners approve their own directory (ADR-0018), so halo validate treats anything a unit overlay adds that could run code or reroute traffic on members’ machines as a loosening, reported at its field path: hooks (unless delegation.allowHooks), new env keys (unless listed in delegation.envKeys), and MCP servers that are not an exact selfService.catalog entry. Provider, base-URL, proxy, telemetry, CA-trust and runtime-injection env keys are refused even when listed, matched case-insensitively: *_BASE_URL, *_API_KEY, ANTHROPIC_*, OPENAI_*, GEMINI_*, CLAUDE_CODE_*, AWS_* (including AWS_ENDPOINT_URL*), GOOGLE_*, CLOUD_ML_REGION, HTTP(S)_PROXY, ALL_PROXY, NO_PROXY, SSL_CERT_FILE, SSL_CERT_DIR, REQUESTS_CA_BUNDLE, CURL_CA_BUNDLE, NODE_EXTRA_CA_CERTS, PATH, BASH_ENV, ENV, GIT_SSH_COMMAND, GIT_CONFIG_*, GIT_EXEC_PATH, npm_config_*, JAVA_TOOL_OPTIONS, _JAVA_OPTIONS, PERL5OPT, RUBYOPT, NODE_*, LD_*, DYLD_*, PYTHON*; every OTEL_*; shell start-up and prompt hooks (BASH_FUNC_*, PROMPT_COMMAND, SHELLOPTS, BASHOPTS, PS4, ZDOTDIR, HOME, XDG_CONFIG_HOME); programs tools start (PAGER, GIT_PAGER, MANPAGER, LESSOPEN, LESSCLOSE, EDITOR, VISUAL, GIT_EDITOR); git (every GIT_* except GIT_AUTHOR_NAME, GIT_AUTHOR_EMAIL, GIT_COMMITTER_NAME and GIT_COMMITTER_EMAIL, so GIT_SSH, GIT_ASKPASS, GIT_EXTERNAL_DIFF, GIT_SEQUENCE_EDITOR, GIT_PROXY_COMMAND, GIT_TEMPLATE_DIR, GIT_SSL_NO_VERIFY, GIT_SSL_CAINFO among them; and SSH_ASKPASS*, SUDO_ASKPASS, BROWSER); compilers, build tools and runtimes (GOFLAGS, GOENV, GOTOOLCHAIN, CC, CXX, CGO_*, MAKEFLAGS, MAKEFILES, RUSTC, RUSTC_*, CARGO_*, PERL5LIB, RUBYLIB, JDK_JAVA_OPTIONS, JAVA_OPTS, MAVEN_OPTS, GRADLE_OPTS, CLASSPATH, DOTNET_STARTUP_HOOKS, ELECTRON_RUN_AS_NODE, GCONV_PATH, LUA_INIT*, PHPRC, PHP_INI_SCAN_DIR); package and module sources (GOPROXY, GONOPROXY, GOSUMDB, GONOSUMDB, GONOSUMCHECK, GOINSECURE, GOPRIVATE, PIP_*, UV_*, YARN_*, BUNDLE_*, GEM_*) and DOCKER_HOST; and harness config dirs (CLAUDE_CONFIG_DIR, CODEX_HOME). Adapters write every key Halos sets (gateway routing, model pins, telemetry) after the profile’s env, so an env key never overrides them. halo-server and the gateways also refuse to activate a policy whose units or delegation fail validation (see workload credentials). Overlays cannot carry extensions (plugins, marketplaces). A top-level unit selects members from the whole org, so it may use members.groups only, and halo units codeowners gives its file to the root owners alone.

halo-proxy charges a budget from the usage the provider reports at the end of a response. A 2xx model call is never free: if the body ends before its final usage is read (the client hangs up, the upstream cuts, the encoding cannot be decoded, no usage object), it charges a pessimistic estimate, counted as estimated. Output is estimated from the generated text in the stream’s delta events (/ 4, at least a token per event), not from the SSE wire bytes, and capped at the request’s max output tokens or budgets.maxEstimatedOutputTokens (default 128000). A request that makes the provider bill input its body does not carry is charged at least budgets.serverStateInputTokens (default 200000) of input: on OpenAI Responses previous_response_id, conversation, a stored prompt id, background: true, input_file by file_id or file_url, input_image by file_id or an http(s) URL, item_reference, and the file_search, web_search*, mcp and code_interpreter tools; on Anthropic (and Bedrock invoke) the server tools (web_search, web_fetch, code_execution, tool_search), mcp_servers, container uploads, and document or image sources of type file or url. Hidden reasoning is billed as output but never streamed, so when the request enables it (Responses reasoning other than effort none, Anthropic thinking or an output_config effort, Gemini thinkingConfig) the output estimate is at least the thinking budget, else the output cap. A background request answered with usage: null is charged the input floor plus the output cap. gzip and deflate responses are decompressed for the usage scan only; the client gets the upstream’s bytes.

Limit: an enforce budget is checked before the call against what has been charged so far, and a response is charged when it ends, so one call can overshoot the limit by its own cost (including an estimate); the next call is refused.

  • Emit bypassPermissions or danger-full-access, including via overrides (overrides are an allowlist).
  • Auto-promote a release, auto-merge a PR, or push to your main branch. The MCP server has no publish, retag or merge tool, and the only push it makes is a halos/* review branch behind a PR.
  • Execute a shadow candidate’s tool calls.
  • Merge or auto-promote from the controller: it only opens PRs. Its one automatic action that changes live traffic is the kill switch, which only ever reverts to control.
  • Harness enforcement is only as strong as the harness. Where the matrix says halod enforces, root can still stop halod or edit its files; on rings with posture: enforce that device then loses gateway access, and versionGate: enforce refuses CLIs off the pin (gateway gates).
  • Posture is halod’s own report, not hardware attestation: root can read the device token and post a forged clean report. Posture is checked per user, not per request, so a user with a compliant laptop can use their credential from an unmanaged machine. The User-Agent is client-controlled: the version gate catches stale and unmanaged installs, not a forged header.
  • The signing key is a single point of trust. There is no threshold signing. The kill-switch key is a second, narrower one.
  • The audit log is tamper-evident, not tamper-proof, and tail truncation is undetectable on its own (see Audit log).
  • The enrollment bootstrap (curl -fsSL <server>/enroll.sh | sh -s -- <token>) is itself a piped script whose integrity rests on TLS to your server. It pins the halod binary by sha256 and refuses to install without a pinned checksum.
  • Shadow is single-turn only (ADR-0004).
  • The halo-kong Go plugin runs as a separate process; treat its socket as part of the trust boundary.
  • UNVERIFIED: the above was tested with unit tests, the compose demo, and the kind, real-CLI and Kong OSS UAT suites in CI, not against a real fleet, Kong Enterprise or Konnect, real AWS Bedrock, or a third-party penetration test.

Every claim on this page is held by at least one test in the repository; internal/docgen fails CI if a named test is renamed or removed. Parsers of untrusted input also have native Go fuzz targets (Fuzz*), run briefly on every PR and for ten minutes per target nightly (scripts/fuzz.sh).

Claim Tests
Ring pointer: seq regression, reused seq, wrong org or ring, expiry and oversize layers are refused before the layer is fetched internal/bundle/pointer_test.go:TestPullRing, internal/bundle/pointer_test.go:TestPointerCrossRingAndForeignKey, internal/bundle/pointer_test.go:TestPointerTamperedPayload, internal/bundle/fuzz_test.go:FuzzPullRingPointer
Release fetched by content digest, signature verified before download; halod applies nothing from a release that fails verification cmd/halod/agent_test.go:TestRefusesUnverified, cmd/halod/agent_test.go:TestPointerFreshness, cmd/halod/hardening_test.go:TestTamperedReleaseTouchesNothing
Signer continuity: promote and rollback ignore retagged tags, refresh refuses replayed and expired pointers, an unknown pointer needs --adopt-existing or --expect-digest internal/bundle/continuity_test.go:TestPromoteRollbackIgnoreRetaggedTags, internal/bundle/continuity_test.go:TestRefreshRefusesReplayedPointer, internal/bundle/continuity_test.go:TestRefreshRefusesExpiredPointer, internal/bundle/continuity_test.go:TestContinuityUnknownPointerNeedsAdoption, internal/bundle/continuity_test.go:TestPointerStateCheck
Bundle tar: only regular files, no entry larger than the tar (PAX sparse bombs), blobs must hash to their names internal/release/open_test.go:TestOpenRefusesExpansionAndLinks, internal/release/fuzz_test.go:FuzzOpen
Verified installs: wrong hash refused, no reinstall when the version matches, curl | bash only behind allowShellInstall cmd/halod/agent_test.go:TestInstallBadHashRejected, cmd/halod/agent_test.go:TestNoInstallWhenVersionMatches, cmd/halod/agent_test.go:TestLegacyShellInstallGated
Path allowlist: .., relative, backslash, NUL, Win32-normalized and alternate-stream paths refused; stale removal uses the same allowlist cmd/halod/agent_test.go:TestAllowedPath, cmd/halod/fuzz_test.go:TestAllowedWindowsWin32Normalization, cmd/halod/fuzz_test.go:FuzzAllowedPath, cmd/halod/agent_test.go:TestApplyInstallDriftAndRemoval
Mode ceiling and ownership preflight; a symlink or foreign-owned file where a managed file belongs is replaced, never adopted cmd/halod/agent_test.go:TestApplyRefusesUnsafeTargets, cmd/halod/agent_test.go:TestOwnershipPreflight, cmd/halod/hardening_test.go:TestUntrustedTargetReplacedNotAdopted
halod.yaml is parsed defensively cmd/halod/agent_test.go:TestLoadConfig, cmd/halod/fuzz_test.go:FuzzLoadConfig
OIDC JWT: none, HS*, wrong iss/aud, expired and not-yet-valid tokens rejected; unknown kid refetches JWKS, and a cached JWKS older than 24 hours is refetched before use (a withdrawn key stops verifying), except under a cache-only context, where a stale set keeps verifying; a stale set also serves while the issuer is unreachable, retried each minute internal/identity/security_test.go:TestOIDCJWTAdversarial, internal/identity/security_test.go:TestOIDCJWTRefetchesJWKSOnUnknownKid, internal/identity/security_test.go:TestJWKSMaxAge, internal/identity/fuzz_test.go:FuzzOIDCJWTVerify
trusted_header honors identity headers only from trustedProxyCIDRs peers, ignores X-Forwarded-For, fails closed without CIDRs internal/identity/security_test.go:TestTrustedHeaderPeerOnly, cmd/halo-proxy/security_test.go:TestTrustedHeaderIgnoresForwardedFor, cmd/halo-proxy/security_test.go:TestTrustedHeaderWithoutCIDRsFailsClosed
Every inbound x-halo-* header is stripped (any case, repeated, HTTP/2, trailers); cohort comes from the verified identity only; anonymous callers never get an experiment cmd/halo-proxy/security_test.go:TestHaloHeadersStrippedAnyCaseRepeatedAndH2, cmd/halo-proxy/security_test.go:TestRequestTrailersNeverForwarded, cmd/halo-proxy/proxy_test.go:TestSpoofedHeadersStrippedRewriteAndAuth, internal/gateway/decision_test.go:TestPrepareVerifiedIgnoresHeaders, cmd/halo-proxy/security_test.go:TestAllowAnonymousNeverGetsExperiment, cmd/halo-kong/security_test.go:TestAccessClearsOwnedHeadersEvenWhenHeaderListTruncated, internal/gateway/fuzz_test.go:FuzzPrepareVerified
Workload exchange limiter: only malformed, unlisted-issuer or bad-signature subject tokens count; signed tokens that match no rule, expired or for another audience do not; a signed token passes from a blocked IP, verified from cached keys without a JWKS fetch; replay is still refused; delegation is dispatched before the CI budget and uses its own internal/server/tokenexchange_test.go:TestExchangeLimiterCountsOnlyForgeries
Workload token exchange: untrusted issuer, wrong aud, expired, future nbf/iat, alg: none, HS256 keyed with the issuer’s public key, unknown kid, replayed jti (or token), and claims no rule matches are refused; only a token a tenant-pinned rule matches takes a replay-cache slot, so tokens anyone can mint from a shared CI issuer cannot fill the cache; denials are audited at most once per issuer and client IP per minute (the rest counted in halo_server_workload_denied_audit_suppressed_total); exchanges are audited without tokens internal/server/tokenexchange_test.go:TestTokenExchangeRejectsBadSubjectTokens, internal/server/tokenexchange_test.go:TestUnmatchedWorkloadTokensSkipReplayCacheAndCoalesceAudit, internal/server/tokenexchange_test.go:TestAuditDeniedBounded, internal/server/tokenexchange_test.go:TestTokenExchangeMintsGatewayCredential, internal/server/tokenexchange_test.go:TestTokenExchangeRequestShape, internal/server/tokenexchange_test.go:TestReplayCacheBounded, internal/policy/workloads_test.go:TestMatchWorkloadGlobs, internal/policy/workloads_test.go:TestValidateWorkloads
Workload credentials: EdDSA only, trusted kid, rejected at expiry and over an hour; accepted by the gateway as a workload principal with x-halo-* still stripped and no experiment by default internal/identity/workload_test.go:TestWorkloadTokenRoundTripAndRejections, internal/identity/workload_test.go:TestSignWorkloadTokenRefusesBadClaims, internal/identity/workload_test.go:TestWorkloadJWTDispatch, cmd/halo-proxy/workload_test.go:TestWorkloadCredentialAcceptedStrippedAndNotExperimented, cmd/halo-proxy/workload_test.go:TestWorkloadCredentialRejected
Delegated agent credentials: forged, re-signed or extended act chains, cycles, depth over the ceiling, human roots with the workload: prefix and unknown claims are refused by the shared verifier; a forged chain never falls through to the human verifier internal/identity/delegation_test.go:TestDelegatedTokenRejections, internal/identity/delegation_test.go:TestWorkloadJWTDelegated, internal/identity/delegation_test.go:TestDelegatedTokenRoundTrip
Delegation exchange: depth and cycle limits; a child cannot gain groups, models or lifetime, or narrow groups; agents outside policy, wrong harnesses and roots outside the actor’s groups are refused; revoked sessions and deactivated roots kill the chain, while a login after a revocation (stamped on a whole second past it, as delegated credentials carry auth_time in seconds) delegates normally; every exchange and refusal is audited with the chain internal/server/delegation_test.go:TestDelegationAfterRevocationAuthTime, internal/server/delegation_test.go:TestDelegationChainDepthAndCycles, internal/server/delegation_test.go:TestDelegationScopeEscalation, internal/server/delegation_test.go:TestDelegationTTLBoundedByParent, internal/server/delegation_test.go:TestDelegationRefusesForgedAndDeadParents, internal/server/delegation_test.go:TestDelegationRevokedSession, internal/server/delegation_test.go:TestDelegationFromSession
Delegated callers route as their root (ring, budgets, posture, arm), stay out of experiments by default, are refused for models, tools and depth outside current policy, and a deprovisioned root’s agent gets a 403; client actor headers are stripped internal/gateway/agents_test.go:TestDelegatedCohortIsRoot, internal/gateway/agents_test.go:TestDelegatedAdmission, internal/gateway/agents_test.go:TestActorHeadersStripped, cmd/halo-proxy/agents_test.go:TestDelegatedCredentialAtTheProxy, cmd/halo-proxy/agents_test.go:TestDelegatedBudgetChargedToRoot, cmd/halo-proxy/agents_test.go:TestDelegatedDeprovisionedRoot, cmd/halo-kong/agents_test.go:TestAccessDelegatedAgent
Delegated agents’ tool policy cannot be bypassed: deep nesting, Gemini snake_case declarations, Bedrock Converse tools, hosted tools (web search, code execution, MCP servers) and unreadable or unknown tool shapes are refused when deniedTools is set; every actor of the chain is re-checked for groups and models, and the root’s profile and unit maxDepth apply at the gateway internal/gateway/agents_test.go:TestDelegatedToolBypasses, internal/gateway/agents_test.go:TestToolNames, internal/gateway/agents_test.go:TestToolNamesFailClosed, internal/gateway/agents_test.go:TestDelegatedChainRecheck, internal/policy/agents_review_test.go:TestDeniesTool, internal/policy/agents_review_test.go:TestChainRecheck, internal/policy/agents_review_test.go:TestDecideRemoteRechecksChain
Delegation and egress hardening: a revoked root’s agent gets no egress decision; delegation and egress have their own failure budget that policy refusals and expired agent credentials do not spend; an egress allow needs its audit record; refused verified parents and unresolvable policy are audited with root and jti; actor labels cannot forge audit structure; an overflowing expires_in is a 400 internal/server/delegation_security_test.go:TestEgressRevokedRoot, internal/server/delegation_security_test.go:TestAgentLimiter, internal/server/delegation_security_test.go:TestEgressAllowIsAuditStrict, internal/server/delegation_security_test.go:TestDelegationAuditsRefusedParents, internal/server/delegation_security_test.go:TestDelegationAuditsDeprovisionedParent, internal/server/delegation_security_test.go:TestDelegationActorLabels, internal/server/delegation_security_test.go:TestDelegationExpiresInOverflow, internal/identity/delegation_review_test.go:TestCheckChainLabels, internal/identity/delegation_review_test.go:TestExpiredIsDistinguishable
AgentCards: invalid UTF-8 and lone surrogates cannot collide digests; case-variant keys cannot change the skills Halos sees; NAT64, 6to4 and Teredo destinations are refused; card text cannot write terminal escapes internal/a2a/review_test.go:TestCanonicalizeRefusesLossyUnicode, internal/a2a/review_test.go:TestParseCardExactKeys, internal/a2a/review_test.go:TestPublicAddrTransition, cmd/halo/cmd_agents_test.go:TestAgentsVerifyEscapesCard
Remote agents: AgentCards fetched over https only, no off-host or downgrade redirects, size and time capped, non-public destinations refused; a changed card fails its pinned digest and a bad or missing signature fails a pinned key; egress decisions use the verified root’s ring and unit; units cannot raise depth or add remote agents internal/a2a/card_test.go:TestFetchLimits, internal/a2a/card_test.go:TestVerifyCard, internal/a2a/card_test.go:TestVerifySignatures, internal/a2a/card_test.go:TestPublicAddr, cmd/halo/cmd_agents_test.go:TestAgentsVerify, internal/server/delegation_test.go:TestAgentEgressDecision, internal/policy/agents_test.go:TestDecideRemote, internal/policy/agents_test.go:TestUnitAgentLoosenings, internal/gateway/snapshot_test.go:TestSnapshotRefusesSecurityErrors
halo ci: credential masked in GitHub logs, no newline injection into $GITHUB_ENV, https-only exchange, headless config never carries a bypass mode cmd/halo/cmd_ci_test.go:TestCIExchangeAndExport, cmd/halo/cmd_ci_test.go:TestCIConfigWritesOneHarness
Model allowlist fails closed; duplicate or case-variant JSON keys cannot smuggle a model; clients cannot steer the upstream host cmd/halo-proxy/proxy_test.go:TestModelAllowlistFailsClosed, internal/gateway/security_test.go:TestModelSmugglingViaDuplicateOrCaseVariantKeys, cmd/halo-proxy/security_test.go:TestClientCannotSteerUpstreamHost
Gateway fail-closed paths: a delegated agent keeps its chain when the directory fails open; a refused posture lookup is a failed verdict; an invalid posture config refuses on enforce rings; a request at Kong’s header cap is 431; owned stamps are cleared even when unseen cmd/halo-kong/agents_test.go:TestAccessDelegatedAgent, internal/gateway/enforce_test.go:TestPostureRefusedFailsClosed, cmd/halo-kong/gate_test.go:TestAccessGates, cmd/halo-kong/metrics_test.go:TestAccessHeaderCap, internal/gateway/decision_test.go:TestOwnedHeadersAlwaysCleared
JWT verification cannot be turned against the IdP: one shared discovery bounded by each request’s deadline with a 30s negative cache; unknown-kid JWKS refetches throttled per issuer, rotation still accepted internal/identity/oidc_test.go:TestOIDCDiscoveryShared, internal/identity/security_test.go:TestOIDCJWTRefetchesJWKSOnUnknownKid
Upstream SSE is re-emitted without event injection internal/gateway/security_test.go:TestEventStreamToSSENoInjection, internal/gateway/fuzz_test.go:FuzzEventStreamToSSE
Bedrock: client Authorization, x-api-key and x-amz* dropped even with forwardAuth; non-AWS hosts and missing credentials are 502, never unsigned cmd/halo-proxy/security_test.go:TestBedrockClientCredentialsDroppedEvenWithForwardAuth, cmd/halo-proxy/bedrock_test.go:TestBedrockNonAWSHostRefused, cmd/halo-proxy/bedrock_test.go:TestBedrockMissingCredentials502, internal/gateway/upstreamauth/bedrock_test.go:TestStripClientAmz
Shadow replays and the mirrorer stay on policy hosts and never follow redirects internal/shadow/security_test.go:TestMirrorerDoesNotFollowRedirects, internal/shadow/shadow_test.go:TestBedrockPathStaysOnPolicyHost, internal/shadow/security_test.go:FuzzShadowReplayStaysOnPolicyHost
Kill list: signed with the dedicated key, fresh, strictly newer than the held one, never expires, fetched over TLS without following redirects internal/gateway/killswitch_test.go:TestKillListSignVerify, internal/gateway/toggles_test.go:TestKillListTogglesSignedFreshMonotonic, internal/gateway/security_test.go:TestKillsDoNotExpire, internal/gateway/security_test.go:TestKillSwitchNeverFollowsRedirects, internal/gateway/kong/deck_test.go:TestKillswitchURLMustBeHTTPS, internal/gateway/fuzz_test.go:FuzzVerifyKillList, internal/gateway/fuzz_test.go:FuzzKillSwitchRefresh
Kill endpoint: operator or admin only, reason required, 501 without a key, gateway token compared in constant time and rate limited internal/server/killswitch_test.go:TestKillEndpointsAuthz, internal/server/rbac_test.go:TestRolePermissionMatrix, internal/server/killswitch_test.go:TestKillWithoutKeyNotImplemented, internal/server/security_test.go:TestGatewayKillswitchRateLimited, internal/server/security_test.go:TestGatewayKillswitchConfigAndDomain, internal/server/fuzz_test.go:FuzzKillListSigned
Auto-kill only on gateway-sourced evidence; CLI and eval evidence never kill; tampered or forged rollout state is not acted on internal/controller/controller_test.go:TestRollbackOnNonGatewayEvidenceDoesNotKill, internal/controller/eval_source_test.go:TestEvalEvidenceNeverKills, internal/controller/rollouts_test.go:TestRolloutBreachOnCLIEvidenceDoesNotKill, internal/controller/rollouts_test.go:TestRolloutTamperedStateIsNotActedOn, internal/telemetry/collector_test.go:TestGenerateEvidenceTrust, internal/telemetry/gwmetrics/gwmetrics_test.go:TestUnitHashing
Console roles: every cookie route is in the permission table and enforced, an unmapped route is denied to everyone, each role gets exactly its permissions, every denial is audited, adminGroups still grants admin, unknown role names fail to load internal/server/rbac_test.go:TestRoutePermsCoverMux, internal/server/rbac_test.go:TestUnmappedRouteDenied, internal/server/rbac_test.go:TestRolePermissionMatrix, internal/server/rbac_test.go:TestInHandlerAuthorization, internal/server/rbac_test.go:TestRolesFor, internal/policy/roles_test.go:TestIdentityRoles
Audit log: hash chain verifies, edits break it, appends survive restart and torn lines, privileged actions fail closed when the append fails internal/server/console_test.go:TestAuditChainAndEndpoint, internal/server/audit_test.go:TestAuditRotationChainsAcrossSegments, internal/server/audit_test.go:TestAuditEndpointAcrossSegments, cmd/halo/cmd_audit_test.go:TestAuditVerify, internal/server/console_test.go:TestAuditPersistsAcrossRestartAndTornLine, internal/server/security_test.go:TestPrivilegedActionsFailClosedOnAudit, internal/server/security_test.go:TestAuditInvalidUTF8StaysVerifiable, internal/server/fuzz_test.go:FuzzAuditChainTamper, internal/server/fuzz_test.go:FuzzAuditChainParse
Audit export: every entry reaches each SIEM sink at least once, in order, byte-identical with its chain fields; a restart resumes without resending; a broken or rewritten chain halts export internal/server/audit_export_test.go:TestAuditExportWebhook, internal/server/audit_export_test.go:TestAuditExportRetriesThenResumesWithoutResending, internal/server/audit_export_test.go:TestAuditExportHaltsOnBrokenChain, internal/server/audit_export_test.go:TestAuditExportSplunkHECOCSF, internal/server/audit_export_test.go:TestAuditExportSyslogTLS
Enrollment and device tokens: single use, short TTL, stored only as SHA-256, revocable, expiring; a device cannot overwrite another host’s record internal/server/portal_test.go:TestEnrollmentToken, internal/server/devices_test.go:TestDeviceFlow, internal/server/epoch_test.go:TestDeviceTokenExpires, internal/server/devices_test.go:TestDeviceCannotOverwriteOtherHost
Failed logins, enrollments and device auth are rate limited per client IP, behind trusted proxies too internal/server/security_test.go:TestLoginCallbackRateLimited, internal/server/security_test.go:TestEnrollRateLimited, internal/server/devices_test.go:TestFailedAuthRateLimit, internal/server/devices_test.go:TestRateLimitBehindProxy
Failed-auth budgets are per endpoint class: token exchange and SCIM failures never lock out gateway-token or device callers on the same IP; a valid gateway token is never blocked internal/server/security_test.go:TestAuthLockoutIsPerEndpointClass, internal/server/security_test.go:TestGatewayKillswitchRateLimited
User ids are compared and keyed in one canonical form, budgets included: no self-approval or fresh budget under another case; own requests, devices and whoami match; lookalikes (Kelvin sign, dotted I, fullwidth) never fold into another user internal/policy/userid_test.go:TestCanonicalUser, internal/budget/budget_test.go:TestCharges, internal/server/security_test.go:TestUserIDsCompareCanonically, internal/server/scim_test.go:TestSCIMDeprovisionIgnoresCase
Console: cookie-authenticated state changes are refused when Origin or Sec-Fetch-Site says cross-site; cookies are HttpOnly, SameSite=Lax, Secure over https, and integrity-protected internal/server/security_test.go:TestCSRFStateChangingRequests, internal/server/security_test.go:TestCookieAttributes, internal/server/portal_test.go:TestSessionCookieIntegrity
API JSON bodies are size-limited and decoded defensively internal/server/security_test.go:TestJSONBodySizeLimits, internal/server/fuzz_test.go:FuzzAPIJSONDecoders
SCIM: only a configured SCIM token (either of a rotation pair) is accepted, never a session, gateway or fleet token; failures are rate limited; an empty-token hash is refused at startup internal/server/scim_test.go:TestSCIMAuth, internal/server/scim_test.go:TestNewSCIMConfig
SCIM: deactivating, deleting or renaming a user revokes their sessions, device tokens and pending enrollments at once, case-insensitively and across restarts, and is audited internal/server/scim_test.go:TestSCIMDeprovisionRevokesSessions, internal/server/scim_test.go:TestSCIMDeprovisionIgnoresCase, internal/server/scim_test.go:TestSessionRevocationReplayCanonical, internal/server/scim_test.go:TestSCIMRenameDeprovisionsOldName, internal/server/scim_test.go:TestSCIMIdPFixtures
SCIM: authoritative directory groups replace session, enrollment and token groups; unprovisioned or inactive users are refused on halo-server and refused (403) at the gateways, never anonymous; workloads skip the directory and keep their kind internal/server/scim_test.go:TestSCIMAuthoritative, internal/gateway/directory_test.go:TestDirectoryClientResolve, internal/gateway/directory_test.go:TestDirectoryClientWorkloads, cmd/halo-proxy/directory_test.go:TestDirectoryIsAuthoritative, cmd/halo-proxy/directory_test.go:TestDirectoryKeepsWorkloads, cmd/halo-kong/directory_test.go:TestAccessDirectoryIsAuthoritative
Directory outage or invalid config never makes a verified caller anonymous: fail open keeps the subject with no groups (posture gate and budgets still apply), fail closed answers 503; fail open never admits a cached inactive entry or follows a non-5xx refusal, a full cache evicts active entries only so an inactive answer is never forgotten, and an oversized subject is inactive internal/gateway/directory_test.go:TestDirectoryClientNeverFailsOpenOnInactiveOrRefusal, internal/gateway/directory_test.go:TestDirectoryCacheEvictionKeepsInactive, internal/server/scim_test.go:TestGatewayDirectoryEndpoint, internal/gateway/directory_test.go:TestDirectoryClientResolve, internal/gateway/directory_test.go:TestRejectDirectoryUnavailable, cmd/halo-proxy/directory_test.go:TestDirectoryOutageKeepsIdentity, cmd/halo-kong/directory_test.go:TestAccessDirectoryIsAuthoritative
Human identities (OIDC claim, trusted header, SCIM userName) cannot carry the workload: prefix internal/identity/identity_test.go:TestSubjectFromClaims, internal/identity/identity_test.go:TestTrustedHeader, internal/server/scim_test.go:TestSCIMResourceRules
SCIM: request bodies are size-limited and decoded defensively; the directory stays consistent and lookups and member deltas stay linear at 20k users internal/server/scim_test.go:TestSCIMResourceRules, internal/server/scim_fuzz_test.go:FuzzSCIMJSONDecoders, internal/server/scim_store_test.go:TestSCIMStoreMembershipIndex, internal/server/scim_store_test.go:TestSCIMScalesLinearly
Gateway tokens: a per-capability token (--gateway-{killswitch,posture,budget,directory}-token-file) is the only token its endpoint accepts and opens nothing else; every gateway token must look random: 32 or more hex or base64/base64url characters carrying 128 bits (charset bits times length), no run of 8 repeated or consecutive characters, enough distinct characters, and base64 mixing upper case, lower case and digits (words and alphabets are refused) internal/server/killswitch_test.go:TestGatewayCapabilityTokens, cmd/halo-server/main_test.go:TestReadGatewayTokens
Policy load errors are never returned by public endpoints (readiness, login, token exchange, agent egress), only logged internal/server/security_test.go:TestPolicyLoadErrorNotLeaked
Policy snapshots with a null ring, experiment, unit, rollout, toggle or profile are refused, and a panic in halo-kong’s Access phase answers 500 instead of killing the plugin internal/policy/snapshot_test.go:TestParseSnapshotRejectsNullEntries, cmd/halo-kong/access_test.go:TestAccessRecoversPanic
Secrets never appear in logs, error bodies or notifications cmd/halo-proxy/security_test.go:TestSecretsNeverLogged, internal/server/security_test.go:TestSecretsNeverLoggedOrReturned, internal/controller/notify_test.go:TestWebhookSecretNeverSent, internal/controller/notify_test.go:TestNotifyErrorsNeverLeakURL, internal/server/audit_export_test.go:TestAuditExportSecretsNeverLogged
Webhook notifications are HMAC-signed and timestamped; verification rejects replays internal/controller/notify_test.go:TestWebhookSignature, internal/controller/notify_test.go:TestWebhookVerify, internal/controller/notify_fuzz_test.go:FuzzWebhookVerify
A known issuer’s tenantClaim that is not one of its tenant claims (GitHub plus ref) is ignored at runtime and refused by validation internal/policy/workloads_test.go:TestKnownIssuerIgnoresForeignTenantClaim
Workload rules must pin the tenant: a public issuer’s rule without an exact tenant claim (or literal/*) fails validation; unknown issuers need tenantClaim; renamable-name-only GitHub, GitLab and Buildkite rules and pull_request_target-reachable GitHub rules warn internal/policy/workloads_test.go:TestValidateWorkloads, internal/policy/workloads_test.go:TestValidateWorkloadsGitHubWarnings, internal/policy/workloads_test.go:TestCIAgentsExampleWorkloads
Tenant pinning and unit loosening are enforced at runtime: the exchange never matches an unpinned rule; halo-server and the gateways refuse to activate a policy with security validation errors and keep the last good one, whose workload rules a refused policy can still remove (never add or change), each removed rule staying in place as a deny tombstone so its tokens never fall through to a broader rule below it; halo release build refuses invalid policy internal/policy/workloads_test.go:TestMatchWorkloadSkipsUnpinnedRules, internal/server/security_test.go:TestPolicyLoadRefusesSecurityErrors, internal/server/security_test.go:TestRefusedPolicyStillRemovesWorkloadRules, internal/server/security_test.go:TestRefusedPolicyRemovalDoesNotWiden, internal/server/tokenexchange_test.go:TestWorkloadRulesSkippedIsPerLoad, internal/gateway/snapshot_test.go:TestSnapshotRefusesSecurityErrors, cmd/halo/cmd_units_test.go:TestReleaseBuildRefusesUnpinnedWorkload, cmd/halo/cmd_units_test.go:TestValidateRejectsUnitLoosening
Units cannot run code or reroute traffic on members’ machines: added hooks, env keys, MCP servers and extensions are loosenings unless delegated; provider, cloud, proxy, telemetry, CA-trust and injection env keys never pass, in any case, nor shell hooks, pagers, editors, any GIT_* but commit identity, compilers and build tools, package and module sources, harness config dirs or any OTEL_*; policy telemetry is written after the env; top-level units select groups only and their files belong to the root owners internal/policy/unit_test.go:TestUnitForbiddenEnv, internal/harness/claudecode/claudecode_test.go:TestTelemetryEnvWinsOverProfileEnv, internal/policy/unit_test.go:TestUnitAdditionsAreLoosenings, internal/policy/unit_test.go:TestUnitExtensionsStayCentral, internal/policy/unit_test.go:TestUnitOverlayEnforcement, internal/policy/unit_test.go:TestCodeOwners, cmd/halo/cmd_units_test.go:TestUnitsCodeowners
Budgets: a metered 2xx response is never free; an aborted or cut stream, a missing usage object or an undecodable encoding is charged an estimate (output from delta text, capped, at least the thinking budget when reasoning is on, the cap for background requests; remote files, server tools and server-side state charged a worst-case input); gzip and deflate bodies are metered cmd/halo-proxy/budget_test.go:TestBudgetChargesAbortedStream, cmd/halo-proxy/budget_test.go:TestBudgetMetersCompressedResponse, internal/gateway/usage_test.go:TestUsageTapIncomplete, internal/gateway/usage_test.go:TestUsageTapContentEncoding, internal/gateway/usage_test.go:TestEstimateUsage, internal/gateway/usage_test.go:TestUsageTapOutputText, internal/gateway/usage_test.go:TestServerStateInput, internal/gateway/usage_test.go:TestEstimateChargesUnseenTokens
Guardrails: no bypassPermissions / danger-full-access in any mode, env, override or rendered file; literal credentials refused in bundles internal/policy/guardrails_test.go:TestSecurityGuardrails, internal/policy/simple_test.go:TestSimpleSafetyNeverBypasses, internal/release/check_test.go:TestBuildBackstop, internal/release/check_test.go:TestCheckRenderedSecrets, internal/harness/claudecode/claudecode_test.go:TestOverridesCaughtByReleaseChecks, internal/policy/fuzz_test.go:FuzzLoadValidate
No auto-merge, no push to main: MCP write tools, promotion PRs and the controller commit to halos/* branches only internal/mcpserver/write_safety_test.go:TestWriteToolsNeverPushMergeOrTag, internal/promote/branch_security_test.go:TestPushBranchNeverUpdatesMain, internal/promote/branch_security_test.go:TestBranchPrefixValidated, internal/controller/never_merge_test.go:TestGitWriterNeverMergesOrPushesBase
Vendor admin sync never loosens a console: halo admin apply refuses the whole run, sending nothing, when a change it would write loosens a control the console has now (a dropped exclusion string, logging off, an unspecified or stable channel moved to experimental, a moved binding) or a non-Halos setting governs the same target; object exclusion rules are preserved; it never emits bypassPermissions / danger-full-access; it needs --yes or a typed confirmation internal/vendoradmin/vendoradmin_test.go:TestNoLoosening, internal/vendoradmin/fixes_test.go:TestCopilotObjectEntriesPreserved, internal/vendoradmin/fixes_test.go:TestGeminiLooseningClassification, internal/vendoradmin/fixes_test.go:TestReadOnlyLooseningDoesNotBlock, internal/vendoradmin/vendoradmin_test.go:TestForbiddenValuesRefused, cmd/halo/cmd_admin_test.go:TestAdminApply
Vendor admin apply writes only what the human confirmed: it re-reads every console before writing and aborts with nothing written if it changed since the plan; Copilot re-reads again before the PUT and sends If-Match when GitHub returns an ETag; the apply record has one truthful result per change internal/vendoradmin/fixes_test.go:TestCopilotStalePlanAborts, internal/vendoradmin/fixes_test.go:TestCopilotETagIfMatch, internal/vendoradmin/fixes_test.go:TestGeminiStalePlanAborts, internal/vendoradmin/fixes_test.go:TestApplyPartialFailureResults, cmd/halo/cmd_admin_test.go:TestAdminApplyRecordOnFailure
Vendor admin credentials come only from a file or an environment variable, never policy (strict decoding rejects a token: key), go only over https (http only to loopback), never follow a redirect (a 3xx is an error), and are scrubbed from every error; MCP admin_plan reads consoles only with --admin-read and HALO_MCP_ADMIN_<VENDOR>_TOKEN (never the CLI’s variables), takes no credential or API URL argument, and no apply tool exists internal/vendoradmin/vendoradmin_test.go:TestCredentialRedaction, internal/vendoradmin/fixes_test.go:TestCheckBaseURL, internal/vendoradmin/fixes_test.go:TestClientDoesNotFollowRedirects, internal/policy/vendoradmin_test.go:TestVendorAdminStrictDecode, internal/mcpserver/tools_admin_test.go:TestAdminPlanReadOnly
Vendor admin console exports carry no machine config (credential helpers, gateway endpoint and headers, machine-path commands, ring stamps), each removal warned; identifiers are validated and every rendered string is stripped of control and format characters internal/vendoradmin/fixes_test.go:TestClaudeConsoleArtifactHasNoMachineConfig, internal/vendoradmin/fixes_test.go:TestConsoleSettingsStripsMachineKeys, internal/policy/vendoradmin_test.go:TestVendorAdminValidate, internal/vendoradmin/fixes_test.go:TestSanitize, cmd/halo/cmd_admin_test.go:TestAdminOutputSanitized
Vendor admin renderers never silently drop a profile field (invariant 2): each one sets, exports or warns about it internal/vendoradmin/vendoradmin_test.go:TestNoSilentDrop
Delivery ROI ingest is read-only on the SCM: halo roi ingest github sends one fixed GraphQL query (no mutation), takes the token only from the variable named by --token-env, sends it only over https (http only to loopback), never follows a redirect, scrubs it from every error and stores no titles or bodies; MCP roi_report is read-only and takes no path or credential argument internal/roi/github_test.go:TestGitHubFetch, internal/roi/github_test.go:TestGitHubErrors, cmd/halo/cmd_roi_test.go:TestROIIngestGitHub, internal/mcpserver/tools_roi_test.go:TestROIReportTool
Delivery ROI claims causality only for randomized arms: a ring comparison is labelled observational with kind: association, a sample ratio mismatch marks the report srm, and the PR section is informational (a promotion verdict never reads it) internal/roi/report_test.go:TestAnalyzeObservationalRings, internal/roi/report_test.go:TestAnalyzeSRM, internal/promote/promote_test.go:TestOpenPRROISection
HA (Postgres) cluster locks and claims: a SCIM mutation is refused (503) rather than run without the cluster lock, a lock or claim is released only by its owner, expiry is judged on the database clock, and one userName is provisioned once across replicas internal/server/ha_fixes_test.go:TestSCIMLockHeldIs503, internal/server/ha_fixes_test.go:TestClusterMutexOwnerRelease, internal/server/ha_fixes_test.go:TestSCIMUserNameClaim, internal/server/ha_fixes_test.go:TestDecisionClaimOwner, internal/pgstore/hafix_test.go:TestKVLockSQL, internal/storage/lock_test.go:TestMemKVLock
HA leader fencing: a fenced write commits only while its token owns the lease (the takeover waits for it; expiry judged at check time), and a deposed controller takes no kill, PR or notification; a new leader finds a PR its predecessor opened instead of opening another internal/pgstore/hafix_test.go:TestFenceHoldsOffTakeover, internal/pgstore/hafix_test.go:TestFenceUsesCheckTime, internal/pgstore/pg_test.go:TestFencedWrites, internal/controller/intent_test.go:TestDeposedLeaderTakesNoSideEffects, internal/controller/intent_test.go:TestLeaseLostBeforeKill, internal/controller/intent_test.go:TestFencedAfterKillStopsPRAndNotify, internal/controller/intent_test.go:TestNewLeaderFindsPRInsteadOfReopening, internal/controller/intent_test.go:TestRolloutFencedSkipsNotify
HA revocation across replicas: a revocation is padded for replica clock skew (a fresh login is stamped past it), a deprovisioning voids enrollment tokens before revoking devices and an enrollment racing it is undone, the void never moves backwards, and a workload token is exchanged once cluster-wide until well past its exp internal/server/ha_fixes_test.go:TestRevocationSkewPad, internal/server/ha_fixes_test.go:TestPostMintVoidRevokes, internal/server/ha_fixes_test.go:TestEnrollVoidWindow, internal/server/ha_fixes_test.go:TestJTIRowOutlivesExp, internal/server/ha_shared_test.go:TestSharedSCIMDeprovision, internal/server/ha_shared_test.go:TestSharedWorkloadReplay
HA database connection: a database URL to a non-loopback host must verify the server certificate and host name (sslmode=verify-full) unless –database-insecure-tls is set internal/pgstore/hafix_test.go:TestCheckTLS
Failed-auth budgets: a valid SCIM token is accepted from a blocked IP; IPv6 is counted per /64; with the Postgres backend replicas share one budget per address and endpoint class internal/server/hardening_test.go:TestSCIMValidTokenFromBlockedIP, internal/server/hardening_test.go:TestLimiterKeysIPv6By64, internal/server/hardening_test.go:TestSharedAuthLimiterInMemory, internal/server/hardening_test.go:TestSharedAuthLimiterPostgres, internal/pgstore/revocations_test.go:TestCountAuthFailure
Access-request decisions are never reported unless stored and audited (500 otherwise; a failed save leaves the request pending) internal/server/hardening_test.go:TestDecisionNotReportedUnlessPersisted
Posture is unknown, never current, when the release registry or the shared device registry cannot be read; devices enrolled on another replica count internal/server/hardening_test.go:TestPostureUnknownWhenRegistryUnreachable, internal/server/hardening_test.go:TestPostureSeesDevicesFromOtherReplica
Credential revocation: admin only, reason required, audited fail closed, bounded; a jti kills its lineage and a subject epoch every chain it is in, workload-rooted ones included; every replica refuses at once and a store error refuses; gateways refuse within the 30-second list refresh and follow the directory fail mode internal/server/revocations_test.go:TestRevocationAPI, internal/server/revocations_test.go:TestRevocationCap, internal/server/revocations_test.go:TestRevokedJTIKillsLineage, internal/server/revocations_test.go:TestRevokedSubjectEpoch, internal/server/revocations_test.go:TestRevocationsAcrossReplicasInMemory, internal/server/revocations_test.go:TestRevocationsAcrossReplicasPostgres, internal/server/revocations_test.go:TestRevocationsFailClosed, internal/server/revocations_test.go:TestRevocationReachesGatewayWithinBound, internal/server/revocations_test.go:TestRevocationLogMerge, internal/gateway/revocation_test.go:TestRevocationListCheck, internal/gateway/revocation_test.go:TestDirectoryRevocations, internal/pgstore/revocations_test.go:TestRevocationsSQL

Report vulnerabilities via the repository’s SECURITY.md.