Skip to content

v0.4.0

Choose a tag to compare

@github-actions github-actions released this 29 Oct 12:33
· 27 commits to main since this release
5865e73

Added

  • Cloud agent governance (ADR-0034) for the Claude Code GitHub Action and cloud sessions, Codex cloud, the Copilot cloud agent, Cursor cloud agents and the Gemini CLI GitHub Action. halos.yaml cloudAgents sets per vendor: enabled, allowed repositories and models, maxConcurrent per user and group, budgets (ADR-0017 fields), sandbox network mode, identity: workload (gateway credential through the ADR-0023 exchange instead of a repository secret) and onOverLimit: alert|pause; identities map GitHub logins to Halos users. halo cloud-agents render writes SHA-pinned, permissions: {} workflows (halos-claude.yml, halos-gemini.yml, copilot-setup-steps.yml) checked against the never-bypass invariant, and releases carry them signed in manifest.json cloudAgents (never written by halod). halo-server receives GitHub workflow_run webhooks (POST /api/v1/cloud-agents/webhooks/github, X-Hub-Signature-256 HMAC, --cloud-webhook-secret-file), stores tasks in the audit chain (cloudagent.task; finished ones also as session ledger records with source cloudagents), raises alerts (halo_server_cloud_agent_alerts_total) and, with onOverLimit: pause, reversibly disables the workflow or archives the Cursor agent. GET /api/v1/cloud-agents (read) and halo cloud-agents list show tasks and alerts; POST /api/v1/cloud-agents/kill (operate) and halo cloud-agents kill --scope user|ring|experiment cancel a user's, ring's or experiment's tasks through the GitHub and Cursor APIs, dry run by default, reason required, audited before any call. An experiment kill reports its users' active cloud tasks. halo admin plan|export add the console steps for Claude cloud sessions, Codex cloud, Cursor and Copilot repository access, and read each repository's Copilot cloud agent firewall.

  • Egress and sandbox policy (ADR-0032). A profile sandbox block (mode = permissions.sandbox, writableRoots, protectedPaths) and an extended egress block (default: allow|deny, deniedDomains, presets: package-registries|vcs|model-providers beside allowedDomains; deny wins, *.x is subdomains only). deniedDomains and protectedPaths union down extends and across units; a unit switching egress to allow, dropping a denied domain or protected path, or adding a writable root is a loosening. Validation rejects bypass-shaped combinations: a writable root of /, ~ or a glob, over a protected path or under read-only, and mode off with egress or path rules. Rendered natively: Claude Code sandbox.network allowed/denied domains with allowManagedDomainsOnly, sandbox.filesystem and Read/Edit/WebFetch(domain:) deny rules; Codex requirements.toml [experimental_network] and [permissions.filesystem] deny_read, writable_roots; Copilot CLI managed sandbox with userPolicy.network/filesystem (pins >= 1.0.93); Gemini CLI tools.sandbox (macOS); OpenCode webfetch and path deny rules; Cursor warns (per-user files only). New sandbox and egress capabilities in halo harnesses. halod reports per-user settings that weaken the managed posture (~/.codex/config.toml danger-full-access, ~/.gemini/settings.json tools.sandbox: false, ~/.cursor/sandbox.json insecure_none) as posture: drift with a fix; halo doctor shows each installed harness's effective sandbox and egress posture, including environment overrides. halo egress test <url> --ring R [--unit U] names the deciding rule and the mechanism each harness enforces it with (exit 1 on deny). Guide: Egress and sandbox. Existing allowlist profiles now also render Codex [experimental_network] with network_access = false, and permissions.sandbox now renders Gemini CLI tools.sandbox on macOS.

  • Gateway data-loss prevention and prompt-injection defense (ADR-0033). Policy gateway.dlp makes halo-proxy and halo-kong scan every model-call request (user text, tool results, files; Anthropic, Bedrock, OpenAI Responses, Gemini) with twelve high-precision secret detectors (AWS, GitHub, GitLab, Slack token and webhook, Stripe, Anthropic, OpenAI, JWT, PEM private keys, GCP service-account keys, key-name plus high-entropy) and org RE2 patterns, and monitor, redact ([REDACTED:<detector>:<hash8>], the JSON otherwise byte-for-byte unchanged, also in the shadow mirror) or block (HTTP 400 in the caller's wire format). Tool results are scored for prompt-injection signals (instruction overrides, fake turns, invisible Unicode, HTML-comment imperatives, exfiltration URLs, permission-bypass tokens) and monitored or blocked above a threshold; model content is never modified. rules set the actions per ring and org unit. halo-proxy also watches streamed responses for echoed secrets (monitor only, bytes unchanged). Bounded by maxScanBytes and a 250 ms budget (200 KB request: about 5 ms p99). Findings never carry the secret: halo_proxy_dlp_* and halo_kong_dlp_findings_total metrics, a dlp request-log attribute, audit entries dlp.secret / dlp.injection through the new POST /api/v1/gateway/dlp/events (--gateway-dlp-token-file; halo-proxy dlp.url, halo-kong dlp_url) so the SIEM exporter carries them, and dlpSecrets / dlpInjections in the session ledger. halo dlp test <file> dry-runs the scan locally against a policy, ring and unit.

  • Policy copilot (ADR-0035): the model proposes, Halos verifies, a human approves. halo copilot draft "<intent>" has a model draft whole policy files and loops on Halos' own checks (bypass-token scan over every written byte, validation, a release plan per ring, the impact preview) until they pass or a hard ceiling (--max-iterations, --max-tokens, --max-cost-usd) stops it; --apply commits a passing draft to a new local halos/copilot-* branch and never pushes. halo copilot experiment "<hypothesis>" takes the arms and metrics from the model and the baseline, sd and MDE from the human; stats.SampleSize computes the per-arm sample size and duration, and Halos renders a status: draft experiment. halo copilot explain release|diff|experiment rejects any point that does not cite a Halos fact key. halo copilot next suggests next steps from verdicts, eval scorecards, ROI and budgets, each with evidence and the exact command. Model replies are validated against a Go-side schema and retried; each call logs token counts only (prompts with --debug); the default model is claude-opus-5-5 through the policy's gateway with the eval judge's client (eval.HTTPLLM now reports token usage). --offline uses deterministic templates. MCP: copilot_draft, copilot_experiment (dry run by default; commits need --allow-writes and a reason), copilot_explain, copilot_next and the copilot prompt; the calling agent is the model, so they never call a provider.

  • Console: a Rollouts page (step timeline, per-gate status, bake countdown, halt and rollback events, ring-to-release pointers) and a Cost & budgets page (spend against limit per scope, window, warn/enforce mode and alertAt thresholds); Fleet gains per-ring compliance, a drift count per device and an "in sync" filter; Device lists the drifted settings; Overview links to both new pages. Read-only; no server API changes.

  • Copilot CLI lines of code in halo.loc. github.copilot.code.lines_added and github.copilot.code.lines_removed are mapped to halo.loc with a type attribute (added / removed), like Claude Code's lines-of-code metric. Codex's native lines metric stays unmapped (vendor metric name not verified).

  • Randomized delivery ROI (ADR-0027): halo roi ingest github|file imports pull-request outcomes (cycle time, review rounds, lines changed, a revert/hotfix change-failure proxy) read-only from GitHub GraphQL or a generic CSV/JSONL export into ClickHouse halo_delivery_prs or a local JSONL store. halo roi report --experiment X maps authors to Halos subjects with an identity roster, recovers each developer's variant with the fleet's own assignment, and estimates per-arm effects with developer-clustered bootstrap CIs, an SRM check, novelty time slices, an honest insufficient_power state and cost per merged PR; --rings comparisons are labelled observational. The MCP tool roi_report is read-only, and promotion PRs (halo exp promote --roi-identities, propose_promotion) carry an informational ROI section that never affects the verdict. internal/stats gains ClusterBootstrapDiff and SRM.

  • Agent session ledger (ADR-0031): one vendor-neutral, identity-linked record per agent session (who and the delegated agent chain, harness and version, release/ring/experiment variant, models, MCP servers and tools, hashed or dropped repos and files, tokens, cost, start/end, outcome, evidence sources and confidence). halo sessions import reads halo-proxy GenAI spans and Claude Code OTel events from ClickHouse, the Anthropic Compliance API, the GitHub enterprise audit log and JSONL; records are redacted fail-closed (no field can hold prompt text; paths dropped or HMAC-hashed) and stored by halo-server in the hash-chained audit log (session.record), so they are tamper-evident and reach the SIEM through the ADR-0016 exporter. Identity is joined server-side from the SCIM directory only. halo sessions list|show|export, GET /api/v1/sessions[/{id}] (audit role), POST /api/v1/sessions (admin), the read-only MCP tool session_ledger, and the ClickHouse table halo_session_ledger (--write-clickhouse).

  • Change governance (ADR-0030). halos.yaml governance.freezes declares weekly and one-off change-freeze windows (IANA zones, wall-clock across DST, scoped to rings or units); halo-server adds an audit-strict incident freeze (POST /api/v1/freeze, admin; halo freeze on|off --reason). During a freeze the rollout controller does not advance, and halo rollout advance, halo exp promote and the MCP propose_rollout_advance/propose_promotion tools refuse with the windows and the next open time. Rollbacks, pauses and the kill switch are never held. halo freeze status shows each ring; halo freeze check --event is a required status check for policy-repo PRs and the merge queue (windows from the base branch; rollbacks, recognised from the diff, always pass).

  • Approver quorums (governance.approvals): N approvals from IdP or SCIM groups, never counting the requester, optionally from distinct groups and with a ticket pattern. Enforced on access requests and rollout steps (POST /api/v1/rollouts/{name}/approve, halo rollout approve --ticket), every approval audit-strict; advance PR bodies record the ticket and approvers.

  • Impact preview: halo plan --diff <git ref> (and the MCP plan tool's diff_base) counts the users, devices and agents whose ring, experiment variant or effective settings change, by org unit, with examples, over halo-server's enrolled fleet with --server or a policy-only estimate. Assignment goes through internal/assign.

  • Per-credential revocation (ADR-0026). An admin revokes one workload or delegated agent credential by jti (it and every credential exchanged from it), or every credential issued so far for agent:<name>, workload:<subject> or a human root, without rotating the workload key: POST/GET /api/v1/revocations (admin, reason required, audited fail closed as credential.revoke) and halo agents revoke / halo agents revocations. halo-server refuses a revoked credential at the next exchange or egress decision on every replica (shared halo_revocations table, Postgres migration 0003; a JSONL log on a single replica) and fails closed if it cannot read the list. halo-proxy and halo-kong with directory.url fetch the list with the directory every 30 seconds and refuse with 401 credential_revoked, workload-rooted chains included; past grace without halo-server, failMode: closed refuses and open keeps the last list seen.

  • halo-server request and security metrics on --metrics-listen: halo_server_requests_total and halo_server_request_duration_seconds by route pattern and status class, halo_server_authz_denied_total, halo_server_auth_failures_total{flow,reason} (login, enrollment, token exchange, delegation), halo_server_auth_limited_total and halo_server_gateway_lookups_total (directory, posture, revocations).

  • Agent supply chain and AI-BOM (ADR-0028).

    • supplyChain.artifacts in halos.yaml declares skills, plugins, hooks, slash commands and agent definitions from the policy repo (path), a git commit (git) or a digest-pinned OCI artifact (oci). Each is pinned by sha256 of its sorted sha256sum listing (halo artifact digest). Profiles deliver them with extensions.artifacts; Claude Code places them in its managed directory, other adapters warn.
    • halo release build fetches remote artifacts and fails on a digest mismatch, a branch or tag instead of a commit or digest, or a bad signature. The release manifest pins each artifact's digest and file listing, and halod checks every artifact file against it before writing; a mismatch is refused and reported as drift.
    • Static scan at halo validate and halo release build (and halo artifact scan): hidden Unicode, instructions in HTML comments, permission-bypass tokens, fetch-and-execute, credential paths, exfiltration URLs, large base64 blobs and native executables. Warnings by default; errors with supplyChain.scan: enforce or --strict. supplyChain.allowFindings suppresses by content digest with a mandatory reason.
    • Optional signatures: supplyChain.trustedIdentities (ed25519 or cosign keys), per-artifact signature, requireSignatures; halo artifact sign.
    • Every release build writes a CycloneDX 1.6 AI-BOM (harnesses, rendered files, artifacts, models as machine-learning-model components, MCP servers, A2A agents, policy commit) and an in-toto Statement v1 with SLSA provenance v1. release publish attaches them as a signed OCI referrer. halo release bom <tar | ref> prints and verifies them.
  • MCP registry and authorization (ADR-0029): halos.yaml mcp lists the approved MCP servers, each pinned (an https URL, or an npm/pypi package at an exact version, an OCI image or binary by digest), with tool allow/deny lists, an auth mode (none, oauth with a client id or Client ID Metadata Document, enterprise, halos) and the rings and units that get it; mode: allowlist (default) or warn. Releases render each ring's and unit's servers into all six harnesses' native MCP settings (Claude Code tool denies and headersHelper, Codex enabled_tools/disabled_tools, Gemini CLI includeTools/excludeTools and oauth, OpenCode tools and oauth) and warn per server for what a harness cannot express. halod reports unapproved and version-drifted servers in users' own MCP configs (mcpDrift, with fix hints). halo-server mints 5-minute tokens whose audience is one registry server (POST /api/v1/token/exchange with resource; halo mcp token), never passing the caller's credential on. halo mcp registry import turns an official MCP Registry server.json into a pinned entry.

Changed

  • Actionable CLI errors. A missing halos.yaml now says no halos.yaml in <dir>; run 'halo init' (or 'halo onboard company'), or pass --policy-dir. A missing --ring (render, release build|publish|refresh, rollback, exp promote) lists the rings in the policy; rollback never defaults a ring. release promote also lists them for --from-ring / --to-ring.
  • halo --help is grouped (Get started, Policy & releases, Rollouts & experiments, Fleet & gateway, Agents & MCP, Admin). Command names are unchanged.
  • Current Anthropic model names in examples, docs, scaffolds and fixtures (claude-sonnet-5-5, claude-opus-5-5, claude-haiku-5-5), including the simple-mode defaults. OpenAI and Google names and the dated Bedrock / Vertex ids are unchanged.
  • README and AGENTS.md list every binary make build produces (adds halo-proxy, halo-kong).

Fixed

  • halo artifact digest and halo validate hash working-tree bytes, so a Windows checkout with core.autocrlf=true gave a pin that differed from Linux CI and the remote git fetch. halo init (also --full), halo onboard company and local, and the MCP init_policy tool now write (or append to) .gitattributes with artifacts/** -text; examples/acme-corp has one. Both commands warn, naming each local artifact file with CRLF and the fix. The digest definition is unchanged.

Security

  • Gateway DLP: the exfil-url injection signal runs its regex only near a ![ whose label closes with ]( within 200 bytes. A tool result of ![ repeated cost about 200 regex steps per anchor (229 ms for 256 KB, enough to truncate a scan under load); it now costs under 10 ms.
  • Gateway DLP: the scan-time budget now holds when no regex runs. Anchors that every gate rejects (for example key: repeated) were never charged against the deadline, so a crafted response was scanned in full past the 250 ms budget. near checks the deadline every 1024 anchors, and the response scanner stops once its cumulative scan time reaches the budget.
  • Cloud agent workflows: workloads.audience was interpolated unquoted into the rendered run: script (halo ci token --audience <aud>), so a policy audience such as x; curl ... | sh or one with a newline ran shell or broke the YAML in every repository's workflow. Policy values now reach scripts only as double-quoted env: values read as "$HALO_AUDIENCE"; workloads.audience and issuer audience must match ^[A-Za-z0-9._:/-]{1,128}$; workflow.Check refuses invalid YAML and any run: script that is not one Halos renders verbatim. The rendered audience is now the GitHub Actions issuer's audience when it overrides workloads.audience (it was always workloads.audience, so such jobs failed the exchange).
  • Cloud agent workflows: the rendered Claude Code workflow (halos-claude.yml) ran for any commenter who wrote @claude, including users outside the repository, with contents/pull-requests/issues: write. Every trigger now requires author_association OWNER, MEMBER or COLLABORATOR (comment, review or issue author, per event), like the Gemini workflow. Re-render and commit the workflow.
  • halod posture and MCP drift reads of users' files (as root) open them through an os.Root at the user's home, with O_NOFOLLOW and a device/inode match between the check and the open. Before, a symlinked parent directory (~/.gemini pointing at a root-only directory) was followed, and a file swapped between the Lstat and the open was read.
  • halo-server cloud agent webhooks: a replayed workflow_run delivery re-ran enforcement (alerts, and with onOverLimit: pause vendor pause calls). Each X-GitHub-Delivery id is now claimed once across replicas (shared state store, 72 hours) and checked against the audit log, a replay is answered duplicate, and a delivery with the stored updated_at that does not move the status forward is stale; neither records nor enforces. A delivery that fails before it is recorded releases its claim so GitHub's redelivery still lands.
  • halo-server DLP events (POST /api/v1/gateway/dlp/events) took the gateway name (the audit actor) from the request body under one token every gateway shares, so any gateway could write events as another. New --gateway-dlp-tokens-dir gives each gateway its own token, named for it: its events are attributed to it and a body naming another gateway is refused (422). Behaviour change: events sent with --gateway-dlp-token-file or the shared gateway token are now recorded with actor gateway:unverified and the claimed name in details.claimedGateway.
  • Gateways: credential revocation (ADR-0026) reached halo-proxy and halo-kong only through the SCIM directory URL, so a deployment without SCIM accepted revoked workload and agent credentials until they expired, silently. New directory mode revocations (halo-proxy directory.mode, halo-kong directory_mode) fetches only the revocation list, which halo-server serves without SCIM, and never looks callers up. A gateway that accepts halo-server credentials (identity.workloadPubkeyFile, workload_pubkey) without a directory URL now warns at startup (halo-kong: once, on the first request).
  • Gateway DLP: object keys were never scanned, so a secret used as a key (for example in a tool_use input or a Gemini function response) reached the model under redact and block. Keys are now scanned for secrets and redacted in place like values (the JSON stays valid); a key's finding location is its object's path plus .<key>, never the key itself.
  • Gateway DLP CPU exhaustion: each detector ran its regex on a window (up to 16 KB for PEM, 8 KB for JWT) around every anchor occurrence, and the 250 ms budget was checked only between JSON strings, so a 256 KB string repeating eyJ or -----BEGIN took about 7 s of gateway CPU (4 MB: about 100 s), and the response scanner paid the same inline on the stream. Overlapping windows are now merged and scanned in chunks (one regex pass per detector), submatches are computed only for a match, html-comment-imperative is a linear matcher, and the time budget and a 64 MiB regex-byte cap are checked inside every pass (past either, the scan is truncated: forwarded under monitor/redact, refused under block). The response scanner has the same byte cap and at most 250 ms of scan time per response (then it stops, counted as truncated).
  • Gateway DLP redact and block stopped at 256 matches per string (64 per org pattern) without marking the scan truncated, so secrets past the cap reached the model unredacted. Every match is now kept; the scan budget bounds the work and marks the scan truncated if it runs out.
  • Gateway DLP skipped values by key name at any depth (data, thinking, signature, id, type, ...), so a tool input such as {"data": "<aws key>", "thinking": "<github token>"} passed block with no finding. Opaque blobs are now skipped only at their protocol positions (image source.data, thinking blocks, Gemini inlineData, reasoning encrypted_content), ids and enums only outside user data, and nothing under a tool input, function arguments or response, schema or metadata.
  • Gateway DLP finding locations (logs, the audit log, the 400 block message) carried request object keys verbatim (up to 200 characters), so a secret used as a key in a tool input leaked into them. A location now shows only the wire protocols' own keys; any other key, and every key inside a tool input or function response, is * (for example messages[0].content[0].input.*).
  • halod MCP drift findings, which reach halo-server in the device status, no longer carry the user's own credentials. A finding names the server, its transport (stdio/http), an http server's host and the sha256 of the full definition (new transport, host, definitionSha256 fields); it never carries the command, its arguments, or a URL's userinfo, path or query.
  • halo freeze check no longer trusts the PR. The rollback exemption keyed on the head branch name (halos/rollback-*), so any PR named that way merged during a freeze; the freeze windows were read from the PR's own checkout, so a PR deleting a window passed its own check; and in the merge queue (merge_group) the head branch is empty, so real rollbacks were held. Now --event "$GITHUB_EVENT_PATH" (pull_request or merge_group) or --base REF reads the windows from the base branch and classifies a rollback from the diff: only rollout status paused/aborted, experiment status paused, and ring releases moved back to one that ring pointed at before on the base branch. --branch is refused. The workflow needs actions/checkout fetch-depth: 0.
  • Rollout step approvals no longer survive an edit to the approved change. They were keyed by rollout, step and the release or alias text, so changing the backing experiment's treatment route (or the experiment) after approval kept the approvals. The key now hashes the release digest, alias, backing experiment and the treatment's route for the alias; approvals recorded before this change no longer count and must be given again. change.release must be a sha256:<64 hex> digest (validation and schema).
  • Sandbox writableRoots refuse home directories and system directories. Only / and ~ were refused, so /home, /Users, /Users/alice, /root or /etc passed, and overlap with protected paths compared text, so /Users/alice against ~/.ssh (or ~/.SSH on a case-insensitive file system) was missed; Codex renders protected paths as deny_read only and absolute writable roots verbatim, so such a root was writable there. Validation now refuses a home directory, any system directory (/etc, /usr, /var, /System, /Library, /private, ...) or ancestor of one, and anything under /etc, /usr, /bin, /sbin or /System, and compares overlaps case-insensitively in both ~ and absolute form; the release check applies the same rule (policy.WritableRootProblem) to rendered Codex writable_roots.
  • halod posture checks read sandbox_mode in every [profiles.<name>] of ~/.codex/config.toml, not only the top level, so profile = "yolo" with [profiles.yolo] sandbox_mode = "danger-full-access" is reported as drift. A project's own .codex/config.toml and command-line flags stay out of halod's reach (documented in ADR-0032).
  • halo mcp registry import no longer follows a redirect to plain http or to another host (the default client followed any, including https to http). The response stays capped at 1 MiB.
  • halo plan --diff (and halo freeze check --base/--event) extract the base policy safely on Windows: archive entry names were checked as slash paths but joined with the OS separator, so a name with \ or a drive or stream : could escape the temporary checkout. Names with \ or : are refused and the rest must pass filepath.IsLocal. git archive is streamed with a 64 MiB cap (headers included) instead of buffered whole before the check.
  • halo rollout advance and halo exp promote without --server/HALO_SERVER, and the MCP propose_rollout_advance/propose_promotion tools without --server and HALO_SESSION (in note and next_steps), warn that only the policy's freeze windows were checked, not the incident freeze. Before, an incident freeze was silently ignored. They stay usable offline; the controller and halo freeze check --server still hold the change.
  • halod MCP drift findings, which reach halo-server in the device status, no longer carry the user's own credentials: detail drops a URL's userinfo, query and fragment and replaces any command argument that looks like a credential with [redacted].
  • MCP registry: a stdio package.identifier starting with - is a validation error. It was rendered into npx/uvx/docker run command lines where it reads as an option: an OCI identifier like -v=/:/host passed validation, mounted the host root and ran an unpinned image named in args, bypassing the digest pin.
  • halod MCP drift reads users' MCP configs like its posture check: a regular file only, never through a symlink, opened without blocking. Before, it followed a symlink (halod runs as root, so a user could have it read a root-only file and echo its server commands into findings) and a FIFO in place of a config hung the cycle.
  • halo-server access requests: when the approval that meets a quorum cannot be persisted (the decision after the PR opened, or the approvals after the PR failed), the in-memory request now drops it as well. Before, it stayed in memory only, so a retry saw the quorum met without the store ever recording that approval. The approval's audit entry stays; the approver approves again.
  • Claude Code: permissions.disableBypass now renders permissions.disableBypassPermissionsMode: "disable". Previous releases rendered disableBypassPermissionsMode at the top level of managed-settings.json, where Claude Code's documentation does not read it (https://code.claude.com/docs/en/settings-reference#permissions-disablebypasspermissionsmode), so bypass mode may not have been disabled for Claude Code. The release check now requires the nested key and fails on a top-level key or any other value. Operators should upgrade and re-publish releases.
  • halo-kong refuses a request (500) when Kong cannot return its headers, instead of verifying and stripping an empty header view.
  • halo-kong: when the directory config is invalid and directory_fail_mode is open, a delegated agent keeps its delegation chain. Before, it was treated as its human root: the agent policy (deniedTools, chain model scope, maxDepth) was skipped and the agent could be enrolled in experiments.
  • halo-kong: a posture config that is set but invalid (for example posture_token naming an unset env var) no longer turns the posture gate off. Enforce rings answer 403 and warn rings pass tagged posture=unknown.
  • Gateways: a posture lookup that halo-server refuses (any non-200 below 500, such as 401 or 404) is now a failed verdict. Before, it counted as unknown, and unknown passes.
  • halo-kong: a request that reaches Kong's 1000-header-line cap is answered 431 before identity is derived. Kong truncates the header list silently, so a duplicate identity header past the cap went unseen.
  • Gateways: x-halo-gate and x-halo-killswitch are cleared on every request. Before, they were cleared only when they appeared in the header view the host returned.
  • Identity: OIDC discovery no longer holds a lock across the network. Concurrent requests share one discovery and each waits only until its own deadline. A failed discovery is cached for 30 seconds.
  • Identity: a token that no cached JWKS key verifies (an unknown kid) triggers a JWKS refetch at most once every 30 seconds per issuer, which stops unauthenticated amplification against the IdP. A rotated key is accepted once that window has passed.
  • halo-kong: with HALO_KONG_METRICS_LISTEN set, the plugin serves Prometheus counters on GET /metrics for directory resolution outcomes (including fail-open), posture decisions (pass, fail, deny, unknown), header-cap rejections and agent admission denials.
  • Invariant 1 could be bypassed through command strings: a hook, status line or MCP server command such as claude --permission-mode bypassPermissions or codex --sandbox=danger-full-access passed halo validate and the release build, which only matched exact, case-sensitive values in env and overrides of parseable JSON/TOML. One shared check (policy.BypassToken) now matches bypass modes and the harnesses' bypass CLI flags (--dangerously-skip-permissions, --dangerously-bypass-approvals-and-sandbox, --yolo, --approval-mode yolo, --allow-all*) in any case. Validation runs it on every string in the policy; the release build on every decoded value and then every raw byte of every rendered file, parseable or not; vendor admin sync and halo ci config call the same function. Errors name the field or file and the token. Behaviour change: an instructions file (managed CLAUDE.md, AGENTS.md, ...) that names a bypass token no longer builds.
  • MCP: eval_run and eval_harvest require a reason for a real run (a preview accepts one), like every other write tool.
  • MCP: copy-paste commands in tool output (halo kill, halo eval run|harvest|steps run, halo release publish, halo onboard ..., git push / gh pr create) single-quote every interpolated value, so a hostile name, reason, path or redaction pattern cannot run a command when pasted.
  • MCP: tool path arguments (eval suite, upgrade config, harvest output) are resolved through symlinks with the same containment as policy resources; a symlink that escapes --policy-dir, or a dangling one, is refused.
  • SCIM checks the bearer token before the failed-auth limiter, so a valid IdP token is accepted from an IP a guesser has blocked.
  • Failed-auth limiters key IPv6 clients by their /64, and with the Postgres backend count every failure in the database, so N replicas grant one budget per address and endpoint class instead of N (amends ADR-0021).
  • Access-request approve and deny are audited fail closed (AuditStrict): a decision that cannot be stored answers 500 and stays pending, and one whose audit append fails answers 500 instead of reporting success.
  • Gateway posture catches up with the shared device registry before judging (a device enrolled on another replica counts) and answers unknown, not compliant, when that or the release registry cannot be read.

Fixed

  • Windows (Git for Windows defaults to core.autocrlf=true): a git-pinned supply-chain artifact (git: {url, commit}) is digested over its committed bytes. The fetch checked it out with LF converted to CRLF (and any .gitattributes eol, ident or filter applied), so its digest never matched the pin halo artifact digest printed and halo release build failed. The fetch now checks out with no attribute conversion.
  • Windows: halo plan --diff, halo freeze check and the policy copilot's base read the base policy with core.autocrlf=false and core.eol=lf, so the base's artifact digests match their pins (freeze check exited 2 on every rollback, and plan --diff reported a changed release with no change). The git archive read also drains the tar's trailing padding, which hung it on Windows' small pipe buffer, and stops waiting for git's pipes 5s after it is cancelled. halo-server's policy clone resets with the same settings.
  • MCP resource canonicalization keeps the brackets of an IPv6 literal with a non-default port (https://[::1]:8443/mcp). It rendered https://::1:8443/mcp, an ambiguous audience for Halos-minted MCP tokens.
  • Policy copilot: a regression test pins that a draft leaving a ring profile without permissions.disableBypass (set false, dropped, or a Claude Code override replacing permissions.disableBypassPermissionsMode) fails the check and cannot be applied. Test only; behavior unchanged.
  • halo plan --diff <ref>: when the base policy's supply-chain artifacts cannot be fetched or verified, the plan fails naming the artifact and suggests fixing access or --against with a built release. Before, it warned and diffed against an empty release, showing every setting as new.
  • Gateways: a failed revocation-list fetch is now reported through the directory client's Observe hook as revocations_unavailable, and halo-kong counts it in halo_kong_directory_resolutions_total{outcome="revocations_unavailable"}. Before, it was only logged.
  • Real-CLI UAT: the Claude Code permissions.disableBypassPermissionsMode check now runs as a non-root user and requires Claude Code's own "bypassPermissions mode is disabled by settings" in its debug log, with the session in the default mode. It accepted any non-zero exit before, and the recorded PASS came from Claude Code refusing --dangerously-skip-permissions as root, not from the managed setting; that row is marked superseded until the UAT is re-run.