Sealrdocs
Guides

Writing policies

Author Sealr policy YAML — bindings, rules, the four decisions, per-team and per-agent rules, and how a signed bundle reaches your recorders.

Guards classify. Policies decide.

A guard reads the operation with its real grammar — the PostgreSQL statement, the Terraform plan JSON, the git argv — and reports what it is: a verb class, a risk class, a set of reason codes, a structural summary. It never decides anything. A policy takes that classification and turns it into one of four decisions: ALLOW, WARN, REQUIRE_APPROVAL, BLOCK.

That split is why you can change what happens on production without touching a guard, and why two teams can share a database and still be governed differently.

The shape of a policy

A policy is one YAML document with four top-level keys.

schema: sealr/policy/v1
name: prod-databases
bindings:
  - resources: ["db:prod-*"]
    mode: enforce
    critical: true
    stale_action: keep_enforcing
    on_approval_unavailable: block
rules:
  - id: block-unbounded-writes
    match: { guard: sql, reason_any: [SQL_UNBOUNDED_WRITE, SQL_TAUTOLOGY_WHERE] }
    decision: BLOCK
  - id: approve-destructive-prod
    match: { guard: sql, risk_at_least: HIGH }
    decision: REQUIRE_APPROVAL
    approval: { approvers_group: dba-oncall, min_approvers: 1, ttl: 1h, scope: operation }
  - id: default
    match: {}
    decision: ALLOW

schema must be exactly sealr/policy/v1. name must be non-empty. Unknown keys are rejected on both the authoring path and the recorder path, so a typo fails validation rather than silently disabling a rule.

Bindings: where the policy applies, and how hard

A binding attaches enforcement settings to resource patterns.

bindings:
  - resources: ["db:prod-*", "db:payments-prod"]
    mode: enforce
    critical: true
    stale_action: fail_closed
    on_approval_unavailable: block
FieldValuesDefaultMeaning
resourcesglob-lite patternsrequired* spans any run, ? one character, anchored at both ends
modeobserve | enforcerequiredobserve records the verdict and never blocks
criticalbooleanfalseguard errors, panics and timeouts fail closed here instead of open
stale_actionkeep_enforcing | observe | fail_closedkeep_enforcingwhat to do once the cached bundle is over 24 h old
on_approval_unavailableblock | allow_warnblockwhat to do when an approval is required and the Console cannot be reached

Two consequences worth internalising:

  • A resource with no binding stays in observe. Its rules still evaluate and the verdict is still recorded, so you can see what would have been blocked before you turn anything on. That is the intended way to adopt enforcement.
  • A policy with no bindings applies everywhere. Use that for an org-wide baseline; use bindings for anything narrower.

mode: observe beats every BLOCK rule in the policy. A rule that reads as a hard block is only a hard block when the binding covering that resource says enforce.

Rules: first match wins

Each rule has an id, a match, a decision, and — only for REQUIRE_APPROVAL — an approval block.

rules:
  - id: no-privilege-changes-from-agents
    match:
      guard: sql
      verb_class: privilege
      resources: ["db:prod-*"]
    decision: BLOCK

Every condition present in a match must hold. Within a list-valued condition, any entry matching is enough.

ConditionTypeMatches when
guardsql | terraform | git | genericthat guard produced the classification
reason_anylist of reason codesthe guard emitted any one of them
risk_at_leastLOW | MEDIUM | HIGH | CRITICALguard risk is at or above the threshold
verb_classstringexact match on the guard's verb class
integrationstringexact match on the integration name
resourcespatternsthe target resource matches any pattern
agent_kind_anypatternsthe acting surface matches any pattern
env_labels_anypatternsthe recorder carries a label matching any pattern

Verb classes come from the guard: SQL emits read, bounded_write, unbounded_write, structural_ddl, destructive_ddl, privilege; git emits git_secret, git_force_protected, git_ref_delete, git_protected_push; Terraform emits tf_protected_delete, tf_delete, tf_replace, tf_apply, tf_state, tf_hcl.

Evaluation is first-match-wins across all policies, with one ordering rule that matters: policies whose bindings cover the resource are evaluated before those that do not. Without it, a database policy's trailing default: ALLOW would answer for repo:acme/api and silently disable your git rules — a misconfiguration with no symptom until an incident.

The mandatory catch-all

The last rule must have an empty match (match: {}), and no earlier rule may. Both the Console and the recorder reject a policy that breaks either half.

  - id: default
    match: {}
    decision: ALLOW

This is deliberate. A policy that can fall off the end has an implicit decision nobody wrote down, and you would find out which one it was during an incident.

Varying rules per team and per agent kind

env_labels_any matches the labels a recorder was enrolled with. agent_kind_any matches the surface that is acting. Both accept glob patterns.

The agent kinds the recorder emits today are exactly these:

Agent kindEmitted by
clisealr run — the command wrapper
mcp-clientsealr proxy mcp — the stdio MCP proxy
claude-codesealr hook pre-tool-use
recorderthe recorder's own lifecycle records

There is no distinct ci agent kind. A CI runner invoking sealr run reports as cli like any other wrapper. To govern CI differently, enroll its recorder with a label (--label env:ci) and match on env_labels_any instead.

Because the label travels with the recorder rather than with the resource, two teams whose agents touch the same database can be governed differently:

schema: sealr/policy/v1
name: shared-analytics-db
bindings:
  - resources: ["db:analytics"]
    mode: enforce
rules:
  # Contractors may read the shared database, never write to it.
  # Reads classify as LOW; every write the SQL guard recognises is MEDIUM or above.
  - id: contractors-read-only
    match:
      env_labels_any: ["team:contractors"]
      guard: sql
      risk_at_least: MEDIUM
    decision: BLOCK

  # An unattended runner needs a human where an interactive developer does not.
  # `env:ci` is a label assigned when the CI recorder was enrolled.
  - id: unattended-runs-need-a-human
    match:
      env_labels_any: ["env:ci"]
      risk_at_least: MEDIUM
    decision: REQUIRE_APPROVAL
    approval: { approvers_group: data-oncall, min_approvers: 1, ttl: 1h }

  # A write arriving through an MCP tool is annotated; a developer's own CLI is not.
  - id: mcp-tools-warn-on-writes
    match:
      agent_kind_any: ["mcp-*"]
      guard: sql
      risk_at_least: MEDIUM
    decision: WARN

  # Everyone else: recorded, not gated.
  - id: default
    match: {}
    decision: ALLOW

Labels are assigned when the enrollment token is redeemed:

sealr enroll --endpoint https://console.example.com \
  --token <single-use-token> \
  --label team:contractors --label env:prod

The grouping is therefore established at install time and cannot be changed by the agent it governs.

Per project, scope by resource pattern instead. A project's resources carry their own identities (db:payments-prod, repo:acme/payments-*), and a policy bound to those patterns governs only that project's estate.

Approval blocks

An approval block is valid only on a REQUIRE_APPROVAL rule; attaching it to any other decision fails validation.

  - id: tf-destroy-gate
    match: { guard: terraform, reason_any: [TF_PROTECTED_RESOURCE_DELETE] }
    decision: REQUIRE_APPROVAL
    approval:
      approvers_group: platform-oncall
      min_approvers: 2
      ttl: 30m
      scope: operation
  • min_approvers must be at least 1. It defaults to 1.
  • ttl is <n>s, <n>m or <n>h and must not exceed 24 h. 1d is not a valid unit.
  • scope is operation (this instance only) or a narrow pattern reused for the TTL.

See Approvals for what actually happens to a held operation.

Test a policy before you publish it

sealr policy test policy.yaml \
  --integration sql \
  --input "DELETE FROM users" \
  --resource db:prod-main

This prints the guard's classification and the final policy decision, and records nothing:

Guard: Block (risk Critical) reasons ["SQL_UNBOUNDED_WRITE"]
Policy decision: Block via rule 'block-unbounded-writes' (mode Enforce)

Add --json for the machine-readable form. --integration accepts sql, terraform or git; --input is the SQL text, the plan JSON, or the git argv.

The visual builder and the code editor

The Console offers two ways to author the same document, and one honest boundary between them.

The builder is a form: bindings, rules, decisions, a reason-code picker grouped by guard, and validation that mirrors the recorder's own rules so the server rarely has to be the one to say no. It emits YAML.

The code editor is the YAML itself, with line numbers and syntax colouring.

The builder writes YAML but cannot read it back. There is no YAML parser in the Console's editor, so the builder can only claim to describe the document when the document is byte-for-byte what the builder emits.

In practice:

  • A brand-new policy opens in the builder. There is nothing to lose.
  • An existing policy opens in code. If you switch to the builder and the text did not come from the builder, you get a warning and a preview of what the form would emit, not a silent reinterpretation of your file. Rebuilding from the form is an explicit, confirmed action that discards the hand-written text.
  • A hand-edited policy should therefore stay a code policy. Treat the builder as a way to start, and the file as the source of truth once you have edited it by hand.

Publishing: one signed bundle for the fleet

Authoring does not change anything on its own.

Save a draft. The YAML is validated on save. Each save creates a new version number under that policy.

Simulate. The Console replays your tenant's recorded history (30 days by default, up to 365) through the draft and reports how many operations land on each decision and how many would change relative to what was actually recorded. It replays stored guard output, so it tells you the effect of your rules, not of a different guard version.

Publish. The Console marks that version published, gathers the latest published version of every active policy in the tenant, compiles them into one bundle, bumps the tenant bundle version, and signs the canonical (JCS) bytes with Ed25519.

Recorders pull the bundle, verify the console signature over the exact bytes that were signed, and reject any bundle whose version is not newer than the one they hold. A downgrade is not applied. The refresh is best-effort: an unreachable Console does not stop the recorder, it keeps evaluating against the cached signed bundle.

Check what a recorder is actually holding:

sealr policy show
sealr status

Rolling back publishes a new bundle carrying the older text — the version sequence only moves forward, because published bundles are signed statements about what the fleet was enforcing at a point in time. Archiving a policy removes it from every future bundle; the row and its versions are kept for the same reason.

Staleness

A cached bundle is stale once it is more than 24 hours old without a successful refresh. What happens then is the binding's stale_action:

  • keep_enforcing (default) — carry on with the cached rules.
  • observe — downgrade enforcement to recording only.
  • fail_closed — block on that resource until a fresh bundle arrives.

stale_action is applied today in the MCP proxy path (sealr proxy mcp). The wrapper (sealr run) and the Claude Code hook keep enforcing the cached bundle regardless of its age, which is the keep_enforcing behaviour. sealr status and sealr policy show report staleness on every surface. If you depend on fail_closed or observe, verify it on the surface you actually deploy.

Failure behaviour

Guard timeouts, caught guard panics and evaluation errors fail open and produce a guard_error record — except on a binding marked critical: true, where they fail closed. A safety tool that causes outages gets uninstalled and then protects nothing; a resource you have declared critical deserves the stricter default.

On this page