Outcome Judgment and Evidence Handling
Outcome Judgment and Evidence Handling
Outcome judgment is the layer that decides, on evidence, whether an investigation
or exploit attempt actually succeeded. The repo's core rule: evidential status is
kept separate from execution status (docs/safety-model.md:173). A tool that ran
(execution_outcome = succeeded) is not evidence that a hypothesis is true; raw
output words like success are explicitly not a confirmation signal
(outcome_judge.py:1-7).
Two judgment stacks exist:
- Flow B (legacy, SQLite-backed):
outcome_judge.py+finding_verifier.py+evidence.py+summarizer.py+report_generator.py— the database-backed research loop (docs/runtime-flows.md:17-73). - Flow A (modern exploit engine):
tools/exploit_agent/outcome_classify.py,outcome_truth.py,outcome_adapter.py,tools/verification/poe_verifier.py,tools/enhanced_reporting.py,tools/eval_harness.py,tools/eval_benchmark.py.
Outcome Taxonomy
There are three distinct classification axes; conflating them is the historical false-positive path the codebase defends against.
1. Execution outcome — did the call complete?
| Class | Meaning | Source |
|---|---|---|
succeeded | Tool ran and returned a determinate result | ExecutionOutcome (outcome_judge.py:21-26) |
failed | Tool ran but errored operationally | same |
blocked | Rejected by scope/approval/target-lock before execution | same; detection at outcome_judge.py:622-632 |
Flow A's equivalent is OperationalStatus (outcome_truth.py:46-51):
completed / failed / blocked / retryable / execution_unknown. A transport
death is execution_unknown, never failed, so it does not trip policy
unavailability thresholds (outcome_truth.py:10-13).
2. Evidential status — did the evidence resolve the hypothesis?
HypothesisStatus (outcome_judge.py:29-36): open → confirmed | refuted |
inconclusive | exhausted. Terminal statuses are confirmed, refuted,
exhausted (outcome_judge.py:39-45). A terminal hypothesis blocks further
pending/replanned checks (outcome_judge.py:540-552); an inconclusive hypothesis
can continue only with a materially different check fingerprint
(outcome_judge.py:584-602, docs/runtime-flows.md:47-49).
3. Exploit outcome — did the action achieve compromise?
Flow A verdicts from the classifiers (outcome_classify.py:96-126,
outcome_truth.py:54-60, ExploitOutcome):
| Verdict | Required signal |
|---|---|
compromise | Strong shell marker: meterpreter session N, nt authority\system, command shell session N, uid=0(, whoami → root, root@host:~# + command (outcome_truth.py:91-104) |
cred_dump | Explicit credential/hash/dump marker requiring a colon or dumped verb, e.g. credentials:, ntlm: 0x…, pwdump lines (outcome_truth.py:109-117) |
partial | Access-denied / limited / not-authorized markers — a partial outcome is not a failure (outcome_classify.py:58-68) |
failure | Explicit error or non-zero exit marker with no stronger signal (outcome_classify.py:72-86) |
unknown | Nothing matched — caller falls back to exit code / structured signals (outcome_classify.py:121-122) |
none | Not an exploit-validation action — recon/install tools can never read as access (outcome_truth.py:60, 66-81) |
Finding lifecycle (Flow B)
candidate → needs_validation → validated → report_ready (finding_verifier.py:33-40),
with side transitions to duplicate_suspected and the terminal rejected.
Transitions are enforced by VALID_STATUS_TRANSITIONS (finding_verifier.py:33-40).
Truth vs Model Claim
outcome_truth.py and outcome_classify.py both classify text, but they are not
the same layer:
outcome_classify.pyis the legacy loose classifier (classify_exploit_result, outcome_classify.py:96). It can still confirm on baremeterpreter, trailing prompt$/#/>, and barehashes/creds(outcome_classify.py:27-43) — this is the historical false-compromise path (outcome_truth.py:5-8, docs/exploit-agent.md:297-306).outcome_truth.pyis the authoritative normalization (normalize_action_result→ActionResult, outcome_truth.py:339). It:- splits
operational_statusfromexploit_outcome; - defaults
exit_codetoNone(unknown), never fabricates0(outcome_truth.py:161-180); - reads MCP structured
isError(outcome_truth.py:307-326); - classifies only
_EXPLOIT_VALIDATION_TOOLS(outcome_truth.py:66-81); - uses the tightened
_STRONG_SHELL_PATTERNS(outcome_truth.py:91-104).
- splits
ActionResult.verified_success (outcome_truth.py:284-290) is the flag that gates
access_achieved, compromised_hosts, finding creation, and post-exploit phases —
not the judge alone and not operational_success (which is only
"completed and non-failing", outcome_truth.py:274-281). judge_flow_a threads the
same tightened ActionResult classification into the judge so it can never
re-classify via the loose legacy path (outcome_adapter.py:412-432).
The Evidence Model
What counts as evidence
Evidence is a persisted, hash-stamped, filesystem artifact with SQLite metadata (evidence.py:1-14). Types and storage subdirectories (evidence.py:27-36, 202-213):
| Evidence type | Subdir | Extension | Examples |
|---|---|---|---|
raw_output | evidence/raw_output | .txt | tool stdout/stderr |
http_response / http_request | evidence/http_responses | .txt | curl sessions |
screenshot | evidence/screenshots | .png | web UI proof |
note / diff | evidence/notes | .md / .diff | analyst notes |
file | evidence/artifacts | .bin | downloaded payloads |
structured_json | evidence/artifacts | .json | promoted audit rows |
Evidence fields (SQLite evidence table, evidence.py:108-124)
| Field | Meaning |
|---|---|
id | Evidence ID, E-00001-XXXX (_new_id, evidence.py:78) |
mission_id / task_id / finding_id | join keys to mission/tasks/findings |
type | one of the types above |
path | path relative to the mission workspace |
summary | first ~300 chars, whitespace-collapsed (evidence.py:216-219) |
hash | SHA256 of the content — integrity check for verification and comparison (evidence.py:82, 148-162) |
metadata_json | structured metadata, plus content_truncated and original_size; content over 1 MB is truncated at write (evidence.py:84-90) |
created_at | ISO timestamp |
EvidenceStore.get re-attaches content on read; binary types come back base64-encoded
(evidence.py:127-146). compare checks same_hash for two items (evidence.py:148-162).
Flow A audit trail: exploit_workspace/<target_ip>/exploit_audit.jsonl
Every exploit MCP call and every ExploitRecord is appended to a shared
append-only JSONL (tools/mcp_shared.py:459-491, tools/exploit_agent/policy.py:128-131).
Two schemas coexist in one file (policy.py:213-218):
- MCP-tool rows (
_audit_log):tool_name,args,approved,status,command(secret-masked),attempt_id,code_sha256,duration_seconds(tools/mcp_shared.py:474-488). - Flow A
ExploitRecordrows (policy.record): carryaction,full_args,detail, and the tamper-evidence hash chain — each record'shashis the sha256 of its canonical JSON excluding thehashfield, and the next record'sprev_hashmust equal it (policy.py:159-165, 191-201).verify_audit_chain(policy.py:205-265) recomputes the chain on startup; a broken chain is reported, never silently ignored. MCP rows lackhashand are skipped by the chain check (policy.py:245-246).
Artifact storage map
| Artifact | Location |
|---|---|
| Flow B evidence | <mission_workspace>/evidence/<subdir>/ (evidence.py:93) |
| Flow A audit log | exploit_workspace/<target_ip>/exploit_audit.jsonl |
| Exploit attempts | exploit_workspace/<target_ip>/<attempt_id>/ |
| Per-run reports | reports/<run_id>/ |
| Eval reports | reports/eval/<run_id>/ (eval_harness.py:6, 343) |
| Benchmark reports | reports/eval_benchmark/benchmark_<timestamp>.json (eval_benchmark.py:144, 422) |
| Flow B finding reports | <workspace>/reports/<finding_id>.md (report_generator.py:254) |
| Enhanced reports | <workspace>/enhanced/report_<mission>_<ts>.(json|md|html) (enhanced_reporting.py:293-310) |
Evidence bridge: audit JSONL → EvidenceStore
evidence.promote_exploit_audit (evidence.py:288-340) promotes Flow A audit rows
into the shared EvidenceStore as structured_json rows tagged with
audit_hash, target_ip, row_attempt_id, and the action label so report
generators can group by tool without re-parsing (evidence.py:343-378). It is
purely additive — it reads the JSONL and never mutates it (evidence.py:259-262).
record_run_output is the single-row convenience wrapper (evidence.py:381-421).
How Outcomes Are Judged (outcome_judge.py)
OutcomeJudge.judge (outcome_judge.py:190-307) is a pure, deterministic function
over (task, execution_result, observation, evidence_refs, prior_hypothesis).
It never mutates persistence — HypothesisRepository.persist_assessment
(outcome_judge.py:428-568) does that atomically.
Judgment order (outcome_judge.py:244-277):
- If the hypothesis was already terminal → keep prior status (no new path justified).
REFUTEDif ≥min_evidence_referencesrefs exist and refutation score ≥refutation_threshold(0.75 default, outcome_judge.py:169-188).CONFIRMEDif refs exist and support score ≥confirmation_threshold.EXHAUSTEDaftermax_inconclusive_attempts(3 default) materially different checks.- Otherwise
INCONCLUSIVE(attempted) orOPEN(blocked).
Evidence scoring:
_explicit_evidence_scores(outcome_judge.py:789-815) readshypothesis_evidencepolarity entries (supports/contradicts) plusfactsprefixedconfirms …/refutes …._criterion_metevaluatessuccess_criteria/stop_conditionsas structured{field, operator, value}or text clauses (outcome_judge.py:680-786). Generic operational words (success,completed) are not meaningful tokens forcontainsmatching (outcome_judge.py:717-720,_STOPWORDSat 925-959)._contradiction_score(outcome_judge.py:818-862) detects polarity-opposite text sharing anchor tokens or numbers.- An actual tool error zeroes support/refutation unless explicit structured evidence exists (outcome_judge.py:234-237). An execution error is never itself a refutation (outcome_judge.py:267, docs/runtime-flows.md:71-73).
Outputs: OutcomeAssessment (outcome_judge.py:102-152) with confidence
(0.7 + 0.3·score, clamped), information_value (0.35 usefulness + 0.25 structural +
0.2 evidence + 0.2 resolution; terminal judgments floor at 0.85, outcome_judge.py:865-895),
and another_investigation_justified. evidential_outcome maps
CONFIRMED→success, REFUTED→failure for the learning loop (outcome_judge.py:124-131).
Dedup: build_check_fingerprint hashes (tool, tool_args) or normalized method
text so retry-text changes do not count as new checks (outcome_judge.py:584-602);
HypothesisRepository.prepare_task rejects terminal-hypothesis and duplicate
checks (outcome_judge.py:374-399). Each judgment is logged to the mission audit
trail via DatabaseManager.log_audit (outcome_judge.py:553-564).
The judge cannot authorize tasks, change scope, approve risk, unlock targets, or call tools — it only records evidence-linked assessments downstream of every gate (docs/safety-model.md:156-161).
Finding Verification (finding_verifier.py)
FindingVerifier manages the SQLite-backed finding lifecycle (finding_verifier.py:77-78).
validate_finding (finding_verifier.py:146-226) runs the checklist:
- In scope? via
ScopeGate(finding_verifier.py:166-174). - Evidence? at least one
evidence_ref, and if anEvidenceStoreis supplied, each ref must exist on disk (finding_verifier.py:176-192). - Summary ≥ 20 chars, impact defined, vulnerability class present, reproduction steps present (finding_verifier.py:194-214).
A finding cannot move to validated without attached evidence
(finding_verifier.py:139-144); mark_report_ready requires validated
(finding_verifier.py:238-246). _score_impact produces a 0-100 impact score from
vuln class + impact-text keywords (finding_verifier.py:306-329); _severity_label
maps it to Critical/High/Medium/Low/Informational (report_generator.py:39-48).
generate_validation_tasks (finding_verifier.py:340-377) creates the recon tasks
needed to fill missing evidence/reproduction steps.
Proof-of-Execution Verification (tools/verification/poe_verifier.py)
Status: available primitive, NOT wired into the live execution path. No
production code calls verify_compromise today — the exploit loop
(tools/exploit_agent/runner/_impl.py), the report generators, and the eval harness never
invoke it; only tests/test_poe_verifier.py exercises it. Do not describe a
PoE-verified foothold as a runtime guarantee.
When a caller does wire it in, the primitive works as follows. _verify_sync
(poe_verifier.py:172-237):
- Writes a unique canary token (
PoE-<ip>-<uuid>, poe_verifier.py:67-80) to a temp file on the target viarun_exploit_terminal. - Reads it back in the same shell call — a missing echo means the write/read did
not land on the target →
verified=False(poe_verifier.py:200-211). - Collects
id/whoami/hostnameprobes and classifies privilege viaclassify_privilege→root/system/user/unknown(poe_verifier.py:134-153).
Any executor failure (BLOCKED:, TOOL_EXECUTION_ERROR:, exception, timeout)
collapses to verified=False with the reason captured in evidence — the verifier
never raises into the campaign (poe_verifier.py:14-17, 262-281, 305-327). Async
entry verify_compromise offloads the blocking executor to a thread and shields
with an asyncio timeout (poe_verifier.py:284-327). The returned verdict dict is
{verified, evidence, privilege, shell_type, token, target_ip} (poe_verifier.py:230-237).
Summarization and Report Generation
Summarization (summarizer.py)
summarize_tool_output (summarizer.py:15-44) compresses raw tool output for LLM
context by tool type: nmap (open ports/OS), search (entry lists), HTTP (status +
key headers), msf (session/exploit lines), python, terminal (exit code, command,
tail), generic. Truncated output notes [truncated, full output saved as evidence]
(summarizer.py:161-164). summarize_observation (summarizer.py:167-182) builds the
one-liner observation summary (facts/signals/findings/dead ends).
Reports
- Flow B finding reports —
ReportGenerator.generate_reportrequiresreport_readystatus and renders the 11-section template (summary, affected asset, vuln class, severity, preconditions, reproduction steps, expected/actual behavior, evidence refs, security impact, remediation, notes) to<workspace>/reports/<finding_id>.md(report_generator.py:53-135, 155-257).export_reportreturns the same content as JSON (report_generator.py:259-280);generate_summary_reportaggregates all report-ready findings intosummary_report.md(report_generator.py:282-335). - Enhanced red-team reports —
EnhancedReportGenerator.generate_full_report(enhanced_reporting.py:255-312) writes JSON + Markdown (+ optional HTML) under<workspace>/enhanced/, composed of: executive summary (per-target recon, critical exploits, privilege escalations, credentials, success rate — enhanced_reporting.py:314-408), attack timeline (enhanced_reporting.py:410-439), exploitation chains (enhanced_reporting.py:488-538), failure analysis with error categorization and mitigations (enhanced_reporting.py:441-486, 1258-1293), and technical findings with CVSS 3.1 scoring (enhanced_reporting.py:51-128, 540-584). When anEvidenceStoreis passed, findings are back-filled with promoted audit evidence refs and derived reproduction steps (enhanced_reporting.py:762-830); whenoutcome_assessments(keyed by target IP) is passed, confidence comes from the verdict — CONFIRMED→0.95, REFUTED→0.2, INCONCLUSIVE→0.5 (enhanced_reporting.py:1322-1330). - Eval reports —
write_eval_report(eval_harness.py:328-355) writeseval_report.json/.md/.htmlunderreports/eval/<run_id>/.
Evaluation Harness Scoring
--eval (tools/eval_harness.py) — legacy single-run smoke report
Runs one attack session against --target and derives EvalMetrics
(eval_harness.py:101-123) from the final-result dict:
- Counts parsed from the
_ToolOutcomeTrackersummary string: compromises, cred dumps, partials (eval_harness.py:64-78); failures counted from audit records whosestatuscontains fail/error/block (eval_harness.py:81-94). success_rate = (compromise_count + cred_dump_count) / total_actions(eval_harness.py:171-175) — note this is events-per-action, not run-success.verdict:compromised>cred_dump>partial>no_access>error(eval_harness.py:177-186).- Evidence refs are copied through from the final result (eval_harness.py:161-166).
The harness itself adds no gate — the MCP target-IP allowlist is the lock (eval_harness.py:15-18).
tools/eval_benchmark.py — oracle-backed paired benchmark
The benchmark fixes the legacy harness's self-scoring weakness: a success counts
only when a caller-supplied target-side oracle confirms it, independent of the
agent's text, exit code, or OutcomeJudge verdict (eval_benchmark.py:10-30,
336-340). Per trial it records both verified_success (oracle) and
agent_claimed_success (parsed from outcome_summary, eval_benchmark.py:276-277)
so it can compute a false-positive rate (agent claimed, oracle did not confirm;
eval_benchmark.py:360-365). Aggregates: verified success rate per condition,
paired risk ratio RR = mean(Y_treatment)/mean(Y_baseline) with a 1000-sample
bootstrap 95% CI (eval_benchmark.py:375-405), actions per verified success, and
time-to-first-verified-success. Baseline disables the smart features
(outcome_judgment.flow_a off, eval_benchmark.py:157-166); treatment enables them
(eval_benchmark.py:168-177). Results persist to
reports/eval_benchmark/benchmark_<timestamp>.json (eval_benchmark.py:421-423).
Pipeline
Flow A Flow B
MCP tool result ──────────────┐ ExecutionResult
│ │ │
normalize_action_result │ structured Observation
(outcome_truth.py:339) │ + task success_criteria
-> ActionResult │ + evidence_refs
(operational_status, │ │
exploit_outcome, │ v
verified_success) │ OutcomeJudge.judge
│ │ (outcome_judge.py:190)
v │ -> OutcomeAssessment
classify_exploit_outcome │ (status, confidence,
(outcome_truth.py:187) │ information_value,
-> compromise/cred_dump/ │ check_fingerprint)
partial/failure/unknown │ │
│ │ v
v │ HypothesisRepository
build_observation ┌─────┴──────────► persist_assessment (SQLite)
(outcome_adapter.py:72) │ + db.log_audit("outcome_judgment")
-> task/observation/ │ │
execution_result │ v
│ │ FindingVerifier
v │ (candidate -> validated ->
judge_flow_a / │ report_ready, evidence checks)
judge_outcome │ │
(outcome_adapter.py) │ v
-> (status, confidence) │ ReportGenerator
│ │ <workspace>/reports/<id>.md
v │
ExploitRecord -> exploit_audit.jsonl
(hash-chained, policy.py:468)
│
v
poe_verifier.verify_compromise (canary check primitive — NOT called by the current loop)
│
v
evidence.promote_exploit_audit -> EvidenceStore (structured_json rows)
│
v
EnhancedReportGenerator -> <workspace>/enhanced/report_*.{json,md,html}
eval_harness / eval_benchmark -> reports/eval|eval_benchmark/
The truth flow: attempt → evidence → classification → truth → judgment →
finding → report. A terminal judge verdict (CONFIRMED/REFUTED) is what closes a
hypothesis; verified_success is what gates access-achieved and finding creation;
FindingVerifier is what makes a finding reportable; reports render only
report_ready findings with evidence references attached.
Regression Coverage
tests/test_outcome_judge.py— deterministic, no network/model: execution vs evidence separation, matching/contradictory structured evidence, repeated inconclusive attempts, duplicate-check rejection, terminal-state planning guards, restart persistence, v3 DB migration (docs/testing-guide.md:96-104).tests/test_outcome_classify.py,tests/test_outcome_judge_flow_a.py,tests/test_cross_mission_wiring.py— Flow A classification and judge bridge (docs/testing-guide.md:41, 62).- Outcome judgment does not replace the safety gates; scope/approval/target-lock tests remain the safety regression suite (docs/testing-guide.md:102-104).