Skip to content

Phase 3 (O.1-O.7): scan controller, trigger policy, and seal provenance - #9

Merged
tom-snyder merged 1 commit into
mainfrom
feat/phase3-scanctl-policy
Aug 9, 2026
Merged

Phase 3 (O.1-O.7): scan controller, trigger policy, and seal provenance#9
tom-snyder merged 1 commit into
mainfrom
feat/phase3-scanctl-policy

Conversation

@tom-snyder

Copy link
Copy Markdown
Member

Summary

The scan controller state machine, deadline clocks, trigger-policy engine, handoff adapter and semver matching — the two-thirds of Phase 3 that isn't gated on M0.18. Plus the O.4 critic gate and its fix round, which turned into the most consequential change in the project so far.

O.4 returned FAIL: 2 blockers, 6 majors, 4 minors.

A documented limit came true within hours

Two rounds ago an adversary attacked the per-half read gate sixteen ways. Attack 14 was: call the gate, but against a HalfSeal you fabricated or held stale, then return the results anyway. I ruled it out of scope for static analysis — correctly; it's a dataflow question, not a reachability one — and had it documented in readpath_test.go's KNOWN LIMITS instead, along with the runtime fix that would actually close it.

O.4 then found that exact attack occurring naturally in the first area written after the ruling. AuditRecord.HalfSeal built a seal from caller-held fields with no refresh path and handed it to the gate, which answered truthfully about a seal that could be arbitrarily stale. It wasn't adversarial — it's simply the natural way to write it.

That's enough evidence to change the call, so the runtime fix is in:

HalfSeal now carries an unexported provenance marker set by exactly two producers. Outside internal/record an unexported field cannot appear in a composite literal, so it's unforgeable by the compiler rather than by convention — and an AST test fails if a third function inside the package ever stamps it. Crucially the marker detects staleness, not merely construction: the audit publishes an atomic facts snapshot on every mutation and the gate re-reads the origin on every call, because a legitimately-minted seal held across a state change was the defect.

Attack Result
Zero value from outside the package REFUSED
Hand-built literal claiming all-clear REFUSED
Field-by-field copy of a genuine seal REFUSED
Upgrade attack — keep real prov, edit exported fields REFUSED (tampered)
Staleness: expire / consume / transition after minting all four caught
Fresh seal after each transition accepted — not welded shut

Neutering the mechanism turns six named tests red, so the fix is measured load-bearing rather than incidentally green. No hand-built HalfSeal remains anywhere in the tree.

Attack 15 remains open and the docs say so plainly — a legitimate SAST seal still opens the gate while the DAST half is unsealed. Probed and confirmed, not assumed.

A denial of service on the scanner itself

Policy glob matching was super-polynomial and unbounded, and the pattern comes from .anvil/policy.yml — supplied by the repository under scan. Anvil scans untrusted repos by design, so this was attacker-controlled input reaching an exponential matcher. The critic's own shape (30 segments, ten wildcards) took 8.51 s; it now takes 0 s.

The first fix bounded each pattern but not the aggregate, leaving the same outage reachable by multiplication: 256 rules × 2 path keys × 64 patterns × 4096 paths = 134 million match calls, 23 s to 9 min per event. Four bounds now, and the fourth is the one that closes it — the engine computes worst-case call count from the policy's shape in O(rules) arithmetic before the first match and refuses over budget.

Enforcement is in the engine at three entry points, not just the JSON Schema, because a policy can reach Evaluate without passing through either. Refusal, never truncation — a silently truncated policy is the worst outcome, since the operator believes rules are in force that aren't. A test asserts a policy exactly at the cap applies all its rules.

One class closed rather than two instances

Two mutation paths in sealing.go skipped publish() — in the one file whose staleness guarantee depends on publishing on every mutation. That leaves a stale seal reading as current: not a degraded check but the check silently inverted, on exactly the path it was built to defend.

Both fixed, and the class closed with a mutation funnel plus an AST test over every fact-bearing assignment — because this project has now paid four separate times for fixing instances of a class instead of the class.

Also fixed: clock 3 had no authoritative substrate; a redelivered seal event bumped audit_version despite the Sealer treating it as an idempotent no-op (a bump re-cuts the queue, so duplicates silently re-cut); applyFindings/applyCorrelation guarded on stale caller state so findings landed on expired audits; concurrent fan-in lost findings while the doc claimed it only skipped a version bump.

Every new guard was verified RED against the pre-fix code, not merely green against the fixed code.

One ruling that contradicts its own packet

O.1 ruled the deadline anchor is scan_run.started_at, not created_at as its packet text said — created_at is a write timestamp, and anchoring to it is a weaker form of what R.6 already forbids. The frozen schema uses that spelling. I agree; flagging it because it contradicts the packet.

Testing

gofmt / vet / build   clean
go test -count=1      green: cmd/anvil, internal/handoff, internal/policy,
                             internal/record, internal/scanctl, internal/store

Not verified here: go test -race cannot run on this Windows host. Every concurrency conclusion in this batch — and one of the majors was concurrent fan-in losing findings — rests on CI's Linux run, which has already caught one real concurrency bug on this project that passed locally. This PR's CI run is the actual check on that.

The scan controller state machine, the deadline clocks, the trigger-policy
engine, the handoff adapter, and semver matching. Plus the O.4 critic gate and
its fix round -- which turned into the most consequential change in the project
so far.

O.4 returned FAIL: two blockers, six majors, four minors.

A documented limit came true within hours

Two rounds ago an adversary attacked the per-half read gate sixteen ways.
Attack 14 was: CALL the gate, but against a HalfSeal you fabricated or held
stale, then return the results anyway. I ruled it out of scope for static
analysis -- correctly, it is a dataflow question, not a reachability one -- and
had it DOCUMENTED in readpath_test.go's KNOWN LIMITS instead, along with the
runtime fix that would actually close it.

O.4 then found that exact attack occurring NATURALLY in the first area written
after the ruling. AuditRecord.HalfSeal built a record.HalfSeal from caller-held
fields with no refresh path and handed it to the gate, which answered truthfully
about a seal that could be arbitrarily stale. It was not adversarial. It is
simply the natural way to write it.

That is enough evidence to change the call, so the runtime fix is now in.

HalfSeal carries an unexported provenance marker set by exactly two producers.
Outside internal/record an unexported field cannot appear in a composite
literal, so it is unforgeable by the COMPILER rather than by convention, and an
AST test fails if a third function inside the package ever stamps it. Crucially
the marker carries enough to detect STALENESS and not merely construction --
the audit publishes an atomic facts snapshot on every mutation and the gate
re-reads the origin on every call -- because a legitimately minted seal held
across a state change was the actual defect.

Verified adversarially, and the fix is measured load-bearing: neutering it turns
six named tests red. Zero value REFUSED. Hand-built literal REFUSED.
Field-by-field copy of a genuine seal REFUSED. Upgrade attack (keep the real
provenance pointer, edit the exported fields) REFUSED as tampered. All four
staleness mutations caught. A fresh seal after each transition is still
accepted, so the gate is not welded shut. No hand-built HalfSeal remains
anywhere in the tree.

Attack 15 REMAINS OPEN and the documentation says so plainly -- a legitimate
SAST seal still opens the gate while the DAST half is unsealed. It was probed
and confirmed rather than assumed.

A denial of service on the scanner itself

Policy glob matching was super-polynomial and unbounded, and the pattern comes
from .anvil/policy.yml -- supplied by the repository under scan. Anvil scans
untrusted repositories by design, so this was attacker-controlled input reaching
an exponential matcher. The critic's own shape (30 segments, ten wildcards) took
8.51 seconds; it now takes zero.

The first fix bounded each pattern but not the aggregate, which left the same
outage reachable by MULTIPLICATION: 256 rules x 2 path keys x 64 patterns x 4096
paths is 134 million match calls, between 23 seconds and 9 minutes per event.
Four bounds now, and the fourth is the one that closes it -- the engine computes
the worst-case call count from the policy's SHAPE in O(rules) arithmetic before
the first match and refuses over budget. Enforcement lives in the engine at
three entry points, not only in the JSON Schema, because a policy can reach
Evaluate without passing through either the schema or the decoder.

Refusal, never truncation. A silently truncated policy is the worst outcome,
because the operator believes rules are in force that are not -- and a test
asserts a policy exactly at the cap applies ALL of its rules.

The rest of O.4, and one class closed rather than two instances

Fixed: clock 3 had no authoritative substrate and moved by field assignment; a
redelivered seal event bumped audit_version although the Sealer treated it as an
idempotent no-op (a bump re-cuts the queue, so duplicates silently re-cut);
applyFindings and applyCorrelation guarded on stale caller state so findings
landed on expired audits; concurrent fan-in lost findings while the doc claimed
it only skipped a version bump.

Two mutation paths in sealing.go skipped publish() -- in the one file whose
staleness guarantee depends on publishing on every mutation. That leaves a stale
seal reading as CURRENT, which is not a degraded check but the check silently
inverted, on exactly the path it was built to defend. Both are fixed, and the
CLASS is closed with a mutation funnel plus an AST test over every fact-bearing
assignment, because this project has now paid four separate times for fixing
instances of a class instead of the class.

Every new guard was verified RED against the pre-fix code, not merely green
against the fixed code.

Also here: O.1 ruled the deadline anchor is scan_run.started_at and NOT
created_at as its own packet text said, because created_at is a write timestamp
and anchoring to it is a weaker form of what R.6 already forbids. The frozen
schema uses that spelling. I agree with the ruling; recording it because it
contradicts the packet.

Evidence: gofmt, vet, build clean; go test -count=1 ./... green across
cmd/anvil, internal/handoff, internal/policy, internal/record, internal/scanctl,
internal/store.

NOT VERIFIED HERE: go test -race cannot run on this Windows host (cgo.exe exits
2, no C toolchain). Every concurrency conclusion in this batch -- and one of the
majors was concurrent fan-in losing findings -- rests on CI's Linux run. CI has
already caught one real concurrency bug on this project that passed locally.
@tom-snyder
tom-snyder merged commit 6bbd95e into main Aug 9, 2026
4 checks passed
@tom-snyder
tom-snyder deleted the feat/phase3-scanctl-policy branch August 9, 2026 07:16
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant