fix: guarantee evidence_json reads as a dict at the column boundary (#250 follow-up)#251
Conversation
…250 follow-up) PR #250 routed the highest-exposure findings reads through coerce_evidence(), but ~15 other sites read `f.evidence_json` directly: - `(f.evidence_json or {}).get(...)` → AttributeError: 'str' has no .get - `dict(f.evidence_json or {})` → ValueError: dict() of a string so a legacy/double-encoded row (one whose evidence deserializes to a JSON *string*) could still 500 the verify endpoint, report build, dedup, suggester, fuzz/poc/reachability paths, etc. Fix it once, at the source: add a `JSONDict` SQLAlchemy TypeDecorator (impl=JSON) whose `process_result_value` coerces a non-dict stored value to a dict on read (recovering a double-encoded dict, degrading garbage to {}), and apply it to `Finding.evidence_json`. Now no reader can ever observe a non-dict, so every one of those sites is safe by construction. `None` and well-formed dicts pass through unchanged — only the malformed values that would otherwise crash are changed. Relocate coerce_evidence into the new `db/jsontypes.py` (the low-level home shared by the column type and the pure helpers) and re-export it from engine/findings/findings.py so existing imports keep working; is_verified still routes through it for inputs not sourced from the column (tests/in-memory/imports). Scope: `evidence_json` only — the column with the actual bug. JSONDict is generic and could be applied to other dict JSON columns later, but that's a broader, separately-verified change; left out here to keep this tight and reviewable. No DDL change (impl is plain JSON) → `alembic revision --autogenerate` detects no drift → no migration. Verified. Verification: new tests/test_evidence_json_column.py — the column guarantee (string / double-encoded / non-object-JSON / dict round-trip) plus representative previously-unguarded sites (verify endpoint → 400 not 500, report build, dedup); all 4 fail on plain JSON and pass with JSONDict (revert-checked). Full `just test` fast tier green (1411 passed). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
branover
left a comment
There was a problem hiding this comment.
Independent merge-gate review — PR #251 (fix/evidence-json-column)
Verdict: APPROVE (no blocking issues). Reviewed by an independent agent that did not author the change. Ran both /code-review (correctness) and /security-review on git diff main...HEAD.
This is a clean, well-targeted follow-up to #250. It replaces the per-call-site coercion bandaid with the right deep fix: coerce once at the column-read boundary via a JSONDict TypeDecorator(impl=JSON) on Finding.evidence_json. Every reader is now safe by construction.
Correctness — verified
TypeDecoratorhook is correct.process_result_valueis the right hook and runs after the implJSON'sjson.loads, so it receives the deserialized Python value (dict / str / scalar / None), not raw text. Verified end-to-end by driving the full result-processor chain: a normal dict passes through, a double-encoded dict (json.dumps(json.dumps({...}))) is recovered, garbage degrades to{}, and SQL NULL staysNone.- No migration needed — confirmed.
impl = JSONkeeps the DDL byte-identical (JSONDict().compile(sqlite) == JSON().compile(sqlite) == "JSON"). Built the DB viaprepare_database()then ranalembic revision --autogenerate: the generatedupgrade()body is empty (pass). Throwaway migration deleted. The PR correctly ships no migration. cache_ok = Trueis correct. The decorator carries no instance-level parameters that affect SQL caching, so it is safe (and avoids the SQLAlchemy cache-disable warning).Nonehandling is right, not a bug. The column isNOT NULLin practice (default=dict), so the explicitif value is None: return Noneis belt-and-suspenders. ReturningNonerather than{}is the correct choice because all ~15 read sites already guardvalue or {}, and preserving NULL-vs-{} avoids changing the column's value space.- Write path is unaffected.
process_bind_paramis intentionally not overridden, so writes delegate toJSON's bind processor (json.dumps) exactly as before — byte-identical persistence. The write guard remains upstream Pydantic validation. All read-modify-write sites (dict(f.evidence_json or {})→ mutate → reassign incampaigns.py,fuzzing.py,findings.py,source.py,reachability.py) keep working and now read a guaranteed dict. - Full coverage of the direct-read sites. Spot-checked the claimed sites —
api/routers/findings.py:114,engine/findings/report.py:42,engine/graph/dedup.py:18,engine/fuzz/campaigns.py,engine/findings/poc.py:376/412,demo.py:269,suggester.py,runs.py,solving.py,harness_promote.py,reachability.py,llm_tasks.py— every one reachesevidence_jsonthrough the ORM column attribute, so the column boundary covers them all. Grep for raw SQL /json_extract/text(...)onevidence_json: none. No read path bypasses the decorator. - No circular import.
db/models.pyimportsdb/jsontypes.py, which imports onlysqlalchemy+ stdlibjson(notdb.models). Import-checked clean; the fast tier (1411) and the targeted suite pass. - Re-export preserved.
from hexgraph.engine.findings.findings import coerce_evidencestill resolves (re-exported fromdb.jsontypes); existing callers (mcp_tools.py,tests/test_evidence_coerce.py) androw_to_payload/is_verifiedare unbroken — confirmed by the passingtest_evidence_coerce.py. - Nothing depends on the raw stored value. No code distinguishes a stored JSON-string from a dict for
evidence_json; the only None-vs-{} consumers are thevalue or {}guards, which the decorator respects.
Test quality — strong
tests/test_evidence_json_column.py genuinely reproduces the original crash: assigning a Python str to the JSON column double-encodes it, so a fresh read returns a string — the exact legacy shape. The column test asserts recovery of a double-encoded dict (data intact), {} for unparseable / non-object JSON, and pass-through for a healthy dict. Three integration tests (build_report_md, dedupe_findings, POST /verify) each exercise a real previously-unguarded .get(...) / dict(...) site and assert it no longer 500s (the verify test reaches the normal 400, not a 500). By construction these would fail on plain JSON (the column would return a str and the .get/dict() would raise), which matches the author's revert-check. 21 targeted tests pass here; reported full fast tier 1411 green.
Security — no findings
Pure DB read-coercion change. The only parsing is json.loads (no pickle / yaml / eval), and its result is type-checked (isinstance(parsed, dict) else {}) with ValueError/TypeError caught — no deserialization-RCE, type-confusion, or injection path. No new endpoint, subprocess, file/path handling, network bind, or auth/authz logic. The HexGraph security invariants are untouched: loopback-only bind, target bytes stay in the sandbox, opt-in execution/egress only at the policy seam, and secrets never logged/stored/returned (evidence_json is finding metadata, not secret material, and the new code never logs it). The change only makes a previously-crashing read robust; it does not widen data exposure (same row, same project scope).
Non-blocking nits (do not block merge)
- Redundant
or {}guards now scattered across ~15 read sites (e.g.is_verified(f.evidence_json or {})atagent/mcp_tools.py:1099). Now that the column guarantees dict-or-None, these are defensively redundant. Leaving them is a reasonable choice (avoids a large churn diff and keeps the helpers safe for non-column callers), but a future cleanup could drop them once everyone trusts the boundary. No action needed in this PR. - Docs/CHANGELOG: no UI behavior changed, so
docs/dev/ux-contract.mdis correctly untouched; no migration is correctly absent. Nothing required by the gate is missing.
Ready to merge once CI is green.
…500) (#252) Third in the evidence-robustness series (#250 top-level helper, #251 column boundary). Those guarantee `evidence_json` reads as a dict, but a real field finding crashed GET /api/projects/{id} again: is_verified -> (((ev.get("extra") or {}).get("verification") or {}).get("verified")) AttributeError: 'str' object has no attribute 'get' The top-level evidence IS a dict here; the malformed value is NESTED — `evidence.extra.verification` is a prose STRING (an agent wrote free text where a {verified,…} object belongs). The `(x or {}).get(y)` idiom only guards a FALSY intermediate; a truthy non-dict sails through `or {}` and then `.get` raises. The column boundary can't fix this — `extra` is intentionally free-form, so its children can be any JSON type — so the read accessors must navigate defensively. Add `dig_dict(obj, *keys)` (jsontypes.py): walk nested dicts, return None the moment any level isn't a dict. Route the shared findings-read accessors through it / guard with isinstance: - is_verified (findings.py) — the reported crash. - assurance_of (assurance.py) — same finding also crashes it (extra.verification is a string); now returns the first non-empty assurance dict, else None. - list_findings._row (mcp_tools.py) — guards extra/verification/fuzz as dicts (the MCP findings list 500s on the same finding via ver.get / assurance_of). Scope: the shared accessors + the UI/MCP findings-read hot paths (the reported crash and its direct sibling). The remaining subsystem-specific inline nested digs (fuzz/poc/reachability task paths) keep the same idiom and are tracked as a follow-up. No schema/DDL change -> no migration. Verification: validated the fixed code against the REAL project DB (read-only) — is_verified/assurance_of no longer raise, finding_dict over all 194 findings: 0 crashes, MCP list_findings OK. New unit tests (dig_dict; is_verified/assurance_of vs string-extra / string-verification) + an endpoint regression (extra.verification and extra-itself as strings -> 200, not 500); all revert-checked (fail on the old idiom). Full `just test` fast tier: 1416 passed. Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
What
Follow-up to #250. That PR fixed the
GET /api/projects/{id}500 by routing the highest-exposure findings reads throughcoerce_evidence(). But ~15 other sites readFinding.evidence_jsondirectly, and a legacy / hand-edited / double-encoded row — one whose evidence deserializes to a JSON string — would still crash them:(f.evidence_json or {}).get(...)→AttributeError: 'str' object has no attribute 'get'dict(f.evidence_json or {})→ValueError(dict() of a string)Affected paths include
POST /api/findings/{id}/verify(findings.py:114), report build, dedup, the suggester, and the fuzz / poc / reachability task paths.The fix — coerce once at the column-read boundary
Add a
JSONDictSQLAlchemyTypeDecorator(impl = JSON) in the new db/jsontypes.py whoseprocess_result_valuecoerces a non-dict stored value to a dict on read (recovering a double-encoded dict, degrading garbage to{}), and apply it toFinding.evidence_json. No reader can then ever observe a non-dict, so all ~15 sites are safe by construction — without touching each call site.Noneand well-formed dicts pass through unchanged; only the malformed values that would otherwise crash are normalized. Writes are untouched (upstream Pydantic validation remains the write guard).coerce_evidenceis relocated intodb/jsontypes.py(the low-level home shared by the column type and the pure helpers) and re-exported fromengine/findings/findings.py, so existing imports keep working.is_verifiedstill routes through it for inputs not sourced from the column (tests / in-memory / imports).Scope & alternatives
Scoped to
evidence_json— the column with the actual reported bug.JSONDictis generic and the same treatment could be applied to other dict-typed JSON columns (metadata_json,attrs_json,params_json, …) in a later, separately-verified change; deliberately left out here to keep this PR tight, reviewable, and per-column tested. The alternative of patching each of the ~15 call sites was rejected — the boundary fix is the lower-altitude, can't-regress option.No migration
The DDL is plain
JSON(the decorator'simpl), soalembic revision --autogeneratedetects no drift (verified — emptyupgrade()). No schema change → no migration.Verification
build_report_mdrenders,dedupe_findingscollapses. All 4 fail on plainJSONand pass withJSONDict(revert-checked).tests/test_evidence_coerce.py(from fix: tolerate a non-dict evidence_json on findings read (no more 500) #250) still passes via the re-export.just testfast tier green: 1411 passed, 4 skipped, 14 deselected. The target→task→finding→graph loop is untouched (read coercion only), sojust demowas not required.🤖 Generated with Claude Code