Policy model
Most repos start in simple mode: halo init writes one halos.yaml (tools, provider, models, gateway, a safety preset and a rollout preset), and Halos generates the documents on this page from it. halo explain prints them; halo eject writes them out. This page describes those documents, which you can also write yourself, alongside simple mode or instead of it.
A policy repo is a directory of YAML documents plus one root file, halos.yaml. Every other document has the common header:
apiVersion: halos.dev/v1kind: Profile # Profile | Ring | Experiment | Gatewayname: baselinelabels: {team: platform}The Go types in internal/policy/types.go are the source of truth. Full field list: policy schema reference.
Root: halos.yaml
Section titled “Root: halos.yaml”The root file names the org and configures identity and the developer portal. It is required; halo validate fails without org. It may also carry the simple-mode keys (tools, provider, models, gateway, telemetry, team, safety, rollout); see simple mode. The example below is a full-mode root without them.
apiVersion: halos.dev/v1kind: Halosorg: acme-corpidentity: issuer: https://acme.okta.com/oauth2/default clientID: halos-portal audience: halos-gateway # expected in gateway bearer tokens; defaults to clientID userClaim: email # default groupsClaim: groups # default adminGroups: [ai-platform] # may approve requests and manage experiments in the portalselfService: enabled: true launchers: [devcontainer, codespaces, laptop] # devcontainer | codespaces | coder | laptop requestable: [mcp-server, ring-opt-in] # mcp-server | model | ring-opt-in | harness catalog: - name: linear url: https://mcp.linear.app/sse enrollmentTTLSeconds: 900 # default 900org must equal the org in every signed ring pointer and release; halod refuses a mismatch. Any OIDC-compliant IdP works. See the self-service portal.
Decoding is strict: an unknown field is an error reported with file and line.
Documents
Section titled “Documents”Gateway
Section titled “Gateway”apiVersion: halos.dev/v1kind: Gatewayname: acmebaseURL: https://ai.acme.exampleprotocols: claude-code: anthropic-messages codex: openai-responsesauth: helperCommand: acme-sso-token --audience ai-gateway ttlSeconds: 900 identityHeader: x-acme-user # only for trusted_header mode; JWT mode does not read itmodels: sonnet: upstream: orchestrator model: us.anthropic.claude-sonnet-4-5-v1:0 opus: upstream: orchestrator model: us.anthropic.claude-opus-4-v1:0upstreams: orchestrator: {url: https://orchestrator.internal.acme.example, kind: orchestrator, serves: [anthropic-messages]} anthropic: {url: https://api.anthropic.com, kind: anthropic}Each harness speaks one wire to the gateway: Claude Code anthropic-messages, Codex openai-responses, Gemini CLI gemini. halo-proxy forwards that wire to the upstream, so the model a harness starts on (its harnesses.<h>.model, else models.default) must route to an upstream that answers it. halo validate checks every harness a ring or client-axis experiment delivers:
- Error: no target of that alias can answer the wire, for example Codex starting on an alias that only reaches
kind: anthropic. The message names the alias and upstream to add. - Warning: the only candidates are
kind: orchestrator, which may translate. Declare what an orchestrator answers withserves: [<wire>, ...](valid onkind: orchestratoronly) and the warning goes away.
Clients request stable aliases (sonnet); the gateway maps them to upstream model ids, which is what lets a model upgrade be a route change. Model ids above are examples; use your own inference profile ARNs. An alias that is not in models is rejected with 400 invalid_request_error (the allowlist fails closed). Alias, upstream, profile, ring, experiment, variant and MCP server names must match ^[a-z0-9][a-z0-9._-]{0,62}$ because they flow into scripts, paths and headers.
Profile
Section titled “Profile”apiVersion: halos.dev/v1kind: Profilename: baselineharnesses: claude-code: {version: "2.1.280"} codex: {version: "0.60.0"}models: default: sonnet allowed: [sonnet, opus] enforce: truepermissions: mode: default deny: ["Read(./.env)", "Bash(curl:*)"] disableBypass: true sandbox: workspace-writemcp: managedOnly: true servers: - name: github url: https://mcp.acme.example/githubhooks: managedOnly: true items: - {event: PreToolUse, matcher: Bash, command: /opt/acme/hooks/audit.sh}telemetry: enabled: true otlpEndpoint: https://otel.acme.example:4318 protocol: http/protobuf logPrompts: falseegress: allowedDomains: [ai.acme.example, github.com, registry.npmjs.org]instructions: | Follow the Acme engineering handbook. Never commit secrets.Notes:
hooks.itemsis the YAML key for the hook list (Go fieldHooks.Hooks).harnesses.<name>.versionis an exact pin and is required for rings. Harness names:claude-code,codex,gemini-cli,copilot-cli.overridesmerges raw harness-native keys last, but only an allowlist of cosmetic/behavioural keys is accepted; everything else is rejected (see guardrails).extendsinherits from another profile; scalars: child wins.- Credentials are never literals: use
${VAR}references resolved on the client.
apiVersion: halos.dev/v1kind: Ringname: ring1-canaryorder: 1profile: baselinerelease: sha256:9f2c... # empty = build from profile (dev only)membership: percent: 5 optIn: true # developers may ask to join from the portalposture: enforce # off | warn (default) | enforce: gateway refuses non-compliant devicesversionGate: warn # off | warn (default) | enforce: gateway checks the CLI version in the User-Agentmembership.users and groups are always in the ring (users are checked first); percent is the share of remaining users hashed in; default: true marks the GA catch-all; optIn lets developers request the ring in the portal. posture and versionGate are the ring’s gateway gates. See rings and releases.
Experiment
Section titled “Experiment”apiVersion: halos.dev/v1kind: Experimentname: opus-5-5-canarytype: canaryaxis: trafficstatus: draftrings: [ring1-canary]variants: - {name: control, weight: 90, control: true} - name: candidate weight: 10 routes: opus: {upstream: orchestrator, model: us.anthropic.claude-opus-5-5-v1:0}metrics: primary: {metric: halo.api.error_rate, direction: decrease} guardrails: - {metric: halo.api.error_rate, direction: decrease, maxRegression: 0.02} - {metric: halo.latency.p95_ms, direction: decrease, maxRegression: 0.15}stopping: {method: msprt, alpha: 0.05, minSamples: 300, maxDays: 14, maxSpendUSD: 500}See experiments.
Guardrails
Section titled “Guardrails”halo validate runs these org guardrails after schema and reference checks; halo release build/publish runs them again, then re-checks the rendered files. They are plain Go (ADR-0007).
| Guardrail | Severity | Rule |
|---|---|---|
| No bypass | error | No profile may set permissions.mode: bypassPermissions. No string anywhere in the policy (hooks, status line, MCP server commands and args, env, overrides, toggles, units, gateway) may contain a permission-bypass mode or CLI flag, in any case: bypassPermissions, danger-full-access, dangerously-skip-permissions, dangerously-bypass-approvals-and-sandbox, --yolo, --approval-mode yolo or --allow-all. auto on the default (GA) ring warns |
| Ring profiles | error / warning | Every ring’s profile must set permissions.disableBypass: true. telemetry.logPrompts on the GA ring warns |
| Telemetry on | error | Every ring’s profile must enable telemetry |
| MCP servers | error | Exactly one of url (https only) or command |
| Egress has gateway | error | A non-empty egress.allowedDomains must include the gateway host |
| Sandbox egress | error | A ring profile with permissions.sandboxRequired: true must set a non-empty egress.allowedDomains (an empty list leaves the sandboxed network open) |
| GA hooks managed-only | error | The default (GA) ring’s profile ships hooks only with hooks.managedOnly: true |
| Override allowlist | error | harnesses.<h>.overrides may set only the allowlisted keys for that harness (list below); any other key is an error, and a harness with no allowlist accepts no overrides |
| Names | error | Identifiers must match ^[a-z0-9][a-z0-9._-]{0,62}$ |
| Widening | warning | A child profile that adds permissions.allow, permissions.ask or egress.allowedDomains entries beyond its parent |
| Secrets | error | Literal-looking credentials in MCP headers or env (prefixes such as sk-, ghp_, AWS key ids, Bearer ..., high-entropy tokens). ${VAR} references pass |
| Reserved env | error | env keys with prefixes OTEL_, ANTHROPIC_, CLAUDE_CODE_, DISABLE_, CODEX_, GEMINI_, GOOGLE_GEMINI_ |
Overrides are an allowlist, not a denylist: only these harness-native keys (dotted paths; a trailing .* allows anything below) may be set, and every other key is an error. Commands (Claude statusLine/fileSuggestion, Codex notify) are never allowed.
| Harness | Allowed override keys |
|---|---|
claude-code |
cleanupPeriodDays, companyAnnouncements, outputStyle, language, theme, spinnerTipsEnabled, spinnerTipsOverride, spinnerVerbs, showTurnDuration, includeCoAuthoredBy, attribution, prefersReducedMotion, respectGitignore |
codex |
model_reasoning_effort, model_reasoning_summary, model_verbosity, hide_agent_reasoning, show_raw_agent_reasoning, file_opener, tui.* |
gemini-cli |
ui.*, general.preferredEditor, general.openEditorInNewWindow, general.vimMode, general.enableNotifications, general.notificationMethod, general.checkpointing.enabled, general.sessionRetention.*, context.fileName, context.importFormat |
A harness not in this table accepts no overrides. Source: allowedOverrides in internal/policy/guardrails.go.
The release build then checks every rendered file with the same function (policy.BypassToken): each decoded JSON/TOML value, then every raw byte of every file whatever its format or whether it parses, so instructions files and scripts are covered too. It also fails if a profile’s disableBypass is not reflected in the output; for Claude Code that means permissions.disableBypassPermissionsMode: "disable", and a top-level disableBypassPermissionsMode (which Claude Code does not read) or any other value of that key fails the build. Only setting names that turn bypass off (disableBypassPermissionsMode, disableYoloMode) are not tokens. That backstop covers adapter regressions and anything an override could merge in after rendering.
Validation order
Section titled “Validation order”Adapters warn rather than drop silently: halo render prints warnings such as codex: profile field "hooks.items" is not supported and was not rendered, and halo release build records them in the release manifest.