Skip to content

Codegen, cost capture, no more mocks — plus a hardened sandbox and an authentication hole closed - #11

Open
barancan wants to merge 12 commits into
mainfrom
claude/hardened-sandbox-and-codegen
Open

Codegen, cost capture, no more mocks — plus a hardened sandbox and an authentication hole closed#11
barancan wants to merge 12 commits into
mainfrom
claude/hardened-sandbox-and-codegen

Conversation

@barancan

@barancan barancan commented Aug 4, 2026

Copy link
Copy Markdown
Owner

Completes every phase of the original market-ready plan: the builder now writes Python checks, runs report what they cost, and mock adapters no longer resolve outside tests. Generated code runs behind a genuinely hardened boundary, and an authentication hole that made the approval gate decorative is closed.

704 tests passing with zero API keys set, up from 499.

The sandbox first, deliberately

Codegen means model-written Python executes by default, so the containment boundary was built before any of it. Four layers added:

  • Empty environment. The sandbox subprocess was inheriting the parent's entire environment, including ANTHROPIC_API_KEY. os was import-blocked so it wasn't directly reachable, but shipping model-written code with the keys sitting in its environment wasn't defensible.
  • Throwaway working directory — the child no longer inherits the repo.
  • A real network namespace. Where unprivileged unshare works, generated checks run with no network interfaces at all rather than monkeypatched socket factories. Verified against an actual connection attempt before being wired in.
  • The child never needs open — the trusted parent reads the source and passes it in.

sandbox_tier() reports which layers are actually live, because the namespace needs Linux with user namespaces and isn't universal. The README says where it degrades rather than claiming a flat guarantee. The containment tests attempt the escape rather than asserting on internals.

Codegen (P4)

generator/codegen.py writes the check, then gates it three ways: a static AST check (contract, import allowlist, no __globals__/__subclasses__ crawling — caught as attribute access and as string constants), a sandboxed dry-run, and a discrimination test. That last one matters: a check returning passed: True on a sample only proves it runs. A check that passes everything is testing nothing.

Worth reading the discrimination design — the first implementation falsely rejected the repo's own worked example, because severity_monotonic is a conditional assertion legitimately satisfied by a response with no blocked findings. Rejecting the very check held up as a model is a bad gate, so it now tries degraded samples first and falls back to asking the check's author for a counter-example. A vacuous check still fails, because it passes its own counter-example too.

On failure after repairs, the intent degrades to a judge check with the reason recorded in build_meta.codegen_failures, and the review screen badges it — a reviewer can see a mechanical check was wanted and not achieved.

Cost capture (P5)

Per-case tokens and cost, with judge spend attributed to the case that caused it including every self-consistency sample. An unpriced model records NULL and renders as "unknown", never as $0.00 — free and unknown are different answers, and a silently wrong cost is worse than an absent one. Price table dated and overridable via ASSAY_PRICING_FILE; a malformed override raises rather than silently billing at list rates.

Model matching tolerates version decoration but refuses to cross families: claude-haiku-4-5-20251001 finds claude-haiku-4-5, but gpt-4o-mini never inherits gpt-4o's rate.

No more mocks (P6)

mock resolves only under ASSAY_ALLOW_MOCK=1 or an explicit --offline. Removed from the wizard. build.py no longer substitutes a mock judge when none is configured. Verified end to end: with no keys and no opt-in, generate refuses and run refuses, both with messages naming what to configure instead.

This surfaced two silent-mock paths nobody knew about: the wizard collected a judge model and never sent it, so every graded check the UI ever built was scored by the substituted mock judge. The CLI's default path had the same result.

An authentication hole

Writing the reviewer and CI journeys surfaced that X-Assay-User is a header anyone able to reach the port can send, and enforced mode accepted it alone. Naming a seeded reviewer was enough to approve a report — which made the approval gate decorative and contradicted the product's central claim that automation produces evidence while only a human produces a decision.

Enforced mode now requires a matching X-Assay-Token (ASSAY_API_TOKEN) alongside the header, and refuses the header outright when no token is configured. Session cookies are unchanged; open mode stays frictionless. Ten of the thirteen new tests fail against the previous code.

Two routes had no gate at all: POST /hooks/run (the only mutating route without identity, passing a caller-supplied path straight to load_spec — an unauthenticated arbitrary file read) and POST /settings/{judge,builder}.

Journeys and other fixes

docs/user-journeys.md gains reviewer, CI and admin actors (J13–J20), each row grounded in code, with a test asserting every route and symbol cited actually exists. Writing them found the bugs above plus:

  • docker-compose lost every exported report. The volume mounts /root/.assay, ASSAY_HOME was never set, and WORKDIR is /app — so reports were written outside the volume and lost when the container was replaced. The database survived; the signed artifacts didn't.
  • STATUS claimed a state-machine back-edge that doesn't exist. ready_for_review → pending is declared legal and never invoked, so a reviewer cannot return a report for rework. Now stated honestly rather than fixed, since adding it is a product decision.

Behaviour changes worth review

  • Enforced-mode CI callers now need ASSAY_API_TOKEN. This breaks existing header-only automation, deliberately — the previous behaviour was the hole.
  • assay generate requires --adapter unless --offline; it no longer defaults to mock.
  • A run costs more and now says so. Case generation plus codegen means more model calls; the reports finally show it.

Verification

  • pytest -q — 704 passing with provider keys explicitly unset. No test makes a network call.
  • Codegen driven end to end with a scripted model: the AST gate catches wrong signatures, blocked imports and dunder crawling; the repair loop converges carrying the complaint; a vacuous check is rejected.
  • The no-mocks claim walked by hand with no keys and no opt-in.
  • Egress block verified by an actual connection attempt inside the namespace.

Not in this PR

Run history, pass-rate trends, regression detection, spec export from the UI, run-level gating enforcement, and seeding the first reviewer from the UI in enforced mode. All tracked in the ranked-gap table in docs/user-journeys.md.


Generated by Claude Code

claude added 12 commits August 4, 2026 20:09
Codegen will make model-authored Python execute by default, so the containment
boundary had to come first rather than after.

Four layers added. The child now runs with an empty environment, so a check
cannot read a provider API key even if it reached os.environ. It runs in a
throwaway working directory, so relative paths reach nothing and the repo and
.assay/ are not the cwd. On Linux hosts allowing unprivileged unshare it runs in
a network namespace with no interfaces -- a real egress block rather than a
monkeypatch, verified by a test that actually attempts a connection. And the
source is now read by the trusted parent and passed in, so the child never needs
filesystem access at all.

sandbox_tier() reports which layers are genuinely active, because the namespace
is unavailable on non-Linux hosts and kernels with user namespaces disabled, and
a containment claim that is not true is worse than one that is merely modest. The
README and STATUS say exactly that rather than claiming a flat guarantee.

Adds run_generated_source(), which executes a candidate from a string so codegen
can dry-run before anything is written or persisted.

The containment tests attempt the escape rather than asserting on internals: a
test that does not try to get out is not testing containment.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UJsfnFtK7P69zJvxwY98TE
user-journeys.md covered only the builder/integrator. The three actors the
product is actually gated on -- the human who approves, the automation that
cannot, and the operator who deploys for a team -- were named in a TODO and
never written.

Eight new journeys, every row grounded in code read at 7c69500:

  J13-J15  reviewer: queue -> report -> transcript -> per-case override ->
           report verdict -> lock. Plus what a reviewer cannot do, verified
           against engine/review.py rather than assumed.
  J16-J17  CI: the merge hook, POST /hooks/run, where a machine-triggered run
           lands and why it stops there.
  J18-J20  admin: enforced posture, providers and keys, accounts and roles,
           Postgres, cost and budget.

What the reading turned up, recorded plainly rather than smoothed over:

  * ready_for_review -> pending is declared legal in review.VALID and no code
    path anywhere implements it.
  * _apply_verdict is structurally the only route to `done` and is guarded by
    _check_reviewer -- but the actor name is taken from an unverified
    X-Assay-User header before the signed cookie, in enforced mode too.
  * POST /hooks/run resolves no identity at all and feeds an arbitrary
    request-supplied path to load_spec.
  * The shipped eval.yml runs against a throwaway SQLite file in the runner and
    always exits 0, so CI neither reaches the team's queue nor gates a merge.
  * pass_policy is read by compute_suggested_verdict and written by nothing.
  * Settings' accounts card says "add reviewers here" and has no form; enforced
    mode cannot seed its own first reviewer.
  * docker-compose mounts /root/.assay while ASSAY_DIR resolves to /app/.assay,
    so exported reports die with the container.
  * Nothing anywhere caps spend, estimates cost, or cancels a run.

Ranked gaps grows from 7 open to 16, closed items retained.

tests/test_journeys_doc.py (80 cases) keeps the doc honest the way
test_docs_truth.py does: every route the tables cite must be mounted on the
FastAPI app, and every assay.<module>.<symbol> must resolve. Rows marked
MISSING or BROKEN are exempt from the existence check -- naming the thing that
ought to exist is what those markers are for.

Documentation only; no application code touched.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UJsfnFtK7P69zJvxwY98TE
Run.total_cost_usd was always 0: no real adapter ever set cost_usd, and judge
calls were invisible to the roll-up entirely. With case generation producing
several target invocations per intent and rubrics able to take multiple samples
per check, runs cost real money and reported nothing.

New assay/pricing.py normalises each provider's usage shape (Anthropic,
OpenAI-compatible, ollama) and prices it from a per-model table of USD per
million tokens, overridable via ASSAY_PRICING_FILE so a negotiated rate or a
new model is not blocked on a release.

The load-bearing decision is that an unknown model prices as None, never as a
guess and never as zero. "Free" (a local model) and "unknown" (a model missing
from the table) stay distinguishable through the adapters, the schema, the
roll-up and both renderers, because a run that reports $0.00 while spending
money is worse than one that admits it does not know.

Judge spend is now attributed to the case that caused it. run_judge_check
returns its own usage and cost alongside the verdict, summed over every
self-consistency sample rather than just the one whose verdict wins the median,
and rides through evidence -- the only field from_raw carries onto a
CheckResult -- so the engine can pick it up. Only judge-typed checks are
counted, so a generated check (model-written code) cannot forge a cost key into
the run total.

case_results gains input_tokens, output_tokens, judge_tokens and cost_usd, all
nullable, added through the existing _add_columns helper. Databases created
before this open unchanged and read NULL, which renders as unknown.

Known gap: server/app.py::_report_ctx projects CaseResult onto a fixed dict and
does not yet forward the four new columns, so the report page shows "unknown"
per case until those keys are added. The template is already correct and
degrades safely rather than printing a misleading $0.00; the pending end-to-end
assertion is marked xfail and turns green when the projection is updated.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UJsfnFtK7P69zJvxwY98TE
…report

A new install with no API keys could still run a pipeline end to end and get a
fully green report. `mock` was the wizard's first adapter choice, the CLI's
default `--adapter`, and the judge the builder silently substituted whenever
none was configured. Mocks answer instantly and pass everything, so that report
was evidence of nothing -- which is worse than no report at all.

Mock adapters are now test-only. `adapters.registry` resolves them only under
ASSAY_ALLOW_MOCK=1 or an explicit `allow_mock=True`, and otherwise raises an
LLMConfigError that says why it refused and lists every real provider with the
variable to set. `generator.build` no longer stands in a mock judge: a graded
check with no judge configured is an error naming what to configure. `mock` is
gone from the wizard's adapter list and from _adapter_fields.html.

Two silent-mock-judge paths turned up while closing this and are fixed here:
the wizard collected a judge adapter/model and never sent them, and the CLI's
default build path returned an empty judges block. Both now name the real
model, so graded checks are scored by the model the user chose.

`assay generate --offline` still works and still uses mocks -- that is the
documented no-keys path -- but it now writes the mock judge into assay.yaml
where a reviewer can see it, and says that executing the result needs
ASSAY_ALLOW_MOCK=1. `--adapter mock` without `--offline` is refused rather than
warned about: a warning is the affordance that let mock become the default in
the first place, and the fix is one word away.

The suite runs on mocks throughout, so a root conftest.py declares the opt-in
once. test_adapter_fields_mock_has_no_fields asserted the presence of the very
block P6 removes; it is replaced by its inverse.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UJsfnFtK7P69zJvxwY98TE
Assay's stated differentiator is that when no vetted template fits an assertion, it
writes a Python check for you. It never did. `generated_sources` was hardcoded to {},
so a `generated` intent produced a spec entry pointing at a file nobody wrote and the
run died in the sandbox with "generated check not found".

New assay/generator/codegen.py runs generate -> validate -> dry-run -> repair:

  * `validate_source` is a static AST gate run before anything executes -- module-level
    `check(response, context)`, allowlisted imports only, no __import__/eval/exec and no
    dunder attribute crawling. A bad reply is rejected on the AST, not discovered by the
    sandbox.
  * the candidate then runs for real through `run_generated_source`, which takes source
    text, so nothing is written or persisted before it has executed.
  * running is not enough. `return {"passed": True}` passes every dry-run and tests
    nothing, so the check must also REJECT something: a degraded response (content
    ruined, then the body emptied) or, for a conditional assertion that no generic
    degradation can trigger, a counter-example asked of the model that wrote it. A check
    that rejects nothing at all -- not even its own counter-example -- is a failed
    attempt.
  * every rejection is fed back verbatim as a repair prompt, and every attempt's errors
    are kept on the result.

When the loop still cannot get there the intent degrades to a judge check, recorded in
config.build_meta.codegen_failures with the attempts and the exact errors. The assertion
still gets tested, semantically instead of mechanically, and the review screen badges it
so a reviewer can see that a mechanical check was wanted and not achieved. The --offline
path has no model, so it degrades the same way and says so.

`regenerate_check` no longer writes an unimplemented scaffold: it goes through the same
validate + dry-run gate and raises CodegenError with the attempts rather than persisting
something broken. A regenerated or hand-edited source drops the previous source's
recorded dry-run, so the review screen never vouches for code nobody ran.

The sandbox is untouched.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UJsfnFtK7P69zJvxwY98TE
Writing the reviewer and CI journeys surfaced that `X-Assay-User` is a header
anyone able to reach the port can send, and enforced mode accepted it on its own.
Naming a seeded reviewer was therefore enough to approve a report, which made the
approval gate decorative and contradicted the product's central claim that
automation produces evidence while only a human produces a decision.

Enforced mode now accepts the header only with a matching X-Assay-Token
(ASSAY_API_TOKEN), and refuses it outright when no token is configured -- secure
by default rather than quietly weaker. The signed session cookie is unchanged and
still sufficient on its own, so the browser flow is untouched, and open mode stays
frictionless for a single developer on localhost.

Two routes had no gate at all. POST /hooks/run was the only mutating route
without identity resolution, and it passed a caller-supplied path straight to
load_spec -- an unauthenticated arbitrary file read. It now authenticates, keeps
the spec inside the server's working directory, records the authenticated actor
rather than the caller's unverified `by` claim, and returns 4xx instead of a 500
traceback. POST /settings/judge and /settings/builder were unauthenticated, so
anyone could repoint the workspace's models and its spend.

_report_ctx now forwards the token and cost columns, which closes the xfail the
cost work left recording that gap.

Ten of the thirteen new tests fail against the previous code.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UJsfnFtK7P69zJvxwY98TE
Conflict was one import line.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UJsfnFtK7P69zJvxwY98TE
… and reconcile docs

docker-compose mounted a volume at /root/.assay but never set ASSAY_HOME, and the
image's WORKDIR is /app -- so exported reports were written to /app/.assay,
outside the volume, and lost whenever the container was replaced. The database
survived; the signed artifacts did not. ASSAY_HOME now matches the mount.

STATUS claimed the report state machine included the back-edge to `pending`. It
does not: the transition is declared legal in engine/review.py and nothing ever
calls it, so a reviewer cannot return a report for rework. The row now says so.

Docs reconciled across the four workstreams: codegen, token and cost capture,
and mock retirement move to Built, and the README documents ASSAY_API_TOKEN for
non-browser callers. The cost work's xfail is promoted to an ordinary test now
that the report context forwards those columns.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UJsfnFtK7P69zJvxwY98TE
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.

2 participants