Skip to content
Notes on execution evidence

A guardrail must parse the operation, not the prompt

9 minute readSealr Team

  • guardrails
  • sql
  • terraform
  • git
  • security

Prompt filtering polices an assumed intent. A deterministic guardrail reads the operation actually emitted: PostgreSQL grammar, Terraform plan JSON, git argv. How Sealr returns a local verdict in a few milliseconds, and why the verdict is itself recorded.

An ops agent gets a routine instruction: "purge the expired sessions." A few seconds later, this is what reaches the production database:

DELETE FROM sessions WHERE expires_at < now() OR 1=1

Nothing in the instruction was hostile. Nothing in the model's reasoning looked obviously wrong. And yet the whole table is gone. The only place where this operation is unambiguously readable is the operation itself.

The prompt is not the operation

Prompt filtering polices an assumed intent, upstream, with a probabilistic classifier. Three structural problems follow.

What gets filtered is not what executes. Between the instruction and the DELETE there is a model, tools, intermediate calls. The prompt correlates with what is about to happen; it does not describe it.

Natural language has no decidable grammar for "destructive." "Purge the sessions" is innocuous. The resulting query is either catastrophic or entirely routine, depending on a WHERE clause the instruction never mentions.

A probabilistic filter returns probabilistic verdicts. For a control you will eventually put in front of an auditor, irreproducibility is disqualifying: two evaluations of the same input must return the same result, and that result must be explainable.

Sealr therefore does no prompt filtering, no jailbreak detection, and no inspection of model inputs and outputs. The boundary sits elsewhere: at the last deterministic point before execution, where the operation exists in its final, parseable form. At that point a SQL query is a syntax tree, a terraform apply is a plan JSON, a git push is an argument vector. Three grammars, three parsers, no inference.

SQL: the PostgreSQL grammar, not a regular expression

The SQL Guard parses the query with libpg_query — PostgreSQL's own parser. The choice is not cosmetic: a regular expression looking for a DELETE FROM without a WHERE is defeated by a comment inserted mid-statement, by a writing CTE, by unexpected casing. The real parser sees what the server will see.

On the resulting tree, every statement is classified:

Class Statements Risk Default decision
read SELECT with no writing CTE LOW ALLOW
bounded write INSERT; UPDATE/DELETE with a selective WHERE MEDIUM ALLOW
unbounded write UPDATE/DELETE with no WHERE, or a tautological WHERE CRITICAL BLOCK
destructive DDL DROP TABLE/SCHEMA/DATABASE, TRUNCATE, ALTER ... DROP COLUMN CRITICAL REQUIRE_APPROVAL
structural DDL non-destructive CREATE/ALTER MEDIUM WARN
privilege GRANT/REVOKE/ALTER ROLE HIGH REQUIRE_APPROVAL
session-dangerous COPY ... TO PROGRAM, DO $$, ALTER SYSTEM, pg_read_file CRITICAL BLOCK

Tautologies are detected on the tree, not on the text: three-valued constant evaluation (true / false / unknown), a column compared with itself, constant IN lists, negations, boolean casts. A conservatism rule sits on top: a WHERE clause that references no column at all and does not evaluate to false is treated as unbounded. The guard's adversarial corpus contains these cases:

DELETE FROM users WHERE 1=1;                       -- BLOCK, SQL_TAUTOLOGY_WHERE
DELETE FROM users WHERE id = id;                   -- BLOCK, SQL_TAUTOLOGY_WHERE
UPDATE t SET x = 1 WHERE name = 'z' OR 1=1;        -- BLOCK, SQL_TAUTOLOGY_WHERE
DELETE /*comment*/ FROM t;                         -- BLOCK, SQL_UNBOUNDED_WRITE
WITH d AS (DELETE FROM users RETURNING *) SELECT * FROM d;  -- REQUIRE_APPROVAL, SQL_CTE_WRITE
SELECT 1; DROP TABLE t;                            -- REQUIRE_APPROVAL, max severity

What never happens: a silent ALLOW on input the guard did not understand. A query that fails to parse becomes SQL_PARSE_ERROR with a REQUIRE_APPROVAL default; input above the 1 MB cap is not parsed at all and also returns REQUIRE_APPROVAL; a multi-statement batch is split, each statement classified, and the verdict is the maximum severity.

The metadata produced is structural, never literal: WHERE email = '[email protected]' becomes the skeleton email = <string>, with no values.

The limits are documented rather than hidden: SQL built dynamically server-side (EXECUTE in PL/pgSQL) is opaque to the guard, and a non-PostgreSQL dialect degrades to conservative handling with SQL_DIALECT_UNSUPPORTED.

Terraform: counting destructions in the plan

The Terraform Guard does not read HCL; it reads the output of terraform show -json, the only artifact that states what will actually be destroyed once variables, modules and state are resolved. It walks resource_changes[] and counts:

{
  "address": "aws_db_instance.main",
  "type": "aws_db_instance",
  "change": { "actions": ["delete"] }
}

A delete action produces TF_DELETE_PRESENT; a ["delete","create"] pair produces TF_REPLACE_PRESENT; a deletion whose type matches a protected pattern produces TF_PROTECTED_RESOURCE_DELETE at CRITICAL. The default patterns cover the resources whose destruction cannot be walked back: aws_db_instance, aws_rds_cluster, aws_s3_bucket, aws_dynamodb_table, aws_kms_key, google_sql_database_instance, google_storage_bucket, azurerm_*sql*, azurerm_storage_account, *_iam_*, kubernetes_namespace. The list is extensible by policy.

Above the deletion threshold — three by default — the plan moves to REQUIRE_APPROVAL even with no protected resource involved. Replacements alone trigger a WARN.

Here too, the blind spots are named. Raw HCL arrives as TF_HCL_UNANALYZED: recorded, not analyzed. And state manipulation (terraform state rm, import, taint, apply -replace) is invisible to any plan analysis; it is recognized from the wrapper's argv and moves to REQUIRE_APPROVAL.

Git: argv, refs and secret patterns

The Git Guard accepts three inputs: the argv of a git push captured by the wrapper, a hook context, and GitHub App events ingested server-side.

Event Code Default decision
Force push to a protected branch GIT_FORCE_PUSH_PROTECTED BLOCK
Force push elsewhere GIT_FORCE_PUSH WARN
Direct push to a protected branch GIT_PROTECTED_DIRECT_PUSH REQUIRE_APPROVAL
Remote branch or tag deletion GIT_REF_DELETE REQUIRE_APPROVAL
Secret pattern in a visible diff GIT_SECRET_PATTERN BLOCK

Reading argv instead of intent has a specific payoff: force has several spellings and they all count — --force, -f, --force-with-lease, or a refspec prefixed with +refs/heads/main. Same for deletion: --delete stale and :gone are the same gesture. Default protected branches are main, master, release/*, prod*, configurable in the registry.

Secret detection rests on a small set of high-precision patterns — AWS keys, GitHub tokens, private-key headers, Stripe live keys, database URLs with credentials — plus a Shannon-entropy check on assignments. The tradeoff is deliberate: precision over recall. This is not a DLP product, and the misses are documented honestly. In V1 the GitHub App sees refs, not diffs: content detection requires wrapper or hook visibility.

Four verdicts, and why REQUIRE_APPROVAL is not half a BLOCK

Verdict Effect Use
ALLOW the operation proceeds and is recorded the common case
WARN proceeds, annotated, may notify signal without friction
REQUIRE_APPROVAL held until a human decides legitimate but heavy risk
BLOCK refused, with an explicit error returned to the agent unrecoverable

A binary allow/block model is why guardrails end up uninstalled. Most high-risk operations are legitimate: deprecated tables really do get dropped, an RDS instance really is destroyed during a decommissioning. Blocking them across the board pushes teams to route around the control; allowing them means controlling nothing.

REQUIRE_APPROVAL is the third answer: turning a machine decision into a named human decision. The approval is bound to an authenticated identity, scoped, expiring, closed to the operation's own author, and configurable to two approvers for critical cases. This is the technical control point that Article 14 of the EU AI Act calls human oversight — Sealr supplies the capability, not a compliance judgment.

The division of labor is clean: guards classify, policies decide. risk_class and the reason codes feed YAML rules evaluated first-match-wins with a mandatory default rule; the guard's own decision applies only when no rule matches.

rules:
  - id: block-unbounded-writes
    match: { guard: sql, reason_any: [SQL_UNBOUNDED_WRITE, SQL_TAUTOLOGY_WHERE] }
    decision: BLOCK
  - id: tf-destroy-gate
    match: { guard: terraform, reason_any: [TF_DELETE_PRESENT] }
    decision: REQUIRE_APPROVAL
    approval: { min_approvers: 2 }

The decision is local — and it has to stay that way

Guards are pure Rust libraries: no network, no filesystem, no clock reads. The policy is a signed, versioned cache evaluated in memory; the guard runs under a 25 ms budget; the added-latency targets are on the order of 3 ms p99 in observe mode and 15 ms p99 in enforce mode. The only network wait allowed is the approval channel, and only in enforce mode.

Two reasons. A network call in the verdict path makes a third party's availability a precondition for every production write. And a verdict that depends on remote state is not reproducible: you cannot replay it six months later, which is exactly what a piece of evidence has to support.

Failures are explicit. By default the system fails open, loudly: an evaluation error, a budget overrun or a caught panic produces a guard_error record and the operation proceeds. For resources marked critical, policy inverts that choice and fails closed. Each of those cases is a record, not a silence.

Determinism is also testable. Every guard ships an adversarial corpus — obfuscated tautologies, inserted comments, force-push spellings, plans with mixed actions — and CI requires a 100% match against expected verdicts. Changing an expected verdict means changing the specification.

The verdict is itself part of the file

A guard that decides without leaving a trace has done half the job. Every verdict is written into the evidence stream, in the same hash-chained record as the operation:

{
  "decision": "BLOCK",
  "mode": "enforce",
  "risk_class": "CRITICAL",
  "policy_version": 14,
  "rule_ids": ["block-unbounded-writes"],
  "reason_codes": ["SQL_TAUTOLOGY_WHERE"],
  "guard": { "name": "sql", "version": "1.0.0" },
  "eval_ms": 2
}

Human approvals are recorded and linked to the operation they release; policy and mode changes are recorded too. In observe mode, verdicts are computed and recorded without being enforced — which is how you can state, after the fact, what would have been blocked last month.

One qualification matters more than the rest: this evidence is tamper-evident, not tamper-proof. It proves the integrity, ordering, timing and origin of the recorded stream; it cannot prove that unrecorded events did not happen. Coverage is a deployment property, not a cryptographic one: a guard only sees what passes through the Recorder. A psql run from a laptop, a change made by hand in a cloud console — that is a coverage problem, not a parsing problem. Reconciling server-side events against recorded operations turns some of those bypasses into coverage-gap records; it bounds the limit without removing it.

What this fixes, and what it does not

Parsing the operation does not make an agent safe. It removes one specific class of failure: the catastrophic operation that goes through because nobody read it in its real form. And it produces what a probabilistic filter cannot — a verdict that can be replayed, explained and shown.

The rest — coverage, writing the policies, who approves what — is deployment work. Better to name it than to promise it away. The platform page describes how the two halves, recording and guarding, fit together.

Also worth reading

Logs are not evidence

A log index proves what your pipeline chose to keep. Here is what a hash-chained, checkpointed, countersigned and timestamped stream adds — and where the guarantee honestly stops.