Conversation
…#62) test_l1_complete required every catalog control to carry a non-empty relevant_apis tuple, but MSTG-ARCH-3/4/5/6/10/11 legitimately carry none: architecture/process controls are verified by design review, not by inspecting runtime Android APIs (they do populate verification_steps and evidence_hints). Skip the relevant_apis requirement for MSTG-ARCH ids; every other group still must list at least one API. Test-only. test_vr_masvs_catalog: 1 passed.
…ts (#62) - health: /health always runs DB/redis/worker probes and reports unhealthy when a critical check is down (D-14/D-15); patch probe_redis/probe_arq_worker to control state, assert redacted messages (T-138-12), fix the database-down test to patch _check_database (the old session_scope target does not exist). - auth: /auth/oidc/providers/public is intentionally unauthenticated (Phase 177) and the Prometheus /metrics mount 307-redirects before auth; add both to the route-sweep public set. The 422 validation body is now the Phase 176a envelope {code,message,hint,trace_id}. - config_router: fixture must await the async ConfigRegistry.register (the missing await left the schema unseeded, cascading 200->422 and message failures). - schema deep-reviews: JWT_ constants 3->5, CORS default expanded to the Vite dev/preview/default ports, SystemListResponse wraps SystemEnrichedResponse, FindingResponse gained is_kev/workflow_state, and several __all__ sets grew. Test-only. 150 passed across the touched files (+22 health).
- test_vulnerability_routes: the vulnerability router wraps every 2xx response in
DataEnvelope {data,error,meta} (D-27); read response.json()["data"] instead of
the raw body, matching the rest of the api suite. Also corrected the severity
vocabulary comment to the real CRITICALITY_KEYS.
- test_53_01_module_route_spec: ModuleProtocol.system_summary/report_count are
async; await them.
Test-only. 28 passed across the 2 files.
…ve (#62) get_task_tuning always returned the compiled default; the deferral note it carried (avoid asyncio.run on a loopless worker) is moot now that a sync path exists. Resolve the ConfigEntryRecord row through ConfigRegistry.get_sync (psycopg session_scope), which is safe from sync call sites and worker startup. A fresh registry per call keeps reads live (no stale in-memory cache masks a config change); fall back to the compiled default when the value is unset, uncastable, or the DB is unreachable. Makes PUT /config/platform/{key} propagate to task-queue tuning knobs without a restart (XCUT-14). tests/api/test_xcut_config_read_through.py now passes (fixture also had to await the async ConfigRegistry.register).
…#62) - cost: POST /cost/estimate now requires a team-scoped principal; use a team-scoped token and seed records under that team. - reports_detail: NotFoundError carries ClassVar http_status=404, so the unknown id returns 404 (NOT_FOUND_ERROR envelope), not the legacy 500 fallback. - systems_deep_review: AsyncMock the async submit path; align to current shapes. - vr masvs parent-reconciler: sweep returns {started,completed,refilled} now (commit 0025b93 batching); assert per-counter instead of exact dict. - vr masvs dispatch: android_apk dispatch batches (default 5); set MASVS_AUDIT_BATCH_SIZE high to exercise full fan-out. - vr phase-b: patch default_task_queue on the outcome_dispatcher module (import binding), not the source module. - vr verdict_analyzer: skipif when the private scripts/vr_masvs_report_sampleapp.py driver (never committed) is absent -- 40 tests skip cleanly instead of erroring. Test-only.
) The scan SSE generator wrapped its catchup phase in try/except but left the live `async for stream.stream_events(...)` loop unguarded, so a mid-stream backend error (e.g. a Redis blip) propagated out of the generator and surfaced as a 500 on an already-open SSE connection. Wrap the live loop the same way and end the generator quietly (no done sentinel) so the response completes with 200 and the client's EventSource auto-reconnects -- the right behaviour for a transient error. Also aligns tests/api/test_scans_deep_review.py to current contracts (async submit/handle via AsyncMock, get_worker_platform entrypoint, SSE async-generator mocking with pool_available). 11 passed.
… race (#62) revoke_api_key read the row, checked revoked_at, then wrote and committed in separate steps with no row lock or conditional update. Two concurrent DELETE /auth/keys/{id} both passed the read-then-check and both committed 200, instead of the documented 200 + 409-conflict. Replace the read-modify-write with a single conditional UPDATE ... WHERE revoked_at IS NULL and branch on rowcount: concurrent revokes serialize on the row lock, so exactly one flips the timestamp (200) and the loser sees rowcount 0 (409). Verified by tests/api/test_stress_concurrent_revocation (STRESS-15): 3 passed.
Four download endpoints returned StreamingResponse but declared no response
media type, so their OpenAPI 200 carried a bare application/json {} schema
(FastAPI's default), misleading generated clients into expecting JSON:
- GET /executive/risk-summary-pdf -> application/pdf
- GET /executive/systems/{id}/evidence-package -> application/zip
- POST /forensics/projects/{id}/retrieve-file -> application/octet-stream
- POST /forensics/projects/{id}/fetch-raw -> application/octet-stream
Declare response_class=StreamingResponse plus an explicit binary response schema
on each. Also scope the openapi bare-schema test's recursion to skip pydantic's
default: {} emission.
tests/api/test_xcut_openapi_module_discovery: passes.
…shapes (#62) - 96_sse: the scan SSE route awaits ProgressStream.catchup, consumes stream_events via async for, emits a synthetic Connected event first, and resumes from "$" after catchup; patch pool_available True, use AsyncMock + real async-generator mocks, and shift event-index/count assertions. - race_isolation: /vulnerability/findings and /findings/bulk responses are DataEnvelope-wrapped; read ["data"]["items"] and ["data"]["count"]. Test-only. 21 passed with the SSE + openapi fixes.
test_scan_smoke runs a real end-to-end scan against the GHSA/OSV/NVD advisory providers over the network. Without a GitHub token GHSA is rate-limited and the scan persists zero findings, so the "at least one finding" assertion cannot hold in a token-less CI environment. Skip when GITHUB_TOKEN is unset; the test runs when a token plus network are available. Also keeps the corrected provider-logger assertion (only GHSA's token-resolution warning is a reliable smoke signal). Test-only.
GET /tags/systems/{id} built a TagResponse (id, system_id, created_at required)
from list_system_tags, which delegated to system_tags_map -- a lightweight
tag_key/tag_value enrichment map that drops id/system_id/created_at. Every list
call with at least one tag raised a pydantic ValidationError and returned 500.
Query AssetTagRecord directly in list_system_tags and return the full row
(id, system_id, tag_key, tag_value, created_at); system_tags_map is left intact
for its enrichment consumers.
…prod bugs (#62) - health checks: assert redacted (None) messages (T-138-12) and the literal 'timeout' message. - oidc authorize: patch the real msal.ConfidentialClientApplication (msal is a lazy import, so patching the router attribute has no effect) and the async _decrypt_client_secret. - tags list: now returns full rows (see the module fix), so the assignment full-cycle test passes. - xfail (strict=False) with source evidence: (1) OIDC callback compares a 'nonce' claim that _make_state_jwt never emits, so every callback 400s -- OIDC login is broken end to end; (2) the second documented Phase-138 discrepancy. Both left visible rather than masked. Test-only. 96 passed, 2 xfailed across the 4 files.
The callback compared state_payload.get('nonce') against the query state, but
_make_state_jwt never emits a 'nonce' claim, so the comparison was always
None != <state> and every callback returned 400 'OIDC state mismatch'. OIDC
login was unreachable end to end for all three provider types.
authorize puts the same signed state token in both the auth-URL state parameter
(round-tripped through the IdP) and the httponly oidc_state cookie. The callback
now performs the standard double-submit check: validate the cookie is our own
signed, unexpired oidc_state JWT, then require the echoed query state to be
byte-identical to the cookie via hmac.compare_digest. Removes the xfail on
test_oidc_callback_creates_user (now passing) and fixes its msal patch target
(msal is a lazy import).
…45) Two seed-health defects: - The startup seed loop opened one AsyncSession and called every module's seed_data on it with no per-module guard. A raise in any module aborted the loop, so modules later in the order never seeded and startup stranded. Extract _seed_all_modules: each module runs in isolation; a DB/infra failure (SQLAlchemyError/OSError/RuntimeError) is rolled back and logged, the remaining modules still seed. Programming errors outside that set still surface loudly at startup. - MalwareModule.seed_data discarded its session and never stamped SeedVersionRecord, so it was the only in-tree module that violated the MODULE_STANDARD idempotency contract despite declaring SEED_VERSION. It now stamps the version idempotently, mirroring the other modules. Tests: malware stamping, stamping idempotency, and seed-loop isolation (a failing module's partial row is rolled back and the healthy module that follows still seeds).
Two payload-hygiene defects: - PersistContract.upsert_many looped one PersistContract.upsert per record, so a 200-record import was 200 round-trips. It now issues one INSERT ... VALUES ((...), ...) ON CONFLICT DO UPDATE per 500-record chunk; the SET clause references the excluded (proposed) row so each conflicting row takes its own incoming values. Heterogeneous record lists raise TypeError. The single-record upsert switches session.exec -> session.execute (exec is SQLModel's typed Select shim; execute accepts arbitrary DML), removing the call-overload type-ignore. - ObservationStoreService.list_for_target fetched every row for a target with no ORDER BY or LIMIT, so a target with tens of thousands of observations could stream its whole history into a prompt build. It now caps at limit (1..500, default 200), returns newest-first, and accepts cursor_created_at for keyset paging over the existing created_at index. Tests: upsert_many insert/conflict-update/heterogeneous-reject; list_for_target bound, newest-first ordering, cursor paging, and limit validation.
…#65) Two sync callers still read config through the async ConfigRegistry.get and guarded the returned coroutine with hasattr(__await__), which always discarded the value: - TaskQueue._get_redis_url dropped a DB-configured redis_url, so enqueue fell back to env-only resolution and reported the worker unreachable whenever the URL lived only in config. - ReasoningService._load_profile_override silently ignored an operator's per-domain reasoning profile override, always using the hardcoded fallback. Both now use the sync read path ConfigRegistry.get_sync (C3). Test doubles in test_task_queue align to get_sync; TestGetRedisUrl covers configured/empty/ unconfigured/raising cases.
register_periodic_sweep raised ValueError whenever a name was already present, so a module __init__ imported twice in one process (test fixtures, two import paths for the same module) crashed import-time registration. Registering the same callable under the same name is now a no-op; only a DIFFERENT callable under an in-use name raises, which is the genuine name-collision bug the check is meant to catch.
…on drop (#52) Three handlers wrote their audit row in a second transaction after the business change had already committed, so a crash between the two commits lost the audit trail. revoke_api_key, update_system, delete_system, and _add_user_msg now record the audit event before a single commit, inside the same session (the atomic conditional UPDATE in revoke_api_key is preserved). An audit-write failure now rolls the business change back with it. The emitter's _audit_db destination re-raises instead of swallowing at debug, so the existing _dispatch guard logs at ERROR and increments the audit_db destination-failure counter -- a dropped audit row is now visible. SystemService gained an optional emitter and emits system registered/deregistered events on register/deregister; the no-emitter path stays a no-op (backward compatible with existing constructors). Additional commit/audit/commit sites (auth login/refresh/create_api_key, systems create_system, sessions _sync_message) share the anti-pattern but were outside this change's enumerated sites; left for follow-up, not masked. Test: audit row and business row share one transaction; an audit-write failure rolls the business row back.
…ffload (#38, #64) - #38-3.3: check_monthly_budget now raises BudgetExceededError once month-to-date spend reaches 100% of the configured ceiling (hard stop, D-08). The 80% alert- only path is preserved for spend between 80% and 100%; the raise is emitted outside the infra-exception guard so it is never swallowed. No ceiling configured stays non-raising. - #38-3.2: _inner_call performs a pre-flight budget check before dispatching to the provider, mirroring _call_with_retry, so over-budget consensus/verify retries refuse instead of spending. - #64-3.1b: BGEProvider and MiniLMProvider gain encode_async, offloading the CPU-bound model.encode through run_blocking_io; the EmbeddingProvider Protocol declares it. Sync encode is unchanged. Known consequence (not masked): the first call that pushes spend past 100% records its cost and then raises post-hoc from persist_cost_record (BudgetExceededError is outside cost.py's telemetry catch tuple), so that completed response is aborted; subsequent calls are refused pre-flight. Moving the check fully pre-flight and dropping the post-hoc raise lives in cost.py and is out of this change's scope. Tests: hard-stop at 101%/100%/81%/no-ceiling; _inner_call over/under/none budget short-circuit; encode_async shape parity and thread-offload for both providers.
…ings (#55) - 55-3.5: get_findings_facets and list_findings pushed ordering, counting, and paging into SQL. Facets come from five GROUP BY queries instead of loading every filtered row into memory; list_findings runs one COUNT and one LIMIT/OFFSET SELECT with a CRITICALITY_KEYS CASE order (KEV tie-break on severity sort) plus a stable id-ASC tie-breaker so pages do not shuffle. The FindingsListResponse envelope is unchanged. Queries run through the platform UnitOfWork (not a raw session import). - 55-3.8: bulk_update_findings validates every requested workflow transition against an explicit _ALLOWED_WORKFLOW_TRANSITIONS graph (states from the current_workflow_state CHECK constraint) in a preflight SELECT before the UPDATE; an invalid transition rolls back and returns 422 with the offending finding_id/current_state/target_state. The audit event carries the transition deltas. The design's `reason`/`poc_transcript_id` request-schema fields live in api/schemas/findings.py and are deferred (breaking API change coordinated with the frontend, and the AppendJournal tie-in is blocked on #52). Tests: bounded severity-ordered page, disjoint offset page, invalid transition rejected, valid transition applied, transition graph covers every declared state, CRITICALITY_KEYS pinned.
- 59-3.4: list_evidence, list_findings, and list_investigations gained page/page_size query params (page_size 1..500, default 100), a stable created_at DESC, id ASC ordering, and SQL-level LIMIT/OFFSET. list_findings paginates at the ArtifactRecord layer where the row count blows up. The DataEnvelope[list[X]] response shape and existing project-ownership scoping are preserved. - 59-3.6: ForensicsConfigSchema gained freeflow_max_cost_usd (default 25.0). The freeflow state spawns a cost monitor beside the agent run; when month-to-date run cost crosses the ceiling it flips the investigation through the existing cancel path (same halt HonestInvestigator already polls) and overrides the final status to EXHAUSTED. A finally block always stops and awaits the monitor. Known limitation (not masked): the cost query sums LLMCostRecord by run_id == investigation_id, but the reasoning engine does not yet thread the investigation run_id into its LLM calls, so LLMCostRecord.run_id is empty for freeflow calls and the ceiling stays inert in production until that upstream run_id plumbing lands. The mechanism, config field, and termination path are correct and unit-tested with seeded cost rows. Tests: bounded pages for all three list endpoints (with disjoint-offset proof), the pure ceiling predicate, the config field, the cost-sum query with cross-investigation isolation, and an end-to-end monitor cancel.
…cess (#58) ScriptExecutorTool.forward ran the remote extraction script via ssh.run_command and unconditionally reported exit_code=0 on success, so a script that failed with a non-zero exit was recorded as valid evidence. Added SSHService.run_command_full, which returns (stdout, stderr, exit_code) rather than raising UpstreamError on non-zero exit, mirroring run_command for idle timeout, exit-status grace, host-key policy, and stderr redaction (C6). forward now returns the real exit code, and connection-level failures (authentication, host-key mismatch, transport, idle timeout) propagate to the caller instead of being flattened to exit_code=1, so file_retriever's exit_code != 0 guard finally enforces the script contract. Script cleanup still runs in a finally block, with its swallowed-exception set widened so a cleanup error cannot mask the real one. Test: a non-zero exit is surfaced (not hard-coded 0), a clean exit still succeeds, and an authentication error propagates while cleanup still runs.
… injection (#43) Untrusted content reached the model as if it were trusted instruction text: - _tool_loop appended raw tool/MCP results directly to the message list, so a tool return could carry instructions into the agent prompt. - vr writer_agent._render_facts embedded raw investigation facts into the report-writer prompt. New platform/llm/untrusted.py sanitize_untrusted(text, *, source) fence-wraps content in a labeled <untrusted-input> delimiter and mangles any embedded fence sentinel so injected content cannot close the fence early; the source label is scrubbed and length-capped so a hostile source string cannot break out of the opening tag. _tool_loop now fences each executor tool result (keyed by tool name); the platform-authored timeout JSON stays unfenced so telemetry parsing is unchanged. writer_agent runs every embedded fact through sanitize_input, wraps the whole facts block in one data fence, keeps the schema instruction outside the fence, and gains a system-prompt trust-boundary section teaching the model to treat fenced content as evidence only. Tests: fence wrapping, verbatim payload preservation, begin/end sentinel escape, source-attribute injection escape, idempotent double-wrap, and _tool_loop fencing a tool result while the synthesized timeout result stays parseable.
) Extends the audit-in-transaction fix to the three sites left as follow-ups: create_api_key, create_system, and _sync_message (assistant path) each wrote their audit row in a second transaction after the business change committed. Each now records the audit event before a single commit in the same session: create_api_key reads the default_factory-populated id/created_at pre-commit; create_system flushes to populate the DB-generated PK (the unique-name IntegrityError still short-circuits to 409 before any audit staging); _sync_message flushes to populate the assistant message PK referenced in the audit details. An audit-write failure now rolls the business change back. Test: create_api_key audit row and business row share one transaction, and an audit-write failure rolls the key row back.
…auge (#60) - 60-3: ProgressStream.stream_events had an unbounded while loop, so a well- behaved client with an open EventSource pinned a Redis pool connection forever. It now ends cleanly once MAX_STREAM_LIFETIME_S (300s, mirroring sse_events.MAX_CONNECTION_S) elapses; the client EventSource auto-reconnects. - 60-4: stream_task_events and stream_scan_events never checked request.is_disconnected(), so a zombie client held a coroutine and a Redis connection until the next XREAD tick (up to 30s). Both now take the Request, check is_disconnected() each live-loop iteration, and end promptly. - 60-6: the ACTIVE_SSE gauge was defined but never touched. It is now incremented when an SSE generator starts and decremented in a finally on every exit path (normal end, disconnect, exception) across the sse_events, tasks, and scans SSE handlers. Tests: lifetime cap ends the generator after the deadline; a disconnected client ends the scan/task stream on the first check; the gauge increments then decrements in order.
…edule isolation, registry lock (#46) - 46-3: AutomationRunner.tick() gained an asyncio.Lock so a slow tick that overlaps the next cron fire is skipped instead of double-submitting. The per-schedule path now writes last_run_at + last_run_result='pending' BEFORE the queue submit (rewritten to 'submitted:<task_id>' on success, 'error' on failure), so a crash between claim and submit marks the schedule fired for the cycle rather than letting the next tick re-fire it. - 46-4: the per-schedule loop caught only (AILAError, SQLAlchemyError), so a handler raising anything else aborted the whole tick and starved later schedules. It now isolates each schedule with the same resilience-boundary exception set the emitter uses, logs with traceback, and continues; KeyboardInterrupt/SystemExit/CancelledError still propagate. - 46-8: AutomationRegistry claimed thread-safety without a lock. A threading.Lock now guards register_action (the check-then-set race) and list_actions (dict iteration during mutation). Tests: overlap skip, claim-before-submit ordering (success and submit-failure paths), per-schedule exception isolation, atomic registration under 24-thread contention, and stable list snapshot under concurrent writes.
PlaybookRunnerService._run_step dispatched whatever MCP tool a proposed playbook named, with no allowlist, so an auto-proposed or poisoned playbook could invoke any wired MCP tool. New playbook_allowlist.py declares the read-only tool ids a playbook step may invoke (audit_mcp and ida_headless_exp read tools) as a static frozenset with an exact-match is_allowed(). _run_step now rejects a step whose tool is not on the allowlist before touching the dispatch path, returning a failed step result (tool_not_allowlisted) so the playbook's on-failure policy still applies. Follow-ups (not in scope, no DDL): per-workspace allowlist extras via ConfigRegistry once the runner carries a registry/workspace_id, create/update- time enforcement in the playbook API handlers, and a journal denial event once the AppendJournal wiring (#52) reaches this service. Test: an allowlisted tool dispatches; a non-allowlisted tool is rejected before _mcp_dispatch is awaited; exact-match semantics (no prefix/substring/case-fold).
…he analyzer (#58) _run_script_and_pull returned header['sha256'] -- the hash the untrusted analyzer host REPORTED -- as the authoritative digest, so a compromised or buggy analyzer could report a value that did not match the bytes actually delivered. New hash_ledger.py computes and verifies sha256 over local bytes (verify_or_raise / verify_file_or_raise, streamed 1 MB chunks, claim-shape validated as 64-char hex before comparison). _run_script_and_pull now recomputes the digest over the pulled file through the platform run_blocking_io offload and returns the local digest on match; on mismatch it quarantines (unlinks) the local copy and raises FileRetrievalError, logging claimed vs computed for the operator run log. Follow-ups (not in scope, depend on #52 AppendJournal DDL): emit retrieval_hash_reported / retrieval_hash_verified journal events, and a honesty-audit rule flagging a retrieval helper that returns header['sha256'] without an intervening verify call. Test: matching bytes return the locally recomputed hash; a mismatch raises with claimed/computed/size populated; garbled claim raises ValueError before opening the file; streamed multi-chunk compute matches hashlib.
The async render path was hardened earlier, but PDFReportRenderer.render (the
synchronous path) still built a bare Jinja2 Environment with no autoescape and
no safe_url filter, then rendered the same template that emits
{{ row.nvd_url | safe_url }} and untrusted finding/note fields. HTML in a host,
package, note, or rationale survived into the PDF and a javascript:/data: nvd_url
reached the href. render now uses the shared _build_report_env (autoescape +
safe_url), matching the async path; the now-unused jinja2 Environment/Undefined
import is dropped.
Test: rendering the real _HTML_TEMPLATE through _build_report_env escapes a
script/img note and system_name and neutralizes a javascript: nvd_url to
about:blank.
state_deep_analysis ran _analyze_single_file (strings/FLOSS/capa over SSH, seconds per file) inside an open UnitOfWork, so a pooled DB connection was held for the entire multi-file pass -- a pool-exhaustion risk under concurrent investigations. Split into two phases: _collect_deep_artifacts runs all SSH analysis with no session open and returns (artifact, source_evidence_id) pairs; _persist_deep_artifacts adds them in one short transaction. Behavior is preserved -- a file whose analysis raises is still logged and skipped, and the artifact set, counts, and return payload are unchanged. Tests: collection skips a failed file and pairs the source id; persistence writes every collected artifact with correct per-family counts.
vr.investigate.hub runs the VR lifecycle as a dispatch graph: recon runs first and posts scoping discoveries; the audit phases (source / variant / binary / mobile, each keeping its V2 per-phase MCP server allowlist) activate on those discoveries under advisory trust and per-capability routing; poc_development is the gated phase (trust=confirmed, capability=exploit-dev) so it activates only once the panel confirms an exploitable finding by quorum, never on a raw hunch. Reuses the Phase 0-4 substrate (dispatch hub + ledger + oracle) with no new platform code. Ships alongside V1 and V2, bound nowhere live -- an operator seed rebind enables it after smoke, exactly like the malware hub. 4 tests: structure, poc phase is confirmed/exploit-dev, poc activates only on a quorum-confirmed finding, and capability routes a branch to its phase.
…#68) forensics.investigate.hub runs the forensics pipeline as a discovery-driven dispatch over the shared substrate, reusing every existing stage handler unchanged. make_evidence_condition (new, content-aware) matches a discovery's evidence_type, so a discovered disk image opens the disk and binary lanes and a pcap opens the network lane, off the existing _LANE_EVIDENCE_TYPES classification. Each phase adapter runs the real stage and only overrides the transition back to the hub; each lane phase scopes state_collection to its single lane via active_lanes. The deterministic tail (deep_analysis, promotion, resolution, writeup) runs unconditionally after the lanes. record_evidence posts a discovered evidence item to the shared ledger as the cross-branch evidence board (discovery entry carrying type, path, source; idempotent per path). No collector machinery is rewritten and the live FORENSICS_DISPATCHER_V1 is untouched -- the hub ships bound nowhere, enabled by an operator seed rebind after smoke, like the malware and vr hubs. 5 tests: hub structure (lanes + tail), disk_image activates disk, pcap activates network, evidence board records provenance + is idempotent, and no matching evidence falls through to the unconditional tail.
Lock the two RFC-13 invariants in the honesty audit. Rule 50 (static_node_mutation) forbids mutating a WorkflowDefinition.states map after construction -- assign into, delete from, or a mutator call on a .states attribute -- so the dispatch-hub / phase-graph node set stays frozen and every transition target is declared and auditable; a local states dict a builder assembles is a plain Name, not a .states attribute, so it never trips. Rule 51 (ledger_write_bypass) forbids a direct write to the investigation_ledger table (pg_insert / insert / session.add of the record, or a raw INSERT string) outside LedgerService, keeping it the sole writer that owns idempotency and the append-only rule; the service file and the alembic migration are exempt, and the audit tool self-exempts because its own strings name the table. _template/workflow.py documents the optional discovery-driven dispatch path (build_dispatch_workflow + PhaseSpec condition/capability/trust + make_discovery_condition) and the shared-ledger usage (LedgerService, Oracle) so a new module adopts the pattern by example, plus the static-node rule as a one-line reminder. 9 tests: both rules fire on crafted violations (states subscript/update/ delete; pg_insert/session.add/raw-SQL), stay silent on sanctioned code (local states dict, LedgerService.append_general), and the real ledger.py is exempt. Full-tree honesty stays at zero findings.
…aring (RFC-13, #68) A wiring audit found two gaps in the RFC-13 hub machinery (both inside the bound-nowhere hub path, so the live V1/V2 system was unaffected, but the hub would not behave fully as designed once enabled): 1. The Oracle had no live caller -- route/record/apply were defined and tested but nothing invoked them, so the request -> approve -> apply loop was dead. Now: a decider approves a request by naming it in the decision's new ledger_approvals field (the turn runner routes each vote through Oracle.record_decision, distinct-approver enforced, self-approval and unknown ids skipped without failing the turn); the dispatch hub calls Oracle.apply_all_ratified on every visit so a ratified request's effect (confirm the discovery, open the objective) lands before the hub re-evaluates activation. apply_decision is now idempotent via an applied marker so the per-visit sweep never double-applies a non-idempotent effect. 2. PhaseSpec.trust was declared and validated but never read -- confirmed vs advisory was enforced only by a confirmed_only flag baked into each condition, a second source of truth kept in sync by hand. Now the hub threads phase.trust into every condition and make_discovery_condition / make_evidence_condition resolve confirmed-ness from trust (standalone use still honors the confirmed_only param). The malware and vr hub definitions drop the redundant confirmed_only and rely on trust. Live path unchanged: ledger_approvals defaults empty (no-op for V1/V2), and the hub changes touch only the bound-nowhere dispatch definitions. Tests: oracle apply idempotency + apply_all_ratified; trust drives confirmed gating; agent approval records a decision and self-approval is skipped; and an end-to-end hub test where a ratified activate_phase request is applied by the hub and then activates the confirmed unpack phase.
…13, #68) Close the test-thoroughness gap the oracle-wiring defect exposed: the hub suites tested the router in isolation, so nothing caught that the oracle had no live caller. These two tests drive the whole loop through the real DurableStateMachine -- recon posts a discovery + a request, a sibling ratifies it, the hub applies the ratified request on its next visit (confirming the discovery), and the confirmed-trust deep phase then activates. The negative variant (request never ratified) asserts deep does NOT activate and the hub stalls, proving the hub-apply of a ratified request is load-bearing. Before the wiring fix the positive test would have failed (deep never activates), so this guards the exact regression. Deterministic, no LLM: the recon loop handler simulates the agent + oracle ledger activity that a live turn would produce.
Two wiring bugs stopped the dispatch-hub investigation lifecycle from running turns on strict LLM providers. - The turn runner wrapped engine failures as the module researcher error via a builtin-error except tuple that omitted LLMError. A non-retryable provider LLMError escaped run_turn, crashed the phase state, failed the task, and flipped the whole investigation to FAILED, which starved every sibling branch at the setup status-lock and completed the hub with zero turns. LLMError is now in the tuple, so the loop treats it as a researcher error: the investigation stays RUNNING and auto_continue re-enqueues the affected branch. - chat_json sent a strict json_schema built from ReasoningTurnDecision, whose free-form dict fields (observables, payload, edit_patches) cannot be expressed in strict structured-output mode. Strict OpenAI-compatible providers rejected the schema outright, failing every reasoning turn. chat_json now retries the same call in json_object mode, with the schema appended to the prompt, when a provider rejects the strict schema, so reasoning turns run on both strict and lenient providers. Rebinds the VR investigate seed to the dispatch-hub definition and adds regression tests for both paths.
Six wiring defects kept multi-branch dispatch-hub investigations from converging and let one investigation spawn hundreds of tasks. - The hub forwarded the last phase loop's stale max_turns exit reason to emit, so auto_continue re-enqueued on every hub completion. The hub now stamps hub_complete / hub_stalled / hub_budget_exhausted on its emit transitions; emit skips those reasons and caps auto_continue at a per-branch cycle ceiling; the dispatch walk and cycle counter persist across re-enqueues; a branch-scoped dedup suppresses duplicate per-branch tasks. Setup no longer resets an active branch on a RUNNING investigation. - Recon findings were written as note ledger entries but the audit phases activate only on discovery entries, so the graph never advanced past recon. Recon note writes are now recorded as discovery. - A branch reissuing an identical hard-blocked tool call now exits after three consecutive blocks instead of burning turns. - The pre-submit draft-pending gate now forces through after a rejection cap, matching the variant-hunt and unresolved-hypothesis gates. Adds draft_pending_reject_cap config and updates the ledger-write tests for the new _post_ledger_writes signature.
Regression guard for the dispatch-hub convergence fix. Recon agents post findings as ledger note entries; every audit phase gates on an exact discovery-kind condition, so without coercion the phase graph never leaves recon. Verifies _post_ledger_writes records a recon-phase note as a discovery, leaves notes untouched outside recon, leaves requests untouched in recon, and that the coerced discovery activates the audit-phase condition. Provider-independent.
Regression guard for the convergence endgame. Verifies evaluate_quorum tallies real sibling votes: active siblings approving to threshold ship the outcome via the approved_K_of_K_required path (not the dead-voter auto-approve safety net), rejects reaching veto_k reject it, and votes short of quorum leave the outcome in draft while siblings are still active so a live investigation cannot ship prematurely. Provider-independent.
compute_quorum now approves a draft finding on a majority of the static non-proposing branch count (max(2, ceil(N/2))) instead of near-unanimous (max(2, N-1)), matching the documented formula. A lone abstain or request_edit no longer makes approval mathematically unreachable. Auto-deliberation no longer resurrects a completed sibling that has no unvoted pending draft. spawn_persona_siblings gains an optional should_reactivate predicate; VR supplies one that returns True only while an unvoted draft exists. A fully-deliberated split finding therefore settles: the panel goes quiet, the investigation completes, and the unapproved draft is held for operator review instead of cycling to the auto-continue cap. Tests: majority quorum resolution, abstain-does-not-block-majority, premature-approval hold, and the reactivation guard (completed branch left completed without review work, reactivated with it).
Dispatch-hub workflow: deep wiring audit + remediation planA claim-by-claim audit of the RFC-13 dispatch-hub investigation workflow ( Verified WORKING
Confirmed defects (evidence-backed)
Remediation planPhase 1 -- core wiring
Phase 2 -- honest cleanup
Phase 3 -- platform backbone
Phase 4 -- legacy removal
Each fix ships with targeted tests and a live smoke; no version bump lands until the release step. |
…oter findings RFC-13 Phase 4. LedgerService.confirm_branch_discoveries writes a decision entry per discovery authored by the proposing branch when a finding is approved by sibling quorum, connecting the outcome-review quorum to the ledger oracle. Before this nothing wrote kind=decision rows, so no discovery was ever confirmed: confirmed-trust phases (poc_development) could not activate and replan requests could not ratify. emit invokes the bridge on approval. The multi-branch no-active-voters fallback in evaluate_quorum no longer auto-approves. A finding whose siblings all went idle before reaching quorum is held as draft for operator review rather than shipping with zero corroboration. The single-branch no-siblings auto-approve is unchanged. Tests: confirm_branch_discoveries scopes to the branch, is idempotent, and revives the confirmed-trust condition; no-active-voters now holds.
The field had zero readers and zero setters across all 30 PhaseSpec instances; its docstring falsely claimed per-phase prompt selection. The prompt family is selected from the investigation-level strategy_family in the turn runner. Removed the field and corrected the docstring. Also drop a stale lazy-import comment in emit (the ledger import is top-level).
Remediation progress (dispatch-hub audit)Shipped on Fixed (committed + tested)
New tests: Assessed -- no change needed (honestly documented, not lies)
Staged / infra-gated (not flipped blind)
Net: the dispatch hub's evidence/trust layer now executes end to end (confirmed-trust reachable, no vacuous ships), the false affordance is gone, and the remaining backbone coverage (malware bind, forensics migration) is scoped and smoke-gated. |
Restores PhaseSpec.strategy_family as a functional per-phase prompt-family override (reverting the earlier removal). The loop writes it to the _directive.phase_strategy_family observable at phase entry; the turn runner selects that family for prompt + task_type, falling back to the investigation-level strategy_family when a phase sets none. Both module loop builders (vr, malware) pass phase.strategy_family. Feeds the previously-unfed _budget_exhausted hub guard: a phase loop that exits on its turn cap sets it once the branch cumulative turns reach the overall cap, so the dispatch hub enforces the overall budget within one task walk, not only across re-enqueues. Tests: per-phase strategy observable write; dispatch-engine budget/stall/ ratify unchanged.
Adds the platform specialist_agent table (migration 103), the SpecialistAgentRecord model + SpecialistAgentSummary/Create contracts, and the SpecialistAgentRegistry service (CRUD, list_by_module, resolve_capability, find_by_capability, seed_defaults). A specialist is data: a capability that matches a dispatch phase, an optional prompt family, and a description, scoped per module. Built-in defaults seed re / mobile / exploit-dev / variant (vr) and re / crypto (malware); users add their own without a code change. A spawned specialist branch carries the specialist name as its persona_voice; resolve_capability maps that back to a capability so setup can thread _branch_capability into the dispatch hub. Core roles are not in the registry, so they resolve to None and walk every phase. Additive: nothing is wired to spawn specialists yet.
Setup resolves a branch persona_voice through the specialist registry and puts the capability on the dispatch input as _branch_capability, so the hub _pick filter routes a spawned specialist to its capability-scoped phases. Core roles are not registered specialists -> resolve to None -> walk every phase (unchanged dialectic). Additive: no specialist branches are spawned yet, so this is a no-op until the request->spawn path lands.
Oracle gains the request_specialist intent (a benign apply-effect, since
spawning needs the module bindings the oracle lacks) plus
ratified_specialist_capabilities, which lists the target capabilities of
ratified request_specialist requests. persona_spawn gains
spawn_specialist_branch: spawns one branch whose persona_voice is the
specialist name, idempotent on persona_voice. VR setup, after the core
panel, resolves each ratified capability to a registry specialist and
spawns it; the persona_voice then resolves back to _branch_capability so
the hub routes it to the capability-scoped phases.
A core branch files {intent: request_specialist, target_capability: X}; a
distinct branch (the critic) ratifies it. Tests: self-approval blocked,
ratification reports the capability, spawn is idempotent.
Adds /agents/specialists (list, upsert, seed defaults, delete), registered in the app. Any authenticated caller defines a modules specialist agents, so operators add expert perspectives without a code change. Thin wrappers over the tested SpecialistAgentRegistry; DataEnvelope responses.
…ementer) The default deliberation panel is the 3-role spine -- halvar (researcher, primary) + maddie (critic) + renzo (implementer) -- instead of the former fixed 6 personas. The sibling list is read from vr.core_persona_siblings (new config field, get_str reader added to ModuleConfigReader) and falls back to the maddie,renzo baseline when unset, so a fresh worker never collapses the panel to the lone primary. Unknown names are skipped. Expert diversity now comes from optional specialist agents spawned on demand via the oracle, not a hardwired panel. Changes the quorum denominator and cuts baseline cost. Tests: baseline, override, None fallback, unknown-name skip.
The specialist-spawn poll ran only in the setup state, which does not re-run on auto-continue. A request_specialist ratified after setup -- the real use case, since a branch requests an expert eye after recon -- never spawned; the panel stayed at the core roles for the whole investigation. Extract the poll into _spawn_ratified_specialists (self-derives the primary branch and team_id from the investigation) and bind it as InvestigationStateBindings.specialist_spawn_fn on both the setup and loop bindings. The loop base invokes it on each live turn, so a request ratified by a sibling spawns the matching registry specialist on the next turn (idempotent per persona_voice). Also fixes the honesty-audit failure the registry shipped with: replace two equality-to-True WHERE comparisons with .is_(True) and inline the unused builtin_specialists forwarding wrapper. Verified live: a mid-run request_specialist ratified by a distinct branch spawns the capability-matched specialist within one turn, and the spawned branch runs its own turns.
VRBranchSummary.persona_voice was typed as the PersonaVoice enum, but
on-demand specialist branches carry a specialist voice identifier (re,
exploit-dev, mobile, variant) that is not an enum member. Response
validation rejected it, so GET /vr/investigations/{id}/branches -- and
the branch SSE event, which dumps the same summary -- returned 500 for
any investigation that had spawned a specialist. The detail page then
lost its entire branch and agent-name display for those investigations.
persona_voice is now a plain string (a voice identifier: a core persona
name OR a specialist name), since specialists are user-extensible via the
registry and the valid set is open. The frontend type is widened to match;
personaMeta() and formatBranchDisplayName() already fall back for names
outside the core PersonaVoice set.
…olders A VR investigation is bound to exactly one audit_mcp index (its primary source-repo target). The model cannot know the opaque index id and routinely improvises: known placeholders (main/master/head/...) OR invented names (code_graph, ...). The executor only auto-corrected a hardcoded blocklist of placeholders, so any value outside that list slipped through and hit audit-mcp as a bogus index, returning Unknown index / not indexed and burning a turn. Replace the blocklist with an unconditional override: when the investigation has a resolvable index, the executor substitutes it on every audit_mcp call regardless of what the model passed, and only leaves args untouched when there is no resolvable index or the model already passed the correct id. Removes the _BAD_INDEX_PLACEHOLDERS whack-a-mole set. Verified live: after re-enqueue on this code, the investigation ran with zero Unknown-index / bad-index tool errors and converged to a completed outcome.
The discovery-driven dispatch hub stalled at recon. The audit phases gate on make_discovery_condition(discovery), but recon agents put their target characterization in decision.hypotheses (plus a terminal scoping outcome), not ledger notes -- so no discovery ever landed, every audit phase stayed blocked, the hub raised an unratified replan, and the branch emitted a draft from recon. Two coupled fixes: - turn_runner coerces each live recon hypothesis into a ledger discovery (idempotent per hypothesis id) -- the feeder that unlocks source_audit / variant_hunt off the shared ledger. - definitions_hub gets its own recon directive: surface discoveries, do NOT submit a terminal finding during recon. The V2 kind-router directive told the agent to submit a scoping outcome, which ends the branch under the hub instead of handing off. Verified live: a fresh investigation posted 9 ledger discoveries with zero replan requests, instead of 0 discoveries + a stall.
Agents over-qualify read_function names with the class (e.g. a class-scoped method) but trailmark keys the function index on the bare method name. The bridge only appended nearest-name suggestions, so the agent repeated the same qualified name until the 3-strike hard-block, burning turns. On a not-indexed error the bridge now retries once with the tail after the last separator and returns the body if it resolves.
When a function is absent from the trailmark index under any name (both the class-rewrite and bare-name retries miss), the bridge previously only appended nearest-name suggestions to the error. The agent ignores those and repeats the call until the 3-strike hard-block, burning turns. Add a content-returning auto-fallback after the retries: with a file_path, read that file lines 1-400 from disk via the bridge read_lines virtual tool (bypasses the indexer); without one, run semantic_search on the name and return the top matching chunk. Either way the agent gets real code and stops looping.
Gate source_audit, taint_analysis, dependency_audit, crypto_audit, variant_hunt, binary_audit, mobile_audit, and fuzz_targeting on the target's kind instead of shared-ledger discoveries. Discovery-only gating stalled the hub when recon posted no discoveries and let a source-repo investigation walk into binary_audit or mobile_audit, whose server allowlist blocks the source path. poc_development stays gated on a quorum-confirmed finding. Add four tool-backed hub phases: taint_analysis and dependency_audit (source repositories), crypto_audit and fuzz_targeting (source and binary). The full source-repo walk is recon, source_audit, taint_analysis, dependency_audit, crypto_audit, variant_hunt, then fuzz_targeting.
A request_edit vote's suggested_edits and reviewer comment were written to the review row and never read. The synthesis agent, documented as the sole consumer, had no step that loaded them, so every requested correction was silently dropped and the docstring's claim was false. SynthesisRunnerBase now loads every review on the canonical outcome via a _load_reviews seam and passes it to _render_user_prompt; both the vr and malware renderers surface each vote, comment, and suggested_edits to the synthesiser so the consolidated verdict honors a requested confidence change, corrects a claim a reviewer flagged as wrong, and names a dissent instead of dropping it. Drops the pending-synthesis warning now that the consumer exists. Adds fold + omit-section tests for both modules.
The submit-block review directive listed each pending draft by id, kind, and confidence only, and the emit-time review notice passed the first 400 characters of the raw payload JSON as the summary. A sibling asked to vote saw no finding body, could not verify the claims, and abstained. Adds summarize_outcome_for_review to the platform review service: it extracts the finding text (answer for vr, headline_verdict for malware) plus a few high-signal fields, sanitises it against prompt injection, and bounds it. Both module submit-block directives and the emit-time review notice now render that excerpt. Adds helper unit tests.
Release 0.3.0 -- security, correctness, and reliability hardening
Rolls up the work landed on
devsince 0.2.1. Full detail inCHANGELOG.mdunder[0.3.0].Highlights
Behavior change requiring caller action
Not breaking
Verification
Opening for review; not for auto-merge.