> ## Documentation Index
> Fetch the complete documentation index at: https://docs.getunbound.ai/llms.txt
> Use this file to discover all available pages before exploring further.

# Policy as Code

> Keep your policies in versioned YAML packs, review them in pull requests, and sync them to your org with the Unbound CLI

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.

> [!NOTE]
>
> 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:

```bash theme={null}
unbound login --api-key <YOUR_CLI_KEY>
```

2. Write a pack file. Start from the [example below](#a-complete-pack-file), or, if you already have policies, pick **one** of these two ways to get a file:

```bash theme={null}
# Either: export a pack that already exists in the org
unbound pack export k8s-baseline -o policies/k8s-baseline.yaml
```

```bash theme={null}
# Or: bring console-made policies into a new pack. This marks them as managed
# by k8s-baseline (names stay as they are) and writes the pack to the file you name.
unbound pack adopt k8s-baseline --from "Block kubectl secrets" -o policies/k8s-baseline.yaml
```

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.

3. Check the file and preview the change:

```bash theme={null}
unbound pack validate -f policies/                 # schema only, no server contact
unbound pack apply -f policies/k8s-baseline.yaml --dry-run
```

4. Apply, then commit the file:

```bash theme={null}
unbound pack apply -f policies/k8s-baseline.yaml
git add policies/ && git commit -m "Add k8s-baseline pack"
```

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

```yaml theme={null}
# policies/k8s-baseline.yaml
apiVersion: unbound.security/v1
kind: PolicyPack
metadata:
  name: k8s-baseline              # pack slug: every policy below becomes "k8s-baseline/<id>"
  version: 1.2.0                  # bump on every change
  description: Baseline guardrails for coding agents touching Kubernetes
  owner: platform-security        # free text, informational
  labels:                         # free-form, informational
    team: platform

defaults:                         # optional, merged into every policy below
  enabled: true
  scope:
    userGroups: [Engineering]

policies:
  # Tool policy: a terminal command rule
  - id: block-secret-reads        # stable within the pack
    kind: tool
    type: TERMINAL_COMMAND
    action: BLOCK
    commandFamily: custom
    customMessage: Reading cluster secrets is blocked by the k8s-baseline pack.
    config:                       # field -> pattern, passed to the server as-is
      command: "regex:kubectl\\s+(get|describe)\\s+secrets?"

  # Tool policy: an MCP tool rule
  - id: github-writes-need-approval
    kind: tool
    type: MCP_TOOL
    action: REQUIRE_SLACK_APPROVAL
    mcp:
      server: github
      actionType: write           # or: tool: push_files / tools: [push_files, create_pull_request]
    scope:
      userGroups: [Contractors]   # overrides defaults.scope

  # Security policy: guardrails by name
  - id: pii-guard
    kind: security
    priority: 10
    guardrails:
      - name: PII
        action: BLOCK
        threshold: 5

  # Cost policy
  - id: monthly-cap
    kind: cost
    config:
      monthly_budget: 5000
```

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

| Key | Required | Notes |
| - | - | - |
| `apiVersion` | yes | Must be `unbound.security/v1` |
| `kind` | yes | Must be `PolicyPack` |
| `metadata.name` | yes | Pack slug, `[a-z0-9-]`, up to 64 characters. Stored on every policy of the pack as its pack identity |
| `metadata.version` | yes | A string such as `1.2.0`. Informational today; bump it on every change |
| `metadata.description`, `owner`, `labels` | no | Informational, not sent to the server |
| `defaults` | no | `enabled`, `scope` and `labels` merged into every policy; a policy's own value wins, `scope` merges per sub-key |
| `policies` | yes | One or more policies |

### Every policy

| Key | Required | Notes |
| - | - | - |
| `id` | yes | `[a-z0-9-]`, up to 64 characters, unique within the pack. Stored on the policy with the pack name as its identity; changing it is a delete plus a create |
| `name` | no | Display name in the console, free text up to 255 characters, unique in the org. Defaults to the `id` |
| `kind` | yes | `tool`, `security`, `model` or `cost` |
| `enabled` | no | Default `true` |
| `description` | no | Tool policies only |
| `scope.userGroups` | no | User group names. Omit for org-wide |
| `scope.toolTypes` | no | Security, model and cost policies only |
| `config` | no | Sent verbatim |

### `kind: tool`

| Key | Required | Notes |
| - | - | - |
| `type` | yes | `TERMINAL_COMMAND` or `MCP_TOOL` |
| `action` | yes | `AUDIT`, `WARN`, `BLOCK` or `REQUIRE_SLACK_APPROVAL` |
| `customMessage` | for BLOCK and WARN | The text the agent reads back when the policy fires. The key must be present; an empty string is accepted because the server allows it |
| `commandFamily` | TERMINAL\_COMMAND | A family from `unbound policy tool families`, such as `delete_file`, `git_action` or `custom` |
| `config` | TERMINAL\_COMMAND | Field to pattern, for example `{ command: "*sudo su*" }`. Patterns read as exact, glob or regex exactly as in the console; see [Tool Policies](https://docs.getunbound.ai/policies/tool-policies.md) |
| `mcp.server` | MCP\_TOOL | A server from `unbound policy tool mcp-servers`, matched case-insensitively |
| `mcp.tool` / `mcp.tools` / `mcp.actionType` | MCP\_TOOL, exactly one | One tool, a list of tools, or `read`, `write` or `destructive` |

### `kind: security`

| Key | Required | Notes |
| - | - | - |
| `guardrails[]` | one of guardrails or config | Each entry: `name` and `action` from `unbound policy form-data`, which lists the actions each guardrail supports (`BLOCK` and `AUDIT` for PII and Secrets); optional `threshold` (1 to 100, default 1); optional `entities` (names; default all); optional `enabled: false` to keep an entry but switch it off; `routeModel` when the action is `ROUTE` |
| `priority` | no | Lower wins |
| `labels` | no | Label names |
| `config` | one of guardrails or config | Raw config for the routing sub-types |

### `kind: model`

| Key | Required | Notes |
| - | - | - |
| `models.allowAll` | one of | Allow every model, optionally with `models.excluded: [names]` |
| `models.allowed` | one of | Allow only these model names |
| `priority`, `labels`, `config` | no | As for security |

### `kind: cost`

| Key | Required | Notes |
| - | - | - |
| `config.monthly_budget` | yes | Monthly budget in USD |
| `priority`, `labels` | no | As for security |

## Commands

| Command | What it does | Writes |
| - | - | - |
| `unbound pack validate -f <file or folder>` | Parse and validate locally | No |
| `unbound pack diff -f <file or folder> [--prune]` | Print what `apply` would do. Exit 0 when in sync, 1 on drift, 2 on any error (validation, login, network) so CI can tell drift from a broken run | No |
| `unbound pack apply -f <file or folder> --dry-run` | Same plan as `diff`, printed for a human | No |
| `unbound pack apply -f <file or folder>` | Create and update managed policies | Yes |
| `unbound pack apply -f ... --prune` | Also delete managed policies that are not in the file, after a confirmation (`--yes` skips it) | Yes |
| `unbound pack sync <folder>` | Apply every pack file in the folder with `--prune`. The folder is the source of truth for each pack it contains | Yes |
| `unbound pack export <pack> [-o file]` | Write the live policies of a pack as a pack file | No |
| `unbound pack export --all -o <folder>` | One file per pack found in the org. Policies in no pack are counted, not written | No |
| `unbound pack adopt <pack> --from "<policy name>"... [-o file]` | Mark console-made policies as managed by this pack, with an id, and write the resulting pack file (stdout without `-o`). Names are kept | Marks only |
| `unbound pack delete <pack>` | Delete every policy the pack manages, after a confirmation | Yes |

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:

```yaml theme={null}
# .github/workflows/unbound-policies.yml
name: Unbound policies
on:
  pull_request:
    paths: ['policies/**']
  push:
    branches: [main]
    paths: ['policies/**']

jobs:
  policies:
    runs-on: ubuntu-latest
    env:
      UNBOUND_TOKEN: ${{ secrets.UNBOUND_CLI_KEY }}
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: 20
      - run: npm i -g unbound-cli
      - run: unbound pack validate -f policies/
      - name: Show drift on pull requests
        if: github.event_name == 'pull_request'
        run: unbound pack diff -f policies/ --prune || true
      - name: Sync on merge
        if: github.event_name == 'push'
        run: unbound pack sync policies/ --yes
```

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](https://docs.getunbound.ai/policies/tool-policies.md), [Security Policies](https://docs.getunbound.ai/policies/security-policies.md) and [Cost Policies](https://docs.getunbound.ai/policies/cost-policies.md) for what each policy kind can express, and [Policy Management](https://docs.getunbound.ai/cli/policies.md) for the one-at-a-time CLI commands.


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.