Codegen, cost capture, no more mocks — plus a hardened sandbox and an authentication hole closed - #11
Open
barancan wants to merge 12 commits into
Open
Codegen, cost capture, no more mocks — plus a hardened sandbox and an authentication hole closed#11barancan wants to merge 12 commits into
barancan wants to merge 12 commits into
Conversation
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
…sandbox-and-codegen
…sandbox-and-codegen
…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
…sandbox-and-codegen
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
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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:
ANTHROPIC_API_KEY.oswas import-blocked so it wasn't directly reachable, but shipping model-written code with the keys sitting in its environment wasn't defensible.unshareworks, generated checks run with no network interfaces at all rather than monkeypatched socket factories. Verified against an actual connection attempt before being wired in.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.pywrites 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 returningpassed: Trueon 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_monotonicis 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-20251001findsclaude-haiku-4-5, butgpt-4o-mininever inheritsgpt-4o's rate.No more mocks (P6)
mockresolves only underASSAY_ALLOW_MOCK=1or an explicit--offline. Removed from the wizard.build.pyno 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-Useris 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 toload_spec— an unauthenticated arbitrary file read) andPOST /settings/{judge,builder}.Journeys and other fixes
docs/user-journeys.mdgains 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:/root/.assay,ASSAY_HOMEwas 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.ready_for_review → pendingis 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
ASSAY_API_TOKEN. This breaks existing header-only automation, deliberately — the previous behaviour was the hole.assay generaterequires--adapterunless--offline; it no longer defaults tomock.Verification
pytest -q— 704 passing with provider keys explicitly unset. No test makes a network call.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