Policy as code

vulnetix ai-firewall apply makes the organisation’s live policy match a file you can review, diff, and commit.

vulnetix ai-firewall export                    # capture what is live today
vulnetix ai-firewall apply --dry-run           # what would change
vulnetix ai-firewall apply                     # change it

The file

.vulnetix/ai-firewall.yaml:

apiVersion: vulnetix.com/v1
kind: AiFirewallPolicy

metadata:
  # Optional, and worth setting. apply refuses to run if this does not match the
  # authenticated org — the commonest way to do real damage with a policy file is
  # to run staging's against production.
  org: 6f2a1c3e-0000-0000-0000-000000000001

spec:
  # Whether apply deletes server objects this file does not mention. Default false.
  prune: false

  settings:
    logsEnabled: false

  baseline:
    enabled: true          # pull in the server's recommended guardrails
    ref: recommended
    exclude:               # baseline guardrail ids to skip
      - pii-phone

  providers:
    - slug: openai
      action: allow        # allow | deny | default
      key:
        fromEnv: OPENAI_PROVIDER_KEY   # never the key itself
    - slug: openrouter
      action: deny

  models:
    - slug: gpt-4o
      provider: openai     # exactly one of provider: or anyProvider: true
      action: allow        # allow | deny
    - slug: claude-sonnet-4-5
      anyProvider: true
      action: allow

  guardrails:
    - name: block-aws-keys
      ruleType: blocked_pattern    # blocked_pattern | max_messages | pii_redact
      action: block                # block | redact | flag
      pattern: '(?i)AKIA[0-9A-Z]{16}'
      priority: 10
      enabled: true
    - name: cap-conversation
      ruleType: max_messages
      action: block
      pattern: "50"                # the integer, as a string
      priority: 50

Fields

FieldTypeRequiredDefaultNotes
apiVersionstringyesMust be vulnetix.com/v1
kindstringyesMust be AiFirewallPolicy
metadata.orguuidnoGuard. apply refuses on a mismatch unless --force
spec.pruneboolnofalseDelete server objects absent from this file
spec.settings.logsEnabledboolnounsetInference logging (metadata only). Paid plans
spec.baseline.enabledboolnofalseCompose the server’s recommended guardrails in
spec.baseline.refstringnorecommendedNamed baseline set
spec.baseline.exclude[string]noBaseline guardrail ids to skip
spec.providers[].slugstringyese.g. openai
spec.providers[].actionenumyesallow | deny | default
spec.providers[].key.fromEnvstringnoEnvironment variable holding the provider key
spec.providers[].key.fromFilepathnoFile holding the provider key (~ expanded)
spec.models[].slugstringyesModel id
spec.models[].providerstringExactly one of this or anyProvider
spec.models[].anyProviderboolfalseExpand across every provider listing the slug
spec.models[].actionenumyesallow | deny
spec.guardrails[].namestringyesThe reconcile key. Must be unique
spec.guardrails[].ruleTypeenumyesblocked_pattern | max_messages | pii_redact
spec.guardrails[].actionenumyesblock | redact | flag
spec.guardrails[].patternstringRE2 regex, or an integer for max_messages
spec.guardrails[].priorityintno0Ascending, lowest first
spec.guardrails[].enabledboolnotrue
spec.guardrails[].baselineIdstringnoWritten by export when the rule came from a baseline

The file is validated before anything is sent: enums, provider xor anyProvider, integer max_messages, and every pattern is compiled. A file with a lookbehind in it fails here rather than uploading a rule that would be silently skipped at request time.

Keys are never in the file

key.fromEnv and key.fromFile name where the key comes from; the key itself is resolved at apply time. A credential in a file that lives in a repository is a credential that gets committed. export never writes a key source at all — the server does not return keys, so there is nothing to write.

Reconcile keys

ObjectKeyed by
providerslug
model(provider, slug)
guardrailname

Guardrails are keyed on the server by UUID, but a UUID is meaningless in a file you are writing by hand. So apply resolves name → UUID from the live policy. That has a consequence:

Warning Guardrail names must be unique within the organisation. apply hard-errors if the file contains a duplicate name, or if the server does. Rename one, or address it by --uuid with policy guardrail instead.

Execution order

Changes are applied in this order, and it is not arbitrary:

guardrails → models → providers → keys → settings

Guardrails go in first. If providers were enabled before the guardrails that constrain them, there would be a window — small, but real — in which the organisation’s traffic was flowing through a firewall that had not yet been told what to block. Tightening a policy must never pass through a state looser than either the old one or the new one.

If a change fails midway, apply stops and tells you exactly how far it got:

Error: applied 3 of 7 change(s), then failed: create guardrail block-aws-keys: 403 Forbidden

A half-applied policy is bad. A half-applied policy you do not know about is worse.

Drift, and --prune

Objects that exist on the server but are not in your file are reported, not destroyed:

Drift (left alone)
  guardrail "Ad-hoc PII rule": on the server, not in this file (pass --prune to delete)

Someone authored that in the dashboard, possibly during an incident, possibly an hour ago. Deleting it because it was absent from a file written last quarter would be the wrong default. Pass --prune (or set spec.prune: true) when you genuinely want the file to be the whole truth — and run --dry-run first, which lists exactly what would be deleted.

--dry-run

Prints the plan and changes nothing:

AI Firewall policy plan (dry run — nothing was changed)

  Policy file    .vulnetix/ai-firewall.yaml
  Baseline       baseline recommended (2026-07-01) composed in

Changes
  Op      Kind       Target             Detail
  create  guardrail  block-aws-keys     blocked_pattern / block, priority 10
  update  guardrail  PII redaction      action flag -> redact, priority 100 -> 20
  create  model      openai/gpt-4o      allow
  update  provider   openrouter         deny

Drift (left alone)
  guardrail "Ad-hoc PII rule": on the server, not in this file (pass --prune to delete)

export

vulnetix ai-firewall export                  # writes .vulnetix/ai-firewall.yaml
vulnetix ai-firewall export --stdout         # to stdout
vulnetix ai-firewall export -f prod.yaml --force

Serialises the live policy. It refuses to overwrite an existing file without --force.

exportapply is a fixpoint: applying what you just exported produces no changes. That is worth knowing, because it means you can safely adopt this workflow on an organisation that has been managed by hand — export, commit, and you have a starting point that is exactly what is already live.

With one exception, and it is a useful one. If the organisation already holds a guardrail whose pattern does not compile, export writes it out faithfully and apply then refuses the file:

Error: .vulnetix/ai-firewall.yaml: guardrails[Broken rule]: pattern does not compile:
  error parsing regexp: invalid named capture: `(?<=orgUuid=)\S+`
  Go uses RE2, which has no lookahead or lookbehind. Drop it — `orgUuid=\S+` blocks
  the same requests as `(?<=orgUuid=)\S+`.

That rule was never being enforced — the gateway skips a pattern it cannot compile. Adopting policy-as-code surfaces it and makes you fix it, which is the correct outcome even though it means the first apply after an export can fail.

In CI

Check that the live policy matches the committed file on every pull request, and apply it on merge:

- name: Check AI Firewall policy
  run: vulnetix ai-firewall apply --dry-run --baseline-required
  env:
    VULNETIX_API_KEY: ${{ secrets.VULNETIX_API_KEY }}

- name: Apply AI Firewall policy
  if: github.ref == 'refs/heads/main'
  run: vulnetix ai-firewall apply --baseline-required
  env:
    VULNETIX_API_KEY: ${{ secrets.VULNETIX_API_KEY }}
    OPENAI_PROVIDER_KEY: ${{ secrets.OPENAI_PROVIDER_KEY }}

--baseline-required matters here. Outside CI, an unavailable baseline is a soft failure — you should not be blocked from applying your own policy because a recommendation service is down. In CI that leniency is wrong: it would apply a policy missing every baseline guardrail and report success. See baseline.

Flags

FlagMeaning
-f, --filePolicy file (default .vulnetix/ai-firewall.yaml)
--dry-runPrint the plan; change nothing
--pruneDelete server objects the file does not mention
--no-baselineDo not compose in the recommended guardrails
--baseline-requiredFail if the baseline cannot be fetched (use in CI)
--catalogUse a local baseline file instead of the server’s
--forceApply even when metadata.org does not match
-o jsonMachine-readable plan