Repository navigation
v0.4.0
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.yamlcloudAgentssets per vendor: enabled, allowed repositories and models,maxConcurrentper user and group, budgets (ADR-0017 fields), sandbox network mode,identity: workload(gateway credential through the ADR-0023 exchange instead of a repository secret) andonOverLimit: alert|pause;identitiesmap GitHub logins to Halos users.halo cloud-agents renderwrites 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 inmanifest.jsoncloudAgents(never written byhalod). halo-server receives GitHubworkflow_runwebhooks (POST /api/v1/cloud-agents/webhooks/github,X-Hub-Signature-256HMAC,--cloud-webhook-secret-file), stores tasks in the audit chain (cloudagent.task; finished ones also as session ledger records with sourcecloudagents), raises alerts (halo_server_cloud_agent_alerts_total) and, withonOverLimit: pause, reversibly disables the workflow or archives the Cursor agent.GET /api/v1/cloud-agents(read) andhalo cloud-agents listshow tasks and alerts;POST /api/v1/cloud-agents/kill(operate) andhalo cloud-agents kill --scope user|ring|experimentcancel 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|exportadd 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
sandboxblock (mode=permissions.sandbox,writableRoots,protectedPaths) and an extendedegressblock (default: allow|deny,deniedDomains,presets: package-registries|vcs|model-providersbesideallowedDomains; deny wins,*.xis subdomains only).deniedDomainsandprotectedPathsunion downextendsand 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 underread-only, and modeoffwith egress or path rules. Rendered natively: Claude Codesandbox.networkallowed/denied domains withallowManagedDomainsOnly,sandbox.filesystemandRead/Edit/WebFetch(domain:)deny rules; Codexrequirements.toml[experimental_network]and[permissions.filesystem] deny_read,writable_roots; Copilot CLI managedsandboxwithuserPolicy.network/filesystem(pins >= 1.0.93); Gemini CLItools.sandbox(macOS); OpenCodewebfetchand path deny rules; Cursor warns (per-user files only). Newsandboxandegresscapabilities inhalo harnesses.halodreports per-user settings that weaken the managed posture (~/.codex/config.tomldanger-full-access,~/.gemini/settings.jsontools.sandbox: false,~/.cursor/sandbox.jsoninsecure_none) asposture:drift with a fix;halo doctorshows 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]withnetwork_access = false, andpermissions.sandboxnow renders Gemini CLItools.sandboxon macOS. -
Gateway data-loss prevention and prompt-injection defense (ADR-0033). Policy
gateway.dlpmakes 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, andmonitor,redact([REDACTED:<detector>:<hash8>], the JSON otherwise byte-for-byte unchanged, also in the shadow mirror) orblock(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) andmonitored orblocked above a threshold; model content is never modified.rulesset the actions per ring and org unit. halo-proxy also watches streamed responses for echoed secrets (monitor only, bytes unchanged). Bounded bymaxScanBytesand a 250 ms budget (200 KB request: about 5 ms p99). Findings never carry the secret:halo_proxy_dlp_*andhalo_kong_dlp_findings_totalmetrics, adlprequest-log attribute, audit entriesdlp.secret/dlp.injectionthrough the newPOST /api/v1/gateway/dlp/events(--gateway-dlp-token-file; halo-proxydlp.url, halo-kongdlp_url) so the SIEM exporter carries them, anddlpSecrets/dlpInjectionsin 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;--applycommits a passing draft to a new localhalos/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.SampleSizecomputes the per-arm sample size and duration, and Halos renders astatus: draftexperiment.halo copilot explain release|diff|experimentrejects any point that does not cite a Halos fact key.halo copilot nextsuggests 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 isclaude-opus-5-5through the policy's gateway with the eval judge's client (eval.HTTPLLMnow reports token usage).--offlineuses deterministic templates. MCP:copilot_draft,copilot_experiment(dry run by default; commits need--allow-writesand a reason),copilot_explain,copilot_nextand thecopilotprompt; 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
alertAtthresholds); 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_addedandgithub.copilot.code.lines_removedare mapped tohalo.locwith atypeattribute (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|fileimports 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 ClickHousehalo_delivery_prsor a local JSONL store.halo roi report --experiment Xmaps 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 honestinsufficient_powerstate and cost per merged PR;--ringscomparisons are labelled observational. The MCP toolroi_reportis read-only, and promotion PRs (halo exp promote --roi-identities,propose_promotion) carry an informational ROI section that never affects the verdict.internal/statsgainsClusterBootstrapDiffandSRM. -
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 importreads 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 toolsession_ledger, and the ClickHouse tablehalo_session_ledger(--write-clickhouse). -
Change governance (ADR-0030).
halos.yamlgovernance.freezesdeclares 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, andhalo rollout advance,halo exp promoteand the MCPpropose_rollout_advance/propose_promotiontools refuse with the windows and the next open time. Rollbacks, pauses and the kill switch are never held.halo freeze statusshows each ring;halo freeze check --eventis 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 MCPplantool'sdiff_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--serveror a policy-only estimate. Assignment goes throughinternal/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 foragent:<name>,workload:<subject>or a human root, without rotating the workload key:POST/GET /api/v1/revocations(admin, reason required, audited fail closed ascredential.revoke) andhalo agents revoke/halo agents revocations. halo-server refuses a revoked credential at the next exchange or egress decision on every replica (sharedhalo_revocationstable, Postgres migration 0003; a JSONL log on a single replica) and fails closed if it cannot read the list.halo-proxyandhalo-kongwithdirectory.urlfetch the list with the directory every 30 seconds and refuse with 401credential_revoked, workload-rooted chains included; pastgracewithout halo-server,failMode: closedrefuses andopenkeeps the last list seen. -
halo-server request and security metrics on
--metrics-listen:halo_server_requests_totalandhalo_server_request_duration_secondsby 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_totalandhalo_server_gateway_lookups_total(directory, posture, revocations). -
Agent supply chain and AI-BOM (ADR-0028).
supplyChain.artifactsinhalos.yamldeclares 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 sortedsha256sumlisting (halo artifact digest). Profiles deliver them withextensions.artifacts; Claude Code places them in its managed directory, other adapters warn.halo release buildfetches 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 validateandhalo release build(andhalo 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 withsupplyChain.scan: enforceor--strict.supplyChain.allowFindingssuppresses by content digest with a mandatory reason. - Optional signatures:
supplyChain.trustedIdentities(ed25519 or cosign keys), per-artifactsignature,requireSignatures;halo artifact sign. - Every release build writes a CycloneDX 1.6 AI-BOM (harnesses, rendered files, artifacts, models as
machine-learning-modelcomponents, MCP servers, A2A agents, policy commit) and an in-toto Statement v1 with SLSA provenance v1.release publishattaches them as a signed OCI referrer.halo release bom <tar | ref>prints and verifies them.
-
MCP registry and authorization (ADR-0029):
halos.yamlmcplists 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,oauthwith a client id or Client ID Metadata Document,enterprise,halos) and the rings and units that get it;mode: allowlist(default) orwarn. Releases render each ring's and unit's servers into all six harnesses' native MCP settings (Claude Code tool denies andheadersHelper, Codexenabled_tools/disabled_tools, Gemini CLIincludeTools/excludeToolsandoauth, OpenCodetoolsandoauth) and warn per server for what a harness cannot express.halodreports 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/exchangewithresource;halo mcp token), never passing the caller's credential on.halo mcp registry importturns an official MCP Registryserver.jsoninto a pinned entry.
Changed
- Actionable CLI errors. A missing
halos.yamlnow saysno 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;rollbacknever defaults a ring.release promotealso lists them for--from-ring/--to-ring. halo --helpis 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 buildproduces (addshalo-proxy,halo-kong).
Fixed
halo artifact digestandhalo validatehash working-tree bytes, so a Windows checkout withcore.autocrlf=truegave a pin that differed from Linux CI and the remote git fetch.halo init(also--full),halo onboard companyandlocal, and the MCPinit_policytool now write (or append to).gitattributeswithartifacts/** -text;examples/acme-corphas 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
; 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.nearchecks the deadline every 1024 anchors, and the response scanner stops once its cumulative scan time reaches the budget. - Cloud agent workflows:
workloads.audiencewas interpolated unquoted into the renderedrun:script (halo ci token --audience <aud>), so a policy audience such asx; curl ... | shor one with a newline ran shell or broke the YAML in every repository's workflow. Policy values now reach scripts only as double-quotedenv:values read as"$HALO_AUDIENCE";workloads.audienceand issueraudiencemust match^[A-Za-z0-9._:/-]{1,128}$;workflow.Checkrefuses invalid YAML and anyrun:script that is not one Halos renders verbatim. The rendered audience is now the GitHub Actions issuer'saudiencewhen it overridesworkloads.audience(it was alwaysworkloads.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, withcontents/pull-requests/issues: write. Every trigger now requiresauthor_associationOWNER,MEMBERorCOLLABORATOR(comment, review or issue author, per event), like the Gemini workflow. Re-render and commit the workflow. halodposture and MCP drift reads of users' files (as root) open them through anos.Rootat the user's home, withO_NOFOLLOWand a device/inode match between the check and the open. Before, a symlinked parent directory (~/.geminipointing at a root-only directory) was followed, and a file swapped between theLstatand the open was read.- halo-server cloud agent webhooks: a replayed
workflow_rundelivery re-ran enforcement (alerts, and withonOverLimit: pausevendor pause calls). EachX-GitHub-Deliveryid is now claimed once across replicas (shared state store, 72 hours) and checked against the audit log, a replay is answeredduplicate, and a delivery with the storedupdated_atthat does not move the status forward isstale; 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-dirgives 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-fileor the shared gateway token are now recorded with actorgateway:unverifiedand the claimed name indetails.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-proxydirectory.mode, halo-kongdirectory_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_useinput or a Gemini function response) reached the model underredactandblock. 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
eyJor-----BEGINtook 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-imperativeis 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 undermonitor/redact, refused underblock). 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
redactandblockstopped 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>"}passedblockwith no finding. Opaque blobs are now skipped only at their protocol positions (imagesource.data, thinking blocks, GeminiinlineData, reasoningencrypted_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 examplemessages[0].content[0].input.*). halodMCP 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 (newtransport,host,definitionSha256fields); it never carries the command, its arguments, or a URL's userinfo, path or query.halo freeze checkno 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 REFreads 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.--branchis refused. The workflow needsactions/checkoutfetch-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.releasemust be asha256:<64 hex>digest (validation and schema). - Sandbox
writableRootsrefuse home directories and system directories. Only/and~were refused, so/home,/Users,/Users/alice,/rootor/etcpassed, and overlap with protected paths compared text, so/Users/aliceagainst~/.ssh(or~/.SSHon a case-insensitive file system) was missed; Codex renders protected paths asdeny_readonly 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,/sbinor/System, and compares overlaps case-insensitively in both~and absolute form; the release check applies the same rule (policy.WritableRootProblem) to rendered Codexwritable_roots. halodposture checks readsandbox_modein every[profiles.<name>]of~/.codex/config.toml, not only the top level, soprofile = "yolo"with[profiles.yolo] sandbox_mode = "danger-full-access"is reported as drift. A project's own.codex/config.tomland command-line flags stay out of halod's reach (documented in ADR-0032).halo mcp registry importno 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(andhalo 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 passfilepath.IsLocal.git archiveis streamed with a 64 MiB cap (headers included) instead of buffered whole before the check.halo rollout advanceandhalo exp promotewithout--server/HALO_SERVER, and the MCPpropose_rollout_advance/propose_promotiontools without--serverandHALO_SESSION(innoteandnext_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 andhalo freeze check --serverstill hold the change.halodMCP drift findings, which reach halo-server in the device status, no longer carry the user's own credentials:detaildrops a URL's userinfo, query and fragment and replaces any command argument that looks like a credential with[redacted].- MCP registry: a stdio
package.identifierstarting with-is a validation error. It was rendered intonpx/uvx/docker runcommand lines where it reads as an option: an OCI identifier like-v=/:/hostpassed validation, mounted the host root and ran an unpinned image named inargs, bypassing the digest pin. halodMCP 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.disableBypassnow renderspermissions.disableBypassPermissionsMode: "disable". Previous releases rendereddisableBypassPermissionsModeat 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 anddirectory_fail_modeis 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 exampleposture_tokennaming an unset env var) no longer turns the posture gate off. Enforce rings answer 403 and warn rings pass taggedposture=unknown.- Gateways: a posture lookup that
halo-serverrefuses (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-gateandx-halo-killswitchare 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: withHALO_KONG_METRICS_LISTENset, the plugin serves Prometheus counters onGET /metricsfor 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 bypassPermissionsorcodex --sandbox=danger-full-accesspassedhalo validateand 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 andhalo ci configcall the same function. Errors name the field or file and the token. Behaviour change: an instructions file (managedCLAUDE.md,AGENTS.md, ...) that names a bypass token no longer builds. - MCP:
eval_runandeval_harvestrequire areasonfor 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.gitattributeseol,identor filter applied), so its digest never matched the pinhalo artifact digestprinted andhalo release buildfailed. The fetch now checks out with no attribute conversion. - Windows:
halo plan --diff,halo freeze checkand the policy copilot's base read the base policy withcore.autocrlf=falseandcore.eol=lf, so the base's artifact digests match their pins (freeze check exited 2 on every rollback, andplan --diffreported a changed release with no change). Thegit archiveread 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 renderedhttps://::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 replacingpermissions.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--againstwith 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
Observehook asrevocations_unavailable, andhalo-kongcounts it inhalo_kong_directory_resolutions_total{outcome="revocations_unavailable"}. Before, it was only logged. - Real-CLI UAT: the Claude Code
permissions.disableBypassPermissionsModecheck 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-permissionsas root, not from the managed setting; that row is marked superseded until the UAT is re-run.