Security model
The full asset list, attacker models and per-control mapping are in the threat model. This page is the design.
Trust boundaries
Section titled “Trust boundaries”| 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) |
Signing
Section titled “Signing”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.
Signed ring pointers (TUF-lite)
Section titled “Signed ring pointers (TUF-lite)”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).
Signer continuity
Section titled “Signer continuity”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 Breads A’s signed pointer (signature, ring, org, expiry, continuity), fetches the release it names and checks its digest against the pointer. Thering-Atag, which is unauthenticated, is never used as the source. - Rollback needs a matching version.
halo rollback --to <version>resolves tagv<version>and refuses the release unless its signed manifest carries that version, so a retaggedvtag 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 lowerseq, the sameseqand another digest, or no pointer where one was written is refused. Newseq = max(served + 1, recorded + 1, unix now). --expect-digest sha256:<release digest>onrefresh,promoteandrollbackrefuses 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.
Evidence trust
Section titled “Evidence trust”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.
Kill switch
Section titled “Kill switch”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 inhalo-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-serverrefuses 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 withopenssl rand -hex 32. - Freshness. A list is rejected if its
issuedAtis more than 10 minutes old or more than 1 minute in the future. - Replay. A list is accepted only if its
issuedAtis 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) writeexperiment.killandexperiment.unkill(toggle.killandtoggle.unkillfor 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-dirand--gateway-token-file) the server serves no list, answers 501 to kill and unkill, andGET /api/v1/capabilitiesreportskillSwitch: 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, unlesskillSwitch.allowInsecureInCluster(halo-kong:killswitch_allow_insecure_in_cluster) is set for a trusted in-cluster URL. A misconfiguredhalo-kongstill serves traffic but logs at ERROR and addsx-halo-killswitch: misconfiguredto the upstream request. - Notification webhooks are signed and timestamped. The controller’s generic webhook carries
X-Halo-TimestampandX-Halo-Signature: sha256=<hex>, an HMAC-SHA256 overtimestamp + "." + bodyunder 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.
Console roles
Section titled “Console roles”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.denywith the user, path, route, needed permission and the user’s roles. - The console only hides.
/api/v1/meand/api/v1/capabilitiesreport the caller’s roles and permissions so the console can hide what it may not do; the server enforces either way.
Audit log
Section titled “Audit log”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.
SCIM provisioning
Section titled “SCIM provisioning”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.comstill ends the sessions ofalice@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, wherestrings.ToLowerwould have made\u212Aellyshare 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 withdirectory.urlreplace 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_modeonhalo-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.closedanswers 503 in the caller’s protocol. Fail open is for an outage only: a transport error or a 5xx. Any other answer fromhalo-serverthat 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-serveranswers an oversized subject (over 256 characters) as inactive, not with an error. workload:is reserved. A SCIMuserName, an OIDC user claim or a trusted identity header starting withworkload:(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.
Bedrock credentials
Section titled “Bedrock credentials”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.
Verified installs
Section titled “Verified installs”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.
Gateway gates
Section titled “Gateway gates”Two per-ring policies enforce in the traffic path, which developers do not control (ADR-0011; details in delivery):
posture. The gateway askshalo-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 releasehalo-servercannot 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 tokenhalodalready 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-proxyrefuses to start, andhalo-kongrefuses every caller on enforce rings and flagsposture=unknownon 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.
Root-side hardening in halod
Section titled “Root-side hardening in halod”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,
halodverifies 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”.
Identity at the gateway
Section titled “Identity at the gateway”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.
Workload credentials
Section titled “Workload credentials”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,iatnot in the future, and the RS256/ES256/EdDSA allowlist (noneandHS*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 anyauda job asks for, so each rule must also match one of the issuer’s tenant claims exactly, or asliteral/*: GitHubrepository_owner_id,repository_owner,repositoryorrepository_id; GitLab.comnamespace_id,namespace_path,project_idorproject_path; Buildkiteorganization_idororganization_slug; CircleCIoidc.circleci.com/org-id; Googleemailorsub; Entra IDtid.ref: refs/heads/mainalone, orrepository_owner: acme*, failshalo validate. Any other issuer must name itstenantClaim. For a known issuertenantClaimmay only narrow the choice to one of these claims; any other value (ref,environment) failshalo validateand is ignored at runtime, so it can never makeref: refs/heads/maina tenant pin. GitHub, GitLab.com and Buildkite rules that pin only renamable names (norepository_owner_id/repository_id,namespace_id/project_id,organization_id), and GitHub rules with noenvironmentorjob_workflow_ref(sopull_request_targetruns on the base branch match), validate with a warning. The subject must start withworkload:, so a workload never takes a human’s id. - Enforced at runtime, not only by
halo validate.halo-serverand 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 onhalo-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 buildandpublishrefuse any invalid policy. - Single use. Each subject token is exchanged once while valid (by
iss+jti, or by its hash when the issuer sends nojti). - 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-servercall 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 atexpwith 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 setsworkloads.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, appendsworkload.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.
Delegated agents
Section titled “Delegated agents”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
suband 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 unlessagents.experiments(then exactly the root’s arm). - RFC 8693
actchain. 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,allowedModelsand 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 mostmaxTTLSecondsand 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_timeis 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
maxDepthor the root’s profile and unitmaxDepth, a model outside the credential scope,allowedModelsor the models of any actor in the chain, or adeniedToolstool. Tools are read from an explicit list of request shapes (Anthropictools[]andmcp_servers[], OpenAI Responsestools[], GeminifunctionDeclarationsand built-in tool keys, Bedrock ConversetoolConfig.tools[]andadditionalModelRequestFields), 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. WithdeniedToolsset, a body whose tools cannot be read (too deep, malformed, an unknown shape) is refused.x-halo-actorandx-halo-actor-depthare gateway-owned like everyx-halo-*. - Audited.
agent.token.exchangeandagent.egress.alloware fail closed: no credential and no allow without an audit record.agent.token.deniedandagent.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 incrementshalo_server_audit_append_errors_total. Never a token.actor_harnessandactor_sessionare 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.
Credential revocation
Section titled “Credential revocation”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-proxyandhalo-kongwithdirectory.urlfetch the list every 30 seconds and refuse a revoked credential with 401credential_revoked. Whenhalo-serveris unreachable a list younger thangracekeeps serving; past it,failMode: closedrefuses every halo-server credential, andopenkeeps 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.
Header spoofing
Section titled “Header spoofing”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.
Model allowlist fails closed
Section titled “Model allowlist fails closed”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.
Guardrails
Section titled “Guardrails”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.
Delegated units
Section titled “Delegated units”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.
Budget metering
Section titled “Budget metering”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.
What Halos will not do
Section titled “What Halos will not do”- Emit
bypassPermissionsordanger-full-access, including viaoverrides(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.
Limits
Section titled “Limits”- Harness enforcement is only as strong as the harness. Where the matrix says
halodenforces, root can still stophalodor edit its files; on rings withposture: enforcethat device then loses gateway access, andversionGate: enforcerefuses 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 thehalodbinary by sha256 and refuses to install without a pinned checksum. - Shadow is single-turn only (ADR-0004).
- The
halo-kongGo 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.
Tests binding each claim
Section titled “Tests binding each claim”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.