Security controls
Naming a control, access-control regression patterns, assert_no_secret_leak, and the flowproof audit control map.
A security control is not a special kind of test. It is a property that must hold, expressed as an ordinary deterministic assertion over a recorded flow: a viewer cannot delete, a secret never surfaces in output. The forms below add just enough to NAME a control stably and to assert one class of "this must never appear" that the shared grammar could not spell before. The access-control pattern needs no new step at all (see below); it is composed from grammar you already have.
What v1 ships, stated plainly so nothing here is mistaken for more:
- The
control:block on any flow (a stable id for coverage). assert_no_secret_leak: ${VAR}, the named form only, onapp: agent,app: web, andapp: apiflows (the scanned corpus is the agent trajectory, the page surface text, or theassert_apiresponse bodies). A flow kind with no readable corpus fails as a capability error, not a vacuous pass.flowproof audit, the control map. It reads the structured run recordflowproof runpersists (never re-replays), renders each control-bearing flow's verdict with an evidence pointer, and with--since <run-id>diffs two runs by control id (added, removed, verdict-changed).
Naming a control: the control: block
A flow-level block, at most one per flow, gives the control a stable id:
name: A viewer cannot delete a customer
app: web
url: ${APP_URL}/customers
control:
id: ac.customers.delete.viewer-denied # required
title: Viewer role is denied customer deletion # optional
description: >- # optional
The viewer session may read a customer but the API refuses its DELETE.
steps: [ ... ]The id is author-chosen, dotted, lowercase ([a-z0-9._-]+); a value with
whitespace or an out-of-range character is a parse error. Its one hard job
is STABILITY: it survives renames of the flow file, moves between suites, and
re-records, because it is the join key between what an auditor tracks and
what CI ran. title and description are author metadata. A recommended
(not enforced) convention for the id is
<domain>.<resource>.<action>.<expectation>. Teams mapping to an external
framework (SOC 2, ISO) keep that mapping in their own catalog keyed by the
id; flowproof models no compliance ontology, it provides the stable key.
Uniqueness is a suite property. Two flows in one suite sharing a control
id is a suite-load error naming BOTH flows, because a duplicated join key
would corrupt the coverage map. A lone flowproof run on a single flow sees
only that flow, so it neither checks nor needs uniqueness.
Access-control regression (a pattern, not a step)
The highest-value control in practice is "identity X must be denied action
Y". It is NOT a new assert_no_* subject. "Unauthorized access" is not a
lane the engine observes; it is an attempt the flow performs plus a denial
the shipped grammar already asserts. So the flow is three ordinary moves:
become the identity, perform the attempt, assert the denial.
The one rule that makes it a real control: a denial is only evidence when
the same run proves the identity was alive. If the app returns 403 for
both an unauthorized-but-valid session AND a dead one (an expired token, a
logged-out browser), then a credential that quietly expired reads as a
PASSING control while testing nothing. So a denial flow MUST also assert that
the identity is entitled to succeed at something: a 200 on an action it is
allowed, or a UI fact only the signed-in session shows. A denial flow with no
liveness assertion is an incomplete control.
The worked example lives at
examples/access-control/: a suite.yaml
declaring identities and a viewer-cannot-delete.flow.yaml that carries the
liveness proof and the denial side by side. See it for the full flow.
assert_no_secret_leak: ${VAR} (v1)
The engine already guarantees the TRACE never stores a secret (${VAR}
resolves at the moment of use, only the reference is written). That protects
flowproof's own artifacts. It says nothing about the APP under test, which
can render a connection string into an error or echo a token into a response.
That is the leak this control catches.
v1 ships the named-selector form only (one ${VAR}, or a list):
- assert_no_secret_leak: ${DB_PASSWORD} # one named secret
- assert_no_secret_leak: # or several at once
- ${DB_PASSWORD}
- ${API_TOKEN}Semantics, all inherited from the shared grammar:
- The lane is the run's captured outputs. Which outputs depends on the flow kind (detailed below): a closed corpus, not "everything", so the control can name what it checked. Channels the engine never observed (server logs, third-party sinks) are out of scope and the audit output says so.
- The forbidden event is an occurrence of the resolved secret value in
that corpus. At execution (record) and on every replay, each asserted
${VAR}is resolved through the same resolve-refs machinery and the in-memory corpus is substring-scanned for the resolved value. The trace stores only the variable NAMES; the value is never written or printed. - Whole-run scope. Position in
steps:does not narrow it. - Only names travel. A failure names every matching variable (in a stable order, so a run leaking two secrets reports both), the corpus element it appeared in, and the step index. It never prints the value.
- A secret too short to scan is refused, not weakened. A resolved value
under a small minimum length (4 characters) fails the run at execution, in
the same shape as the
MissingSecreterror, naming the variable and the minimum but never the value (scanning for"1"would fire on any page showing a 1).
Bonus: the record-time scan is a store-guard. On an agent flow the model-boundary trajectory is persisted into the trace as a cassette, so a leaked secret would otherwise be written to disk. The scan runs BEFORE the trace is minted, so a leak fails the run and NO trace is written: the leaked secret never reaches disk. Determinism holds because the corpus is re-observed by the same mechanism at both phases, so an unchanged system yields the same scan and the same verdict.
The corpus depends on the flow kind: an app: agent flow scans the
model-boundary trajectory and its MCP lanes; a web flow scans the surface
text read at each step boundary (not page source, and not continuously
between steps); an api flow scans each assert_api response body. A flow
kind with no readable corpus fails as a capability error rather than passing
vacuously.
One thing is deliberately NOT in v1: the bare form ("scan for every
${VAR} the flow referenced") is deferred until a suite-level secrets:
declaration gives it a defined domain (${APP_URL}, ${API}, and minted
test data legitimately appear in output, so a bare scan would false-fail on
nearly every flow).
flowproof audit: the control map
A suite run already yields per-flow verdicts and writes one structured run
record at .flowproof/runs/<run-id>/report.json. flowproof audit <dir> READS
that record and folds the flows that carry a control: block into a
control-coverage report. It never re-replays: the verdicts come from the record
flowproof run wrote, so audit is a pure rendering and stays fast and
side-effect-free. If no run has been recorded yet, audit refuses with an error
pointing you at flowproof run rather than silently re-running anything.
$ flowproof run examples/access-control # writes the run record
$ flowproof audit examples/access-control # YAML on stdout
$ flowproof audit examples/access-control --json # JSON instead
$ flowproof audit examples/access-control --run <id> # a specific past recordsuite: access-control
run: 2026-07-24T09-14-03Z-a1b2
controls:
- id: ac.customers.delete.viewer-denied
title: Viewer role is denied customer deletion
flow: viewer-cannot-delete.flow.yaml
verdict: pass
evidence:
trace: viewer-cannot-delete.trace.jsonl
- id: sec.assistant.no-db-password-leak
title: The DB password never surfaces in agent output
flow: assistant-no-leak.flow.yaml
verdict: pass
lanes: [secret_leak]
evidence:
trace: assistant-no-leak.trace.jsonl
secrets_checked: ["${DB_PASSWORD}"] # variable names, never values
corpus:
- model-boundary trajectory (cassette request and response bodies)
- MCP lanes
excluded:
- channels the engine never observed (server logs, third-party sinks)Each control row carries an evidence pointer to the trace its proof lives in
(and, for a contained agent flow, any egress destinations containment blocked),
so a reader can go from the coverage map to the underlying artifact. Blocked
destinations appear only when THIS run was contained: they are read from the
recorded trace, so a recording made under containment and replayed on a host
without it would otherwise present another machine's blocks as evidence here.
A flow that engages egress also carries containment: - the tier the run
actually ran under (enforced (linux seccomp), or the honest reason it was
not). lanes says what the flow ASSERTED; containment says what was
ENFORCED. On a host where the mechanism does not exist the flow can still
pass, so without this field a passing row would imply a certification the
run never made.
Diffing runs. flowproof audit <dir> --since <run-id> compares the latest
record against an earlier one, folded by control.id: controls added,
controls removed (present in the older record, gone in the newer - coverage
that shrank), and controls whose verdict changed (old -> new). It exits
non-zero on a regression - a removed control or a control that changed to
fail - so CI catches coverage silently shrinking.
$ flowproof audit examples/access-control --since 2026-07-24T09-14-03Z-a1b2base: 2026-07-24T09-14-03Z-a1b2
head: 2026-07-24T11-02-55Z-9f3c
added:
- id: ac.orders.refund.viewer-denied
verdict: pass
removed: []
changed:
- id: sec.assistant.no-db-password-leak
old: pass
new: failThree verdicts, kept distinct so a report can never launder "we could not check" into "it held":
pass- the control held on replay.fail- the control did not hold.flowproof auditexits non-zero when any control failed.capability-error- the platform could not enforce or observe the lane, or the flow never ran (a missing trace is a capability error naming theflowproof recordto run, never a silent pass).
secrets_checked / corpus / excluded appear only for a flow that ran a
secret-leak scan. The audit surface is a stable file external tooling can
ingest, sourced from the persisted run record at
.flowproof/runs/<run-id>/report.json. Both once-absent pieces now ship on top
of that record: evidence pointers (the evidence.trace on each control row)
and cross-run report diffing (audit --since <run-id>, including
removed-control detection). Retention keeps the most recent 10 records per
suite, pruned after each run, so the --since window stays bounded.