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: ALLOWschema 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| Field | Values | Default | Meaning |
|---|---|---|---|
resources | glob-lite patterns | required | * spans any run, ? one character, anchored at both ends |
mode | observe | enforce | required | observe records the verdict and never blocks |
critical | boolean | false | guard errors, panics and timeouts fail closed here instead of open |
stale_action | keep_enforcing | observe | fail_closed | keep_enforcing | what to do once the cached bundle is over 24 h old |
on_approval_unavailable | block | allow_warn | block | what 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: BLOCKEvery condition present in a match must hold. Within a list-valued condition, any
entry matching is enough.
| Condition | Type | Matches when |
|---|---|---|
guard | sql | terraform | git | generic | that guard produced the classification |
reason_any | list of reason codes | the guard emitted any one of them |
risk_at_least | LOW | MEDIUM | HIGH | CRITICAL | guard risk is at or above the threshold |
verb_class | string | exact match on the guard's verb class |
integration | string | exact match on the integration name |
resources | patterns | the target resource matches any pattern |
agent_kind_any | patterns | the acting surface matches any pattern |
env_labels_any | patterns | the 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: ALLOWThis 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 kind | Emitted by |
|---|---|
cli | sealr run — the command wrapper |
mcp-client | sealr proxy mcp — the stdio MCP proxy |
claude-code | sealr hook pre-tool-use |
recorder | the 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: ALLOWLabels are assigned when the enrollment token is redeemed:
sealr enroll --endpoint https://console.example.com \
--token <single-use-token> \
--label team:contractors --label env:prodThe 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: operationmin_approversmust be at least 1. It defaults to 1.ttlis<n>s,<n>mor<n>hand must not exceed 24 h.1dis not a valid unit.scopeisoperation(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-mainThis 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 statusRolling 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.
Evidence format
The .seal bundle in detail — records, the BLAKE3 hash chain, signed Merkle checkpoints, countersignatures, identity, time anchors and payload commitments.
Approvals
How a REQUIRE_APPROVAL verdict holds an operation, who may decide, what is recorded, and what happens when the Console is unreachable.