Skip to content

Release 0.3.0: security, correctness, and reliability hardening#66

Open
echel0nn wants to merge 325 commits into
mainfrom
dev
Open

Release 0.3.0: security, correctness, and reliability hardening#66
echel0nn wants to merge 325 commits into
mainfrom
dev

Conversation

@echel0nn

Copy link
Copy Markdown
Contributor

Release 0.3.0 -- security, correctness, and reliability hardening

Rolls up the work landed on dev since 0.2.1. Full detail in CHANGELOG.md under [0.3.0].

Highlights

  • Security / tenant isolation: OIDC callback state validation, IDOR closed across malware/systems/tags routes, prompt-injection fencing, SSRF redirect re-validation, SFTP traversal rejection, atomic API-key revocation, audit-in-transaction.
  • Audit / evidence: append-only hash-chained platform journal, merkle-sealed evidence packs, CLI audit trail on the journal.
  • LLM cost / resilience: per-run token budget hard-stop, per-tool timeout, pooled clients, provider-error redaction, fail-fast on non-retryable errors.
  • Correctness: session chat now awaits the platform (was echoing input), supervised automation tick loop, workflow retry backoff off-by-one, findings/forensics SQL pagination, SMTP config keys declared, env-tunable DB pool.
  • Foundation: full test suite migrated to PostgreSQL/async; eval metric functions (ECE/precision/recall/determinism/faithfulness).

Behavior change requiring caller action

  • CORS credentials disabled under wildcard origins; OIDC state cookie secure by default. A client relying on credentialed CORS with a wildcard origin, or on the cookie over plain HTTP, must adjust configuration.

Not breaking

  • The findings and forensics list response envelopes are unchanged.

Verification

  • compile / ruff / honesty_audit clean; per-slice tests pass. All 114 pre-release commit messages were audited against their diffs for over/understatement.

Opening for review; not for auto-merge.

echel0nn added 30 commits July 20, 2026 20:42
…#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.
echel0nn added 3 commits July 25, 2026 16:20
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.
echel0nn added 7 commits July 25, 2026 17:49
…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).
@echel0nn

Copy link
Copy Markdown
Contributor Author

Dispatch-hub workflow: deep wiring audit + remediation plan

A claim-by-claim audit of the RFC-13 dispatch-hub investigation workflow (platform/workflows/phase_graph.py) against the actual wiring and live runtime evidence (5 read-only audits + DB/log inspection). Distinguishes "code exists" from "code executes with real inputs."

Verified WORKING

  • Dispatch hub executes; VR investigate is bound to VR_INVESTIGATE_HUB (modules/vr/workflow/task.py:121); dispatch state observed at runtime.
  • Shared append-only ledger blackboard (investigation_ledger); discoveries/notes/requests written by all branches.
  • Advisory discovery-driven phase activation (make_discovery_condition); recon -> source_audit -> variant_hunt -> binary_audit observed live.
  • Per-phase allowed_servers MCP allowlist: hard block at platform/agents/tool_executor.py:575-593, with tests.
  • directive -> _directive.phase_mission; trust threaded into conditions; evaluate_quorum invoked from the bound emit handler.
  • Majority quorum (max(2, ceil(N/2))) and real sibling dialectic (mixed approve/abstain/request_edit/veto votes observed).

Confirmed defects (evidence-backed)

  1. Confirmed-trust tier is inert. No code path writes kind='decision' ledger rows at runtime (0 in DB); the outcome-review quorum and the ledger oracle are disconnected -- approving a finding never confirms a discovery. Effect: poc_development (trust=confirmed) can never activate; 16 discoveries all unconfirmed.
  2. Replan ratification half-wired. 9 replan requests written, 0 ratified (needs decision rows) -> the stall relax path never fires; the hub always emits on stall.
  3. Vacuous approval. A 6-branch investigation shipped an approved finding with zero votes via the auto_approved_no_active_voters fallback (all siblings went idle).
  4. Capability routing dead by design. _branch_capability is never threaded anywhere; the per-phase capability fields never filter. The 6 personas are reasoning styles, not phase specialties, so all-branches-walk-all-phases is the correct behavior -- the fields are decorative and should be documented or removed, not enforced.
  5. _budget_exhausted hub early-exit is dead (never set; self-documented in the code).
  6. PhaseSpec.strategy_family ignored. The field and its docstring claim per-phase prompt selection, but the loop builder never passes it; the prompt is selected from investigation-level strategy_family.
  7. Backbone coverage incomplete. Only VR runs the hub. Malware investigate is bound to MALWARE_INVESTIGATE_V1 (hub built but unbound). Forensics runs a separate dispatcher architecture that does not use the platform investigation bases. Several definitions are built but never bound (VR v1/v2, malware v2, forensics hub) -- dead scaffolding.

Remediation plan

Phase 1 -- core wiring

  • Bridge outcome-quorum approval -> ledger confirmation: on quorum approval, record a confirm decision for the proposing branch's discoveries, connecting the two consensus systems and reviving confirmed-trust + poc_development + replan ratification. Deterministic, no prompt dependency.
  • Change the multi-branch no-active-voters fallback from auto-approve to hold-as-draft for operator review (single-branch no-siblings auto-approve is unchanged).
  • Wire per-phase strategy_family with fallback to investigation-level.
  • Wire _budget_exhausted from the overall turn cap, or remove the dead branch.

Phase 2 -- honest cleanup

  • Remove decorative per-phase capability fields; document that every branch walks every phase.
  • Clean recon trust metadata (condition=None makes trust irrelevant).

Phase 3 -- platform backbone

  • Bind the malware investigate seed to MALWARE_INVESTIGATE_HUB after a live smoke, so the hub covers malware.
  • Assess migrating forensics onto the platform investigation bases + hub.

Phase 4 -- legacy removal

  • Remove unbound definitions once the live bindings are settled.

Each fix ships with targeted tests and a live smoke; no version bump lands until the release step.

echel0nn added 2 commits July 26, 2026 13:46
…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).
@echel0nn

Copy link
Copy Markdown
Contributor Author

Remediation progress (dispatch-hub audit)

Shipped on dev since the audit comment above:

Fixed (committed + tested)

  • Quorum -> ledger confirmation bridge (7c47078). LedgerService.confirm_branch_discoveries writes a decision entry per discovery authored by the proposing branch when a finding is approved by quorum; emit invokes it on approval. This connects the previously-disconnected outcome-review quorum and ledger oracle, reviving the confirmed-trust tier: confirmed-trust phases (poc_development) can now activate and replan requests can ratify. Since malware runs single-branch, its no-siblings auto-approval now also confirms discoveries, so the malware hub's unpack/config_extract confirmed-trust phases become reachable.
  • No vacuous approvals (7c47078). The multi-branch no-active-voters fallback no longer auto-approves a finding with zero corroboration; it holds the outcome as a draft for operator review. The single-branch no-siblings auto-approve is unchanged.
  • Removed dead PhaseSpec.strategy_family (3181000). Zero readers, zero setters across all phases; its docstring falsely claimed per-phase prompt selection. The prompt family is selected from the investigation-level strategy_family; docstring corrected.

New tests: test_ledger_confirm_bridge.py (bridge scoping, idempotency, confirmed-trust condition revival), no-active-voters hold case in test_active_voter_quorum.py. Full sweep green (compile, ruff, honesty 0, 37 targeted tests).

Assessed -- no change needed (honestly documented, not lies)

  • _budget_exhausted hub early-exit: a tested reserved hook with an accurate self-documenting comment; the hub walk is already bounded by MAX_STEPS_PER_JOB.
  • Per-phase capability fields: the docstrings already state they are reserved until _branch_capability is threaded. The six VR personas are reasoning styles, not phase specialties, so all-branches-walk-all-phases is the correct dialectic behavior. Kept as documented intent.
  • recon trust metadata is inert on an unconditional phase (correct behavior).

Staged / infra-gated (not flipped blind)

  • Bind malware investigate seed to the hub. The malware hub loads clean and structurally mirrors the live VR hub (same builder, same platform bases, reused setup/loop builders). Binding flips malware's production workflow to a path that has not had a live smoke; per the module standard it needs a smoke (malware worker + ida-headless + a target) before the seed is rebound. Ready to bind after that smoke.
  • Forensics migration. Forensics runs a separate dispatcher architecture that does not use the platform investigation bases; moving it onto the shared hub is a larger rewrite, planned separately.

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.

echel0nn added 15 commits July 26, 2026 14:19
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.
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