Skip to content

feat: audit follow-ups — console hardening, backup/restore, SBOM and the docs rebuild - #5

Merged
darklordVirtual merged 17 commits into
mainfrom
feat/gate-c-vertical
Aug 6, 2026
Merged

darklordVirtual merged 17 commits into
mainfrom
feat/gate-c-vertical

Conversation

@darklordVirtual

@darklordVirtual darklordVirtual commented Aug 6, 2026

Copy link
Copy Markdown
Owner

Everything on this branch after PR #4 was squash-merged. Two external audits
drove most of it; each finding was verified in code before anything changed.

Security

The dashboard held every credential. Compose handed it operator,
reviewer, domain_expert, senior_authority and viewer — four of which
can approve or execute — because an unauthenticated POST /api/scenarios ran
the scenarios under those roles. No login, no CSRF protection, on a published
port. Running them is a CLI action now; the endpoint is removed, not
guarded (404 because the route does not exist), the dashboard holds one
read-only token, and the image no longer ships REMORA at all.

Nothing was hardened. A survey of the running containers found writable
root filesystems, the full default capability set, no memory or PID ceiling,
ports on every interface, and /metrics served without a token — all
inherited from an upstream pilot whose own comments said "local pilot only".
All fixed and asserted against docker inspect, not the compose file.

Correctness

verify could report more than it verified. With the stack down the e2e
suite reported "14 passed, 91 skipped" and exited 0. AAE_REQUIRE_LIVE turns
those skips into failures; check is now the static variant.

A test that could never fail hid a real defect. It asserted
outcome != "executed", a value the field never holds. Correcting it exposed
the finding: outcome describes the governed step and reports execute
even when the tool raised and the grant was burned. tool_execution.executed
is the field that answers whether anything happened. The product read the
first and meant the second in three places — including the VERIFY scenario,
which passed on every repeat run while the close was failing.

A missing row reported UNOBSERVABLE instead of MISMATCH, collapsing the
one distinction the whole status vocabulary exists for.

The demo was not repeatable. reseed returns the reference work orders to
their seeded state; the audit chain is deliberately never reset. down now
preserves data, reset destroys it.

Claims brought back to what is enforced

network_policy: egress: none declares what a tool requires; nothing here
blocks egress. The event log cannot carry proposal_id — the dispatcher calls
fn(arguments) — so the columns stay NULL and say why. "product code may only
import remora.sdk" was not literally true, and now names its one quarantined
exception.

Added

Backup and restore with a verified manifest and a test that destroys
governance state, restores, and asks REMORA whether the chain still verifies.
A CycloneDX SBOM per image, published by CI, stating what it does not cover.
aae lifecycle renders a readable trail instead of a raw dict.

Building the backup exposed a live hazard: every read-only connection held a
transaction open
, so one stalled verification blocks pg_dump — which is how
a scheduled backup hangs in silence.

Documentation

Rebuilt around one home per kind of information: README, architecture,
security-model, limitations, operations, an attack tutorial, and four ADRs.
ADR 0004 records the honest version of the control-plane boundary — the lock
calls servers internal, the Dockerfile's entrypoint names it, and the
import-boundary test scans Python files, not Dockerfiles.

Verification

189 tests (71 contract, 118 end-to-end), 6/6 scenarios, stable across repeated
runs. CI brings the whole stack up with the same command the README gives a
new developer.

The merge commit uses -s ours because main holds #4 as a squash: every file
main has that this branch lacks is a deliberate removal here, listed and
checked with --diff-filter=A before merging.

The review found both, and both were the same failure: a claim nobody
checked.

1. LICENSING.md was linked from the README and did not exist. GitHub
   reported license: null on a repository whose README asserts BUSL-1.1
   and "commercial production use requires separate written terms".
   That is unenforceable. LICENSE, LICENSING.md, COMMERCIAL_LICENSE.md
   and NOTICE now exist, adapted for AAE rather than copied from REMORA:
   they name Assured Agent Execution as the Licensed Work and state
   explicitly that the pinned REMORA core is a SEPARATE Licensed Work,
   so an AAE license grants nothing in REMORA and vice versa. A
   deployment runs both and needs to be permitted under both.

2. product/core-release-manifest.json had been stale for two pin bumps
   — commit f3e58db, 28 SDK symbols, old digests — while the lock had
   moved to 4c85937 and 36. verify_core_pin.py downloaded five contract
   assets and the wheel, and never looked at the manifest at all.

   Fixed in three places, because one would have been decoration:
   - the file is now the manifest the pinned release actually published;
   - its digest is pinned in the lock and verified (7 artifacts, not 6),
     including the copy checked into this tree — verifying only the
     download would leave the in-tree file free to rot again;
   - test_pin_manifest_agreement.py compares the hand-written lock to
     the release-generated manifest field by field: commit, version,
     every artifact digest, the symbol count three ways, and the
     consumer contract. That last one matters most — if core widened
     its internal namespace set and the lock kept the old list, the
     import-boundary test would keep passing while no longer covering
     the boundary.

   These read JSON only, so they fail before anything is downloaded or
   built.

58 compatibility tests pass (was 46).
… Order ToolPack

## The pin

Moves to the core built from master 4d7b62a, which closes the three
things that kept this product to three of its four decisions:
deployment-declared tool classification, the deterministic
GROUNDED_READ_ACCEPT path, and per-surface production prerequisites.

Every frozen contract artifact is byte-identical to the previous
candidate — public_api_v1.json, openapi.json, and all three schemas.
Only the wheel moved. That is worth stating precisely, and it is only
checkable because the contracts are pinned separately from the code:
the SDK surface AAE depends on did not change, so nothing in
tests/compatibility needed to.

## The ToolPack

Five tools over a Postgres work-order system of record, chosen so each
of the four decisions is reachable by a real action rather than a
contrived one — read (ACCEPT when grounded), priority and create
(VERIFY), close (VERIFY, then effect verification), purge (ESCALATE).

Three separations that are the actual product content:

- Classification is DATA, not code. tool_metadata.json declares the risk
  tiers; registry.py holds only callables. Code that could classify
  itself would be a tool granting itself a risk tier. The file's
  resolved content is hashed into REMORA's policy identity, so editing
  a tier invalidates every outstanding lease.

- Authority is resolved SERVER-SIDE. work_orders.json says what each
  work-order id authorizes; the agent names an id and can never assert
  what it means. That asymmetry is what an ACCEPT rests on.

- Credentials are split at the database. aae_worker reads and writes;
  aae_reader is SELECT only and is what the postcondition reader will
  hold. A verifier that could write would be reporting on itself, and
  store.py refuses to fall back from one DSN to the other — that
  substitution would delete the property silently.

The worker holds no DELETE grant anywhere. purge_work_order_history is
declared and deliberately unimplemented, so the ESCALATE scenario runs
against a tool the deployment genuinely declares rather than an unknown
name, which would test the fail-closed floor instead.

## The dependency I am not hiding

bundle.py imports nine types from remora.toolcall.* to declare tool
semantics. That namespace is not remora.sdk, is not in
public_api_v1.json, and is not forbidden either — it is undeclared
territory, and no equivalent exists in the SDK, so a deployment cannot
author its own semantics without it.

test_toolpack_authoring_surface.py pins the exact symbols AND the
constructor keywords, so an upstream rename fails in CI on a pin bump
rather than in a container as "every proposal abstains". Its last test
asserts remora.sdk does NOT yet offer this surface — when that fails,
it is the signal to move the imports and delete the file.

## Also

docker/control-plane.Dockerfile installs the hash-verified wheel and
re-verifies the digest inside the image against the lock it copies in.
Twice is not redundant: the first check gates the download, the second
makes the image self-describing, so an image built from an unverified
wheel fails its own build.

71 compatibility tests pass (was 58).
    make up && aae scenarios
    6/6 scenarios behaved as documented

One command brings up a control plane built from the hash-verified core
wheel, two Postgres instances, and a migrated system of record. The four
decisions are then exercised against it for real:

  ACCEPT    a grounded read under signed WO-1201 executes with no human;
            the token is refused on replay (ReplayRefusedError)
  VERIFY    close WO-1202 is held; the agent token is refused approval;
            the DOMAIN_EXPERT identity — the role that decision asked for
            — approves; the operator executes; the effect is read back on
            a SELECT-only credential and attested in the chain as
            EFFECT_VERIFIED
  ABSTAIN   the same read under an authority that does not resolve stops,
            and offers nothing to execute
  ESCALATE  the destructive tool is refused and routed to senior_authority

plus payload binding (binding_refused: "tool-call hash differs from the
approved payload") and role separation.

110 tests pass: 71 compatibility, 39 end-to-end.

## What running it actually found

Six defects, none of which any unit test would have reached:

1. The approver token was `admin`. Both this compose file and the OT
   pilot's claimed "the approver token cannot execute" while configuring
   a role holding every capability including execute — so the approver
   approved its own proposal and then executed it. The e2e test caught it
   by trying. Now `reviewer`/`domain_expert`/`senior_authority`, none of
   which can act.

2. One approver identity is not enough. REMORA's escalation contract
   decides per decision which role may release it: a priority change
   takes a reviewer, a production close takes a domain expert, a purge
   takes senior authority. The product now reads `required_role` off the
   decision and presents the matching identity, and refuses loudly when
   it holds none — approving with a weaker identity than the decision
   asked for is exactly what the escalation existed to prevent.

3. A refusal arrives as an OUTCOME, not an exception. The binding
   scenario wrapped execute() in try/except, saw nothing raised, and
   reported "a payload the approver never saw was executed". It had not
   been — the system of record was untouched, and `outcome` said
   `binding_refused`. A consumer that only catches exceptions treats a
   refusal as success. Both the scenario and the tests now read the field.

4. `psql -v` cannot take a dotted variable name, and psql does not
   substitute variables inside dollar-quoted strings — so the first
   migration's DO blocks reading current_setting('aae.worker_password')
   could never have worked. Rewritten with \gexec.

5. A CRLF entrypoint.sh fails in a container as "no such file or
   directory", naming the interpreter rather than the script. .gitattributes
   forces LF.

6. The Windows console cannot encode the report's arrows, so `aae
   scenarios` crashed with UnicodeEncodeError AFTER running every
   scenario — governance decisions recorded, operator shown a traceback.
   The CLI now forces UTF-8 with errors="replace".

## Two findings left standing, deliberately

- A fully grounded MEDIUM-risk mutation falls through every rule to
  default_safe_abstain: no review item, no ResolutionPlan, no path an
  approver could take, for an action a signed work order authorizes. High
  risk hits evidence_insufficient and ungrounded arguments hit their own
  rule; medium-and-grounded matches neither, so declaring a risk tier
  leaves a tool worse off than leaving it unknown. The role-separation
  scenario is retargeted with that reason recorded in the code rather
  than papered over.

- REMORA's evidence bundle is not byte-stable across exports. Exactly one
  field moves — manifest.exported_at — which is correct, and means an
  outer file digest identifies THAT export rather than the evidence. The
  test now asserts nothing ELSE moves, and names any field that starts to.

## Structure

  toolpacks/work_order/   registry (callables + credentials), bundle
                          (semantics + server-side authority resolution),
                          tool_metadata.json (risk classification as DATA,
                          hashed into REMORA's policy identity),
                          work_orders.json (signed authorities)
  db/workorders/          the product's own schema, applied by a one-shot
                          migration under an admin role; the worker and
                          reader roles it creates hold no DDL
  src/aae/                config (identity per decision), postcondition
                          (the read-only verifier), scenarios, evidence,
                          cli
  docker/                 control plane from the verified wheel, with a
                          preflight that refuses on an unresolvable
                          toolpack rather than letting every dispatch
                          report unknown_tool
outsider verifies it with

Closes the four remaining items. 126 tests pass, 6/6 scenarios.

## Signed ToolSpec bundle

`make sign` computes each callable_digest from the SOURCE of the function
actually registered, signs with the deployment's key, and pins the digest
into .env. It refuses to sign in both directions: a declared tool with no
callable, or a registered callable with no spec. A registered tool without
a spec runs unattested; a spec without a tool is an authority nobody can
exercise, which rots quietly until someone adds a function to match it.

tests/e2e/test_toolspec.py takes the bundle this deployment actually runs
and attacks it six ways — widening allowed_targets, rewriting the
description an agent reads, escalating credential_scope, reclassifying a
destructive tool as routine, removing the postcondition reader, loosening
the argument schema — plus a revoked signer, an untrusted signer, the
wrong key, and the subtle one: a correctly-signed OLDER bundle. Every
signature check passes on that last one, because it really was signed by
this deployment. Only the pinned digest refuses it.

Three things this surfaced:

- The runtime compares REMORA_TOOLSPEC_PINNED_DIGEST against a BARE hex
  digest. Everything else in the codebase uses the "sha256:" form, so the
  first version emitted one and the control plane refused the bundle as
  toolspec_bundle_stale — which reads exactly like a real staleness
  attack rather than a formatting mistake.

- A refused bundle surfaces as a 500 on every request rather than a
  startup refusal. Strict mode with an unusable bundle should fail closed
  where an operator sees it.

- callable_digest is recorded but never checked: verify_callable exists
  upstream and nothing calls it at dispatch. Computing the true digest is
  still worth it — it is signed, and it becomes enforced the moment core
  wires the check — and a test asserts it matches the deployed source, so
  the field is not decoration in the meantime.

## Operator console

A separate image that imports no REMORA module and holds no policy. It
shells out to the product CLI for the scenarios rather than keeping its
own copy: a console that could show green while the CLI showed red would
leave nobody able to say which one described the product.

The posture panel is the reason it exists. A console that only showed
activity would let a deployment look healthy while running unpinned,
unenforced, or with a chain that no longer verifies.

Running it found a real config bug: Config.from_env demanded
AAE_READER_PASSWORD even when the full reader DSN was supplied, so the
console's scenario run failed on a credential it does not need.

## The CLI's execute was broken, and the docs caught it

cmd_execute passed a dict where the SDK wants a ToolCall — the same
mistake the scenarios made, left behind in the CLI. Writing the onboarding
walkthrough and then RUNNING it is what found it.

It now takes the review item id, the tool name, the arguments, the intent
and the environment, because the approval is bound to a hash over all of
them. Restating the whole call is what lets the server detect that you are
executing something other than what was approved.

It also exits 1 on a refusal. binding_refused arrives as an OUTCOME, not
an exception, so a script that only checked for a raised error would treat
it as success — exactly the mistake this product's own scenario made.

## Documentation

README rewritten for a product that runs, with a Known gaps section naming
five, two of them upstream and open.

docs/ARCHITECTURE.md — what runs where and why each boundary is where it
is, with the test that checks each claim named at the end of its section.

docs/ONBOARDING.md — 30 minutes, no research repo required, and half of it
is trying to get past the controls rather than watching them work. Every
command in it was executed against the running stack before it was
written down; the CLI bug above is what that caught.

docs/OPERATIONS.md — signing, rotation (and what rotation does to evidence
already signed), evidence export including how to verify an archive
WITHOUT this product, and what each non-terminal effect status means for
an incident.
…achine

Wiped .venv, dist, .env and every volume, then followed the onboarding
guide as written. It failed three times, and all three were real.

1. `make` is not installed on a default Windows machine. The install
   instruction for a product whose pitch is "one command" cannot be
   "first install make". Every target now delegates to run.py, so there
   is one implementation and the Makefile cannot drift from the docs.

2. The runner was named aae.py, which SHADOWS the aae package in src/ —
   `from aae.config import ...` inside a script resolved to the runner
   instead. Renamed to run.py. A latent trap for anything else importing
   aae from the repository root, and only a from-scratch run finds it.

3. bootstrap_env.py hardcoded AAE_API_PORT=8080, which was already taken
   on this machine, so `up` died on "port is already allocated". It now
   probes for a free port and prints the URLs it chose. A fixed default
   is wrong for everyone already running something on 8080, and that is
   a poor first experience.

Verified from a genuinely clean slate afterwards:

    python run.py up          → one command, ends with the two URLs
    python run.py scenarios   → 6/6
    python run.py verify      → 71 compatibility + 55 end-to-end
    python run.py doctor      → pinned core, execution surface only
    console /api/posture      → toolspec signed + pinned, chain valid

Docs now use the commands that exist, and say the venv note the `aae`
console script needs. Every command in ONBOARDING.md was executed against
the running stack before it was written down — which is how the CLI's
broken `execute` was found in the previous commit, and these three here.
Two jobs. The contract job needs no Docker and fails in seconds if the pin,
the manifest agreement or the import boundary is wrong.

The vertical job runs `python run.py up` — the same command the README gives
a new developer — then the four decisions, the end-to-end suite and the
ToolSpec verification. If that step breaks, the install instructions are
broken, which is exactly what we want CI to notice.

Running it is what found everything interesting this product got wrong: an
approver token that could also execute, a refusal that arrives as an outcome
rather than an exception, a CRLF entrypoint failing as "no such file or
directory", a hardcoded port, and a runner module shadowing the package it
was meant to run. None of those were reachable by unit tests.
Measured before cutting. Documentation was 847 lines across four files,
which is more than the product it describes deserves and more than anyone
evaluating it will read.

## Docs: 847 → 325 lines, four files → two

README.md (146) is now the single front door: what it is, two commands to
install, the trace it prints, three ways to try to break it, the boundary
table, and an honest maturity section. It leads with the thing that is
actually interesting — a production write held, released by the identity
the DECISION named, then confirmed in the database by a process holding
SELECT and nothing else.

docs/HOW-IT-WORKS.md (179) absorbs the design reasoning and the operator
essentials from the three deleted files, and keeps the property that made
ARCHITECTURE.md worth having: every section names the test that checks its
claim.

Deleted ONBOARDING.md, OPERATIONS.md and ARCHITECTURE.md. The onboarding
guide's best half — trying to get PAST the controls — is now in the README
where someone evaluating this will actually meet it.

## scenarios.py: 385 → 300 lines

Eighteen `return Outcome(...)` calls each repeated the scenario's own title.
A small `Run` object carries the name and the trace, so a scenario now
reads as a sequence of steps and one verdict. Behaviour unchanged: 6/6.

The scenario functions also lost their `scenario_` prefix and their unused
third parameter, left over from when a single approver client was passed in.

## One entry point

Deleted the Makefile. It delegated every target to run.py, so it was a
second name for the same thing — and `make` is not installed on a default
Windows machine anyway.

## docker-compose.yml: 249 → 198 lines

The comments were written while working out why each setting mattered.
That reasoning belongs in docs/HOW-IT-WORKS.md; what stays is what an
operator needs while reading the file itself.

## Ten dangling `make` references, all in error messages

`run.py doctor` said "try: make up". `sign_toolpack.py` said "run
`make sign`". Four test skips and two config errors said the same. Every
one of them is a message a user meets when something is already wrong,
telling them to run a command that no longer exists. All now name
`python run.py ...`.

Verified after: 126 tests pass (71 contract, 55 end-to-end), 6/6 scenarios,
compose config valid, no `make` reference left anywhere.
…raw dict

Two commands had zero references anywhere — not in docs, not in tests, not
in the console. Untested surface looks like a capability, and nobody finds
out otherwise until an operator reaches for it during an incident. Both
turned out to be broken in ways that matter.

## verify-effect exited non-zero on a VERIFIED effect

It branched on `EffectStatus.is_terminal`, which asks "is this a settled
answer?" — and VERIFIED and UNSUPPORTED are both settled and both fine.
Only MISMATCH is a finding.

A CI job wired to this command would have failed on every effect it
successfully confirmed. Conflating "final" with "bad" is precisely the
class of mistake this product exists to prevent, and docs/HOW-IT-WORKS.md
had the same error in its status table — UNSUPPORTED listed as
non-terminal when it is terminal. Both fixed, and the table now separates
the two questions instead of implying they are one.

## lifecycle printed the whole event mapping on one line

Every hash at full length, every internal key. That is a record, not a
trail. It now names what happened, to which tool, under which authority,
which signed ToolSpec allowed it, the hash the approval is welded to, and
its position in the chain — hashes truncated to eight characters, because
in a trail a hash carries one useful bit: whether it matches the line
above. `--json` keeps the unabridged record for reconciliation.

The rendered trail is now in the README, because it is the clearest
single artifact this product produces.

## Also

`evidence._plain` became `evidence.plain` — the CLI needed it, and
importing a private name across modules is a smell that outlives the
convenience.

tests/e2e/test_cli.py (8 tests) covers doctor, propose, lifecycle in both
views, all three verify-effect outcomes, and an evidence export whose
every file is re-hashed against its manifest.

134 tests pass (71 contract, 63 end-to-end), 6/6 scenarios.
…ics to

anyone who can reach the port

A survey of the RUNNING containers, not the compose file. Everything below
was inherited from an upstream pilot whose own comments said "local pilot
only", and carried into a product that describes itself as governance.

## Found

    readonly_rootfs=false  capdrop=[]  no_new_priv=[]  mem=0  pids=<nil>
    /metrics → 200 without a token
    ports bound 0.0.0.0

Writable root filesystems, the full default capability set (CHOWN, SETUID,
NET_RAW…), no memory or PID ceiling, every published port on every
interface, and a Prometheus endpoint disclosing decision volumes and
auth-failure counts to anyone who could reach it. That last pair is the
worst combination: on a laptop on a shared network, the governance API and
the work-order data were reachable by everyone on the segment, and the
metrics told them how much traffic this deployment governs and how often
auth fails.

## Fixed

- `cap_drop: ALL` and `no-new-privileges` on all four services.
- `read_only: true` + a 64 MB tmpfs on the two application containers. The
  databases keep a writable data volume and are bounded by capabilities
  instead; each gets back only the five capabilities postgres needs to drop
  from root in its own entrypoint.
- Memory and PID ceilings everywhere. Without either, one runaway process
  takes the host down with it — including the databases holding the audit
  chain.
- Every published port binds 127.0.0.1. The governance state database
  publishes nothing at all.
- `REMORA_PROMETHEUS_PUBLIC` defaults to 0. `/v1/metrics` is the
  authenticated view; `AAE_PUBLIC_METRICS=1` opts back in for a local
  scrape.

## Asserted, not just configured

tests/e2e/test_hardening.py (26 tests) checks `docker inspect` and live
HTTP, never the compose file — what is running is the only thing that
protects anyone. It also pins the two properties that must NOT change:
`/` and `/v1/health` stay open, because a liveness probe that needs a
credential fails for the wrong reason during an incident; and no configured
secret may appear in the console's posture response.

160 tests pass (71 contract, 89 end-to-end), 6/6 scenarios, full stack
rebuilt from scratch under the new limits.
A governance system whose tamper-evident chain cannot survive a disk
failure has an expiry date. `run.py backup` takes both databases and the
signed ToolSpec bundle with a manifest hashing all of it; `run.py restore`
verifies every digest BEFORE applying anything, because restoring a
corrupted dump into a live deployment turns a detectable problem into a
silent one.

Signing keys are deliberately excluded. An archive holding both the audit
chain and the key that signs it lets whoever has it forge a history that
verifies perfectly — a test asserts no configured key appears anywhere in
the archive.

The test worth having destroys governance state, restores, and asks REMORA
— not this product — whether the chain is intact. A chain that verifies
after a round trip was not tampered with in between.

## What building it found

The first test run HUNG. `pg_dump` takes an ACCESS SHARE lock on every
table, and one session left idle-in-transaction blocks it indefinitely.
The session was `SELECT count(*) FROM work_order_events`, open for fifteen
minutes — psycopg3 holds a transaction for the whole `with connect()`
block, and a killed test run had leaked one.

Two real defects behind one symptom:

- **Every read-only connection held a transaction open.** The postcondition
  reader, the console, `doctor`, the test fixture. In production that means
  a verification that stalls blocks `pg_dump`, which is exactly how a
  scheduled backup ends up hanging in silence. All are `autocommit=True`
  now: a reader has nothing to keep a transaction for.

- **The backup would have hung forever.** It now sets `lock_timeout=15s`,
  `statement_timeout=300s` and a wall clock, and the failure message names
  the query to run to find what is holding the lock.

Neither was reachable without actually taking a backup against a live
system, which is the argument for having done it this way.

168 tests pass (71 contract, 97 end-to-end), 6/6 scenarios.
…leaves out

`python run.py sbom` produces CycloneDX 1.5 per image, read from installed
package metadata inside the container — so it lists what is actually there,
not what a requirements file claims. Each image is identified by digest,
because two builds of one Dockerfile are not the same artifact. CI publishes
it as a build artifact on every run.

51 Python components across the two images, and the control plane's document
names `remora 0.10.0` — the pinned wheel this entire product is built on. An
inventory of everything except that would have been an odd inventory.

## Honesty is the load-bearing part

An SBOM presented as complete when it is partial converts an unknown into a
false assurance, which is worse than shipping none. So the document states
its own limits and the tests assert it keeps stating them:

- Debian packages in the base layer are NOT itemised. That needs a scanner
  this product does not ship. The base image is recorded by digest instead,
  so the OS layer is identified where it is not enumerated.
- This is NOT build provenance. Provenance is an attestation by the builder
  that an output came from a given input; this is an observation of a built
  image, made afterwards, by whoever ran the command. Both are useful and
  they are not the same claim.

`test_sbom.py::test_the_manifest_states_what_the_inventory_does_not_cover`
fails if either caveat is ever dropped — the person reading the document in
six months will not have this conversation.

## What building it found

The first version pip-installed a generator inside the container and failed:
the container runs with a read-only root filesystem, which is the hardening
from the previous commit working exactly as intended. An inventory tool that
has to modify the thing it inventories is the wrong tool, so it now reads
`importlib.metadata` and writes nothing.

The module docstring still claimed `cyclonedx-py` after that rewrite. Fixed —
a doc describing behaviour the code does not have is the second-highest
signal on this loop's own list, and it is not exempt when the author is me.

176 tests pass (71 contract, 105 end-to-end), 6/6 scenarios.
An external audit found seven P0/P1 items. Verified each in code before
acting; all seven held, and chasing one of them uncovered something worse.

## The one that mattered most

The fault test asserted `outcome != "executed"` — a value the field never
holds, so it could not fail. Correcting it to `"execute"` made it fail, and
that failure was the real finding:

    outcome        = "execute"
    tool_execution = {"executed": False,
                      "refusal_reason": "tool_failed_nonce_burned",
                      "error": "work order WO-1202 is closed, not open"}

`outcome` describes the GOVERNED STEP — authorized, dispatched, grant burned.
`tool_execution.executed` describes whether the tool did anything. They
disagree exactly when it matters, and this product read the first and meant
the second in three places: the fault test, the VERIFY scenario, and the CLI's
exit code.

Consequence: the VERIFY scenario PASSED on every repeat run while the close
was failing. The postcondition read a work order closed by an earlier run and
reported EFFECT_VERIFIED for an action that never happened. A green demo for a
failed governed write is the worst failure mode this product could have.

src/aae/execution.py now owns the reading of both fields, and nothing else
reads them. `aae execute` exits non-zero unless the tool actually acted, and
prints the refusal reason and error rather than a bare outcome.

## `verify` could report more than it verified

Proven, not argued. With the stack down:

    103 tests → "14 passed, 91 skipped", exit 0

The e2e fixtures skip when the stack is unreachable, and pytest exits 0 when
everything skips. `run.py verify` sets AAE_REQUIRE_LIVE=1, which turns those
skips into failures (91 errors, exit 1). Plain pytest still skips, so a
contributor with no stack gets a readable result.

Split `check` (contract, no Docker) from `verify` (live, must run). The two
remaining runtime skips became a visible parametrize exclusion — "2 skipped"
that nobody can explain is indistinguishable from something hidden.

## The demo was not repeatable

The scenarios genuinely mutate the system of record; that is the point. But a
second run met the leftovers. `run.py reseed` returns the reference work
orders to their seeded state, `scenarios` calls it first, and a `seeded`
fixture does the same for the tests that need it.

The audit chain is deliberately NOT reseeded: erasing the record of what was
decided to make a demonstration repeatable would defeat the thing being
demonstrated. `down` now stops without destroying; `reset` is the one that
removes volumes.

A CLI test also depended on execution order — it verified WO-1202, which is
only closed if a scenario ran first. Now uses WO-1150, which the seed closes.

## Claims brought back to what is enforced

- `network_policy: egress: none` declares what a tool REQUIRES. Nothing in
  this profile blocks egress. Recorded in the ToolSpec source and in the new
  security model under "declared, not enforced".
- "product code may only import remora.sdk" was not literally true. The
  message now says what the test checks, and names the one quarantined
  exception.
- The system-of-record event log cannot carry proposal_id or execution_id:
  the dispatcher calls a tool as fn(arguments). The columns stay NULL and say
  so, because an unfilled column is a visible gap where a removed one is a
  hidden one. Correlation is by work order, tool, actor and time — useful, and
  not the unambiguous join the docs implied.
- A missing row now reports EFFECT_MISMATCH, not EFFECT_UNOBSERVABLE. The
  reader passed None in both cases — "we looked and it is absent" and "we
  could not look" — collapsing the one distinction the whole status vocabulary
  exists for. It passes {} now; the `looked` field is no longer dead.

## Also

- `.dockerignore`. The Dockerfiles never COPY `.env`, but a build context is
  uploaded whole, and "not referenced" is not "never sent".
- `tool_specs.signed.json` is untracked. It is generated per installation with
  that installation's key; committing it shipped a signature nobody else's key
  can verify and made a routine install dirty the working tree.

## Documentation, restructured

The audit's central point was that the README argued instead of explaining,
and that the same argument appeared in five files. One home per kind of
information now:

    README            what it is, how to run it, how mature it is
    architecture      components, data flow, boundaries
    security-model    enforced vs declared, and trust assumptions
    limitations       known gaps, each with an owner
    operations        runbooks
    tutorials/        try to get past the controls
    adr/              why, including the costs each decision carries

ADR 0004 records the honest version of the control-plane boundary: the lock
calls `servers` internal and the Dockerfile's entrypoint names it, and the
import-boundary test scans Python files, not Dockerfiles. Stated rather than
implied.

174 tests pass (71 contract, 103 end-to-end), 6/6 scenarios, and both are now
stable across repeated runs.
Adopts a cleaner structure and a calmer voice: a decision table instead of
prose, a mermaid flow instead of ASCII, capabilities as a list, and no
argument for why each boundary is correct — that belongs in the ADRs.

Four corrections to the draft, each one something a reader would have hit:

- `docs/ARCHITECTURE.md`, `ONBOARDING.md` and `OPERATIONS.md` do not exist.
  The first two links resolve locally only because Windows is
  case-insensitive; on GitHub and in CI they 404. Now the real lowercase
  filenames, plus the two the draft omitted — `security-model.md` and
  `limitations.md`.

- "Equivalent Make targets are also available" — the Makefile was deleted
  when `run.py` became the single implementation. `make up` fails.

- `console/` was described as an operator console. It has no inbound
  authentication, no CSRF protection and a POST endpoint that mutates state.
  Calling it an operator console is the exact class of overclaim this
  repository is trying not to make. It is a local demonstration dashboard.

- No maturity statement. A front page with none reads as production-ready,
  and this is a local reference vertical with no external security review.
  Restored as a short block near the top, with the non-claims paragraph at
  the bottom.

Every relative link and every command in the file was checked
case-sensitively against the repository before committing.

174 tests pass, 6/6 scenarios.
The console audit's two open P0s. Verified both against the running
container first; the other findings it raised — `down` meaning `down -v`,
missing capability drops, no read-only root, no resource ceilings, no SBOM —
are already closed on this branch, which the audit could not see because it
read `main`.

## It held every credential

Compose handed the dashboard `operator`, `reviewer`, `domain_expert`,
`senior_authority` and `viewer` — five tokens, four of which can approve or
execute. It needed them for one reason: an unauthenticated
`POST /api/scenarios` that shelled out to the CLI and ran the scenarios,
which perform real writes under those roles. No login, no CSRF protection,
no confirmation step, on a published port.

A presentation surface carrying an approver credential is a high-value
target wearing a low-value label.

## What changed

Running the scenarios is a CLI action now — `python run.py scenarios` —
which is where a privileged credential belongs. The endpoint is gone, not
guarded: 404 because the route does not exist, rather than 403 from a live
handler someone could re-enable with a config change. The page shows the
command instead.

With the scenario runner gone, the dashboard needs exactly one token. The
`viewer` role grants `read`, which turns out to cover every panel including
the audit-chain verification — checked, not assumed.

And it no longer needs REMORA at all. The image installed the pinned wheel
and the product package solely to reach the CLI; both went with it. A
read-only dashboard has no business shipping the governance engine it
displays.

    tokens          5 → 1        (viewer)
    write endpoints 1 → 0
    remora in image yes → no

## Held in place

tests/e2e/test_console.py (15 tests) checks the running container, not the
compose file: one token and which one, no privileged credential and no
signing key present, the DSN connects as aae_reader, every mutating verb on
every path answers 404 or 405, the panels still report, and the image does
not contain remora.

The last one matters most. Everything else could be undone by an env change;
that one would need someone to put the dependency back.

189 tests pass (71 contract, 118 end-to-end), 6/6 scenarios.
PR #4 was squash-merged while this branch kept moving, so main holds the
early work as one commit and this branch holds it as its originals plus
everything since. Every file main has that this branch lacks is a deliberate
removal here:

  Makefile                  run.py became the single implementation
  docs/ARCHITECTURE.md      replaced by docs/architecture.md
  docs/ONBOARDING.md        folded into the README and the attack tutorial
  docs/OPERATIONS.md        replaced by docs/operations.md
  tool_specs.signed.json    generated per install, now gitignored

Checked with `git diff --diff-filter=A feat/gate-c-vertical origin/main`
before merging: that list is exhaustive, so `-s ours` drops nothing
unintentional. The doc renames are lowercase-only changes that a
case-insensitive filesystem hides — verified against `git ls-tree`, not the
working directory.
@darklordVirtual darklordVirtual changed the title Feat/gate c vertical feat: audit follow-ups — console hardening, backup/restore, SBOM and the docs rebuild Aug 6, 2026
@darklordVirtual
darklordVirtual merged commit e4e4bb9 into main Aug 6, 2026
2 checks passed
@darklordVirtual
darklordVirtual deleted the feat/gate-c-vertical branch August 6, 2026 06:37
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