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

# zespan policy

> Author, validate, plan and apply guardrail policies from your repository — with validate running fully offline so it is safe as a pre-commit hook.

`zespan policy` manages [policy-as-code](/policies/as-code) files: the YAML
policies in your repository that compile into guardrails.

## Quick start

<CodeGroup>
  ```bash npx theme={"theme":{"light":"github-light","dark":"one-dark-pro"}}
  npx @zespan/cli policy validate
  ```

  ```bash Installed theme={"theme":{"light":"github-light","dark":"one-dark-pro"}}
  zespan policy validate
  ```
</CodeGroup>

## You must be signed in

`pull`, `test`, `plan` and `apply` check your **role in the organization** that
owns the project. That is a question about a person, so they need a person's
session:

```bash theme={"theme":{"light":"github-light","dark":"one-dark-pro"}}
zespan auth login   # once per machine
zespan link         # once per repository
zespan policy plan --env prod
```

<Warning>
  **An API key cannot run these commands, and the CLI now says so instead of
  sending the request.** `ZESPAN_API_KEY` / `--api-key` authenticates a *project*,
  not a person, and the server has no member role to check for one — so every
  such request was already refused with a `403` that read like an org-membership
  problem. The CLI refuses up front now:

  ```text theme={"theme":{"light":"github-light","dark":"one-dark-pro"}}
  An API key cannot run this command.
  `policy pull`, `test`, `plan` and `apply` check your role in the organization, and an API key authenticates a project, not a person.
  Run `zespan auth login` to sign in from this machine, then re-run this command.
  ```

  If you have a CI job that passes `ZESPAN_API_KEY` to `zespan policy plan`, it
  was never working — see [In CI](#in-ci) for what to run instead.
</Warning>

If both a sign-in and an API key are present, the sign-in wins for these
commands; the API key is left alone for the data plane and
[`zespan doctor`](/cli/doctor). See [the two
credentials](/cli/overview#two-credentials-two-planes).

## Verbs

| Verb       | What it does                                              | Needs the network? | Needs sign-in? |
| ---------- | --------------------------------------------------------- | ------------------ | -------------- |
| `init`     | Scaffolds `policies/` with a starter policy               | **No**             | **No**         |
| `validate` | Checks every policy file                                  | **No**             | **No**         |
| `pull`     | Generates policy files from guardrails that already exist | Yes                | Yes            |
| `test`     | Backtests policies against your recorded traffic          | Yes                | Yes            |
| `plan`     | Shows what applying would change                          | Yes                | Yes            |
| `diff`     | Alias for `plan`                                          | Yes                | Yes            |
| `apply`    | Applies the current policy files                          | Yes                | Yes            |

<Note>
  `validate` and `init` make **no network call and need no credential of any
  kind**. That is deliberate: `validate` is meant to run as a pre-commit or
  pre-push hook, and a hook that needs the network is a hook people disable. The
  parser and the full schema are compiled into the binary.
</Note>

## Flags

| Flag                 | Applies to                      | Meaning                                                                                            |
| -------------------- | ------------------------------- | -------------------------------------------------------------------------------------------------- |
| `--env <name>`       | `pull`, `plan`, `apply`         | Target environment. Defaults to the project default                                                |
| `--project <id>`     | `pull`, `test`, `plan`, `apply` | Project id. Or `ZESPAN_PROJECT_ID`, or [`zespan link`](/cli/link), or `project:` in `.zespan.yaml` |
| `--api-url <url>`    | `pull`, `test`, `plan`, `apply` | For self-hosted deployments. Or `ZESPAN_API_URL`                                                   |
| `--allow-remove`     | `apply`                         | Permit removing policies that left the file set                                                    |
| `--adopt`            | `apply`                         | Permit taking over dashboard-authored guardrails                                                   |
| `--untested`         | `apply`                         | Promote to `deny` with no backtest behind it                                                       |
| `--against <corpus>` | `test`                          | `issues`, `last:7d` or `dataset:<id>` (default `last:7d`)                                          |
| `--all`              | `pull`                          | Include policies already managed in code                                                           |
| `--force`            | `apply`                         | Apply despite a stale plan or a detached-policy conflict                                           |
| `--json`             | `plan`, `apply`                 | Machine-readable output                                                                            |
| `--help`             | any                             | Prints usage, with no network call                                                                 |

Configuration precedence is the same as every other `zespan` subcommand — see
[CLI overview](/cli/overview). As there, the API key and API URL are never read
from `.zespan.yaml`, so a committed config file cannot redirect where your key
is sent.

## `zespan policy init`

```bash theme={"theme":{"light":"github-light","dark":"one-dark-pro"}}
zespan policy init
```

Writes `policies/pii-egress.yaml` with a starter policy in `dryrun` mode. It
refuses rather than overwriting an existing file.

## `zespan policy pull`

```bash theme={"theme":{"light":"github-light","dark":"one-dark-pro"}}
zespan policy pull --env prod
```

Generates one policy file per guardrail that already exists, so adopting an
existing project does not mean transcribing it by hand. By default it covers
guardrails created in the dashboard; `--all` also re-emits ones already managed
in code, which is useful if a file was lost.

Each generated file pins its rule to the existing guardrail with a `slug:`, so
the plan straight afterwards reports no changes to make — only pending
adoptions. Files you have already edited are skipped unless you pass `--force`.

Generation is not lossless and prints a warning for every case it could not
express exactly; see [what `pull` cannot express](/policies/as-code#what-pull-cannot-express).

## `zespan policy validate`

```bash theme={"theme":{"light":"github-light","dark":"one-dark-pro"}}
zespan policy validate
```

Reads every `.yaml`/`.yml` file under `policies/` and validates all of them,
reporting every problem at once with file, line and a message that says what to
write instead. Exits `0` when everything is valid, `1` otherwise.

As a pre-commit hook:

```bash .git/hooks/pre-commit theme={"theme":{"light":"github-light","dark":"one-dark-pro"}}
#!/bin/sh
npx @zespan/cli policy validate || exit 1
```

## `zespan policy test`

```bash theme={"theme":{"light":"github-light","dark":"one-dark-pro"}}
zespan policy test --against issues
```

Runs your policies over the project's own recorded traffic and reports what they
would have caught versus what they would have broken, per rule, with the actual
conversation behind each false positive. See
[Policy testing](/policies/testing).

Promoting a policy to `deny` requires a test result or `--untested`.

## `zespan policy plan`

```bash theme={"theme":{"light":"github-light","dark":"one-dark-pro"}}
zespan policy plan --env prod
```

Prints what an apply would change, and changes nothing:

```text theme={"theme":{"light":"github-light","dark":"one-dark-pro"}}
  + pii-egress   (pii-egress--0)   Block PII in model output
  ~ phi-egress   (phi-egress--0)   PHI egress control
  - stale-policy (stale-policy--0) Retired control

Plan: 1 to add, 1 to change, 1 to remove.
```

The plan is computed on the server against live state, so what the CLI shows and
what an apply would do cannot disagree. Re-running `plan` against unchanged
state prints `No changes.` — reformatting a file, reordering its keys, or adding
comments is not a change, because the comparison runs over a canonical form.

## `zespan policy apply`

```bash theme={"theme":{"light":"github-light","dark":"one-dark-pro"}}
zespan policy apply --env prod
```

`apply` computes a plan first and sends its hash with the apply, so you cannot
apply a plan you never saw. It refuses in the cases below, each naming the flag
that resolves it:

<AccordionGroup>
  <Accordion title="The policy does not list this environment (ENVIRONMENT_NOT_ALLOWED)">
    ```text theme={"theme":{"light":"github-light","dark":"one-dark-pro"}}
    Apply failed (409): 1 policy/policies do not list environment "prod" in appliesTo.environments: staging-only.
    ```

    `appliesTo.environments` is a guard, not a router — it never sends a policy
    somewhere you did not name on the command line, it only refuses to write it
    where the policy says it does not belong. Either point `--env` at an
    environment the policy lists, or add this one to the file. Every offending
    policy is named, not just the first. Checked **before** every other refusal
    below, so a misdirected apply is reported before anything else — see
    [scoping to environments](/policies/as-code#scoping-a-policy-to-environments).
  </Accordion>

  <Accordion title="The state changed since the plan (STALE_PLAN)">
    ```text theme={"theme":{"light":"github-light","dark":"one-dark-pro"}}
    Apply failed (409): The state changed since this plan was computed.
    The state changed since this plan was computed. Re-run `zespan policy plan` and review the new diff.
    ```

    Someone else applied, or changed a guardrail in the dashboard, between your
    plan and your apply. Re-plan and look at the new diff. `--force` overrides.
  </Accordion>

  <Accordion title="A policy was detached in the dashboard (CONFLICTS)">
    ```text theme={"theme":{"light":"github-light","dark":"one-dark-pro"}}
    Apply failed (409): 1 policy/policies conflict with changes made in the UI.
    Those policies were detached in the UI. Re-run with --force to take ownership back in code.
    ```

    Someone took ownership of that policy from the dashboard — often during an
    incident. Applying over it would undo their change. `--force` takes
    ownership back into code.
  </Accordion>

  <Accordion title="A policy would enforce at deny with no backtest (UNTESTED_DENY)">
    ```text theme={"theme":{"light":"github-light","dark":"one-dark-pro"}}
    Apply failed (409): 1 policy/policies would enforce at "deny" without a backtest: hipaa-phi-egress.
    Run `zespan policy test` to see what they would have blocked, or re-run with --untested.
    ```

    Run `zespan policy test --against issues` and look at the false positives.
    `--untested` exists because a platform engineer mid-incident has to be able
    to ship — see [the deny gate](/policies/testing#the-deny-gate).
  </Accordion>

  <Accordion title="The apply would take over a dashboard guardrail (ADOPTION_NOT_ALLOWED)">
    ```text theme={"theme":{"light":"github-light","dark":"one-dark-pro"}}
    Apply failed (409): This apply would take over 2 guardrail(s) created in the dashboard: block-ssn, pii-out.
    Re-run with --adopt to take those dashboard-authored guardrails over in code.
    ```

    Normal straight after `zespan policy pull`. Adopting makes those guardrails
    read-only for whoever built them, so it is never implicit. The row is
    updated in place and keeps its execution history.
  </Accordion>

  <Accordion title="The apply would remove a policy (REMOVAL_NOT_ALLOWED)">
    ```text theme={"theme":{"light":"github-light","dark":"one-dark-pro"}}
    Apply failed (409): This apply would remove 1 policy/policies: phi-egress.
    Removing a policy removes a control.
    Re-run with --allow-remove if that is intended.
    ```

    A policy that used to be in your file set is gone. Removing a guardrail
    removes a control, so it is never implicit. `--allow-remove` makes it
    deliberate.
  </Accordion>
</AccordionGroup>

Nothing is written when any of these fires.

## Every apply names a person

Because these commands run as you rather than as a project key, an apply is
attributed to a real identity. The `policy.applied` audit entry records the
**user**, their **organization**, the **session** the request came from, the
**device** — the machine registered by [`zespan auth login`](/cli/auth#machines-are-remembered) —
plus the IP address and user agent the server observed. The recorded apply itself
names the acting user alongside each file's SHA-256 content hash.

An apply made from the dashboard records no device, because there is no CLI
machine behind it. That is an honest blank rather than a guess.

<Note>
  A control-plane mutation the server cannot attribute to a real user is
  **refused with `401`**, not recorded against a placeholder. There is no
  unattributed apply to find in the audit log, because one can no longer be made.
</Note>

See [Audit log](/platform/audit).

## In CI

```yaml .github/workflows/policies.yml theme={"theme":{"light":"github-light","dark":"one-dark-pro"}}
name: Policies
on: [pull_request, push]

jobs:
  validate:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      # Offline: no credential, no network call.
      - run: npx @zespan/cli policy validate
```

<Warning>
  **`plan` and `apply` cannot run unattended today.** They need a user session,
  and the only way to obtain one is `zespan auth login`, which requires a human to
  approve in a browser. There is no machine credential for the control plane — an
  API key is refused, by design.

  If you previously ran `zespan policy plan` in CI with `ZESPAN_API_KEY`, it was
  returning `403` rather than planning anything. Run `plan` and `apply` from a
  developer machine that is signed in, and keep `validate` — which needs no
  credential — as the CI gate.
</Warning>

Applying needs `policy:apply`, which is granted to **owner** and **admin** only —
`editor` deliberately does not have it, matching how guardrails are already
treated as a high-risk resource. A project you cannot reach is reported by name:

```text theme={"theme":{"light":"github-light","dark":"one-dark-pro"}}
You cannot reach project "Intake Prod" (2845c0ef-…) in "Acme Health" (acme-health).
Either your account is not a member of the organization that owns it, or your role there cannot do this.
Run `zespan projects list` to see what you can reach, and `zespan link` to choose a different project.
```

## Next steps

<CardGroup cols={2}>
  <Card title="Policy as code" icon="file-code" href="/policies/as-code">
    The authoring loop and the ownership model.
  </Card>

  <Card title="File reference" icon="list-check" href="/policies/file-reference">
    Every field and the supported YAML subset.
  </Card>

  <Card title="zespan auth" icon="log-in" href="/cli/auth">
    Sign in — `pull`, `test`, `plan` and `apply` all need it.
  </Card>

  <Card title="zespan link" icon="link" href="/cli/link">
    Link a project so you never pass `--project`.
  </Card>

  <Card title="CLI overview" icon="gear" href="/cli/overview">
    Configuration precedence and install options.
  </Card>

  <Card title="zespan doctor" icon="stethoscope" href="/cli/doctor">
    Diagnose SDK setup problems.
  </Card>
</CardGroup>
