Skip to main content
Policy as code lets you manage Unbound policies the way you manage infrastructure: declared in files, reviewed in pull requests, applied by a command, and recoverable from git. The unit is a policy pack: one YAML (or JSON) file that holds one or more policies, with a name and a version. Every policy in a pack lives in your org as an ordinary policy marked Managed by Code, with the pack name and the policy id stored as its identity. Its name is free text. The unbound pack commands match, update and prune only by that identity, so policies created in the console are never diffed, changed or deleted, whatever they are called. In the console a managed policy can be switched on or off, nothing else: every other change goes through the pack file.
Needs Unbound CLI 1.19.0 or later. Run npm i -g unbound-cli to update, then unbound pack --help to confirm. Every command needs Admin and a CLI key from Configure → API Access.

Quick start

  1. Log in with a CLI key:
  1. Write a pack file. Start from the example below, or, if you already have policies, pick one of these two ways to get a file:
Do not run both into the same path: the second command overwrites the file, and a later sync would then prune whatever the first one had exported.
  1. Check the file and preview the change:
  1. Apply, then commit the file:
From then on, edit the file, bump metadata.version, open a pull request, and run apply (or sync) when it merges.

A complete pack file

The same document is valid as JSON in a .json file.

File format reference

Unknown keys fail validation at every level, so a typo can never become a silently ignored field. config is the one exception: it is sent to the server as-is and validated there, exactly as a console edit would be.

Pack

Every policy

kind: tool

kind: security

kind: model

kind: cost

Commands

All commands take --json where a plan or result is printed.

How apply decides

  • Validate first, write never on failure. Schema, enum values, duplicate ids, and every name (user groups, guardrails, models, labels, MCP action types) are checked before the first request. Any failure stops the whole pack with exit code 2.
  • Match by identity. For each policy the CLI looks for a live policy of this pack with the same id and kind. Found means update, not found means create. Names are compared like any other field and patched when they differ.
  • Updates are patches. Only the fields that differ are sent. Changing the id of a policy is a delete plus a create, because the id is the identity. A change that cannot be patched (an MCP server or a policy type) creates the new policy first under a temporary name and id (<id>-replacing-<stamp>), deletes the old one only once that succeeded, then takes the final name and id. If that last step fails the temporary row still belongs to the pack, so the next apply lists it as an orphan to prune.
  • Prune is opt-in and pack-scoped. Without --prune a managed policy missing from the file is listed as kept. With it, only policies that carry this pack’s identity are deleted. Other packs and console-made policies are never candidates.
  • Conflicts stop everything. A policy of this pack that exists with the same id but a different kind (a tool policy where the file says cost, or a security policy where the file says model), or a name that another policy in the org already uses (names are unique per org), is reported and nothing in that run is written.
  • Drift shows in the plan. Every live field that differs from the file is listed. The one change the console still allows on a managed policy, switching it off, shows as enabled: false -> true (off on the server, on in the pack) with a hint after the table. The next apply turns it back on; set enabled: false in the file to keep it off.
  • Apply is not atomic across policies. A failure part-way leaves the org partially applied. The result lists each policy as created, updated, deleted or failed. Re-running the same file is safe and converges, because every operation is idempotent by name.

Run it from CI

The CLI reads the key from UNBOUND_TOKEN and the API host from UNBOUND_API_URL, so a pipeline needs no unbound login. A typical setup checks drift on every pull request and syncs on merge:
Drop || true on the drift step once you want a pull request to fail when the org has drifted from the files. diff exits 1 only for drift; a login or network problem exits 2, so the failure reason stays readable in the job log.

What to know before you rely on it

  • A removed file does not remove its policies. The server keeps no record of which packs were applied, so sync cannot tell a deleted pack from one that never existed. Use unbound pack delete <pack> to remove a whole pack.
  • The console lock covers edits and deletes, not everything. A managed policy shows a Managed by Code badge; the console refuses every change except enable and disable, and refuses to delete it. Policies created with CLI 1.19.0 before the badge existed are marked on the next apply. There is no way to hand a managed policy back to the console yet; unbound pack delete <pack> removes the whole pack.
  • Unsupported policy types are left alone. A policy of a pack whose type the CLI does not manage (for example a skill or risk-score policy) is reported and never written.
  • MCP action types are compared through the server’s tool list. mcp.actionType: write is stored as the concrete tools it expands to. The CLI expands it the same way for the diff when the server is known; for an unknown server it creates the policy and skips the comparison.
  • metadata.version is advisory. The CLI prints it and nothing gates on it yet.
See Tool Policies, Security Policies and Cost Policies for what each policy kind can express, and Policy Management for the one-at-a-time CLI commands.