Exploit Agent (Flow A)
The AI-driven exploitation agent is the core of the modern Flow A attack path:
main.py / app.py → tools/exploit_session.py → run_exploit_agent →
MCP tools in mcp_exploit_server.py. It is a permission-gated, target-locked
LLM loop that plans, calls MCP tools, reflects, and reports evidence back.
This doc covers the exploit agent implementation (canonical module
tools/exploit_agent/runner/_impl.py plus the supporting tools/exploit_agent/ modules).
It is the authoritative reference for the loop
lifecycle, the permission model, outcome classification, and the safety
boundaries. See CLAUDE.md (Permission Model, Boot Sequence) and
docs/architecture.md for the surrounding system.
Module map (canonical vs compat)
| Layer | Location | Status |
|---|---|---|
| Canonical loop implementation | tools/exploit_agent/runner/_impl.py (~2.1K lines) | Canonical. Defines run_exploit_agent, CheckpointContext/Outcome/Hook, _InMemoryExperienceStore, _resolve_attacker_os, _resolve_allowed_targets. |
| Package import | tools/exploit_agent/runner/loop.py | Imports the packaged _impl submodule with a plain relative import (from ._impl import ...) and re-exports its public API — no importlib file loading, so the built wheel is independently importable. |
| Runner shims | tools/exploit_agent/runner.py + runner/ (context.py, phase.py) | Re-export shims that make the file+directory coexist and forward to tools/exploit_agent/context.py / phase_tracker.py. |
| Public entry point | tools/exploit_agent/__init__.py::run_exploit_agent | The wrapper every caller uses; calls _sync_patchable_symbols() first so historical tests that patch tools.exploit_agent.* names keep working, then delegates to runner.loop.run_exploit_agent. |
| Phase tracker | tools/exploit_agent/phase_tracker.py | _PhaseTracker (phase minima + can_terminate). |
| Policy | tools/exploit_agent/policy.py | Permission modes, audit hash chain. |
| Supporting modules | context.py, prompt.py, tool_calls.py, tool_catalog.py, model_client.py, reflection.py, research_assistant.py, skills.py, outcome_*.py, _common.py | Imported by _impl.py via from tools.exploit_agent.X import .... |
What the agent is
A single-target autonomous loop. Each round the agent:
- Receives a system prompt + conversation history (with compaction, attack memory, and reasoning advisories injected).
- Calls the LLM with a phase-narrowed tool schema list.
- Filters/validates the returned tool calls, approves each through
ExploitPolicy, executes it over the MCP session, and feeds the result back as atool-role message. - Classifies the outcome (operational status vs. exploit outcome), tracks
compromises/cred-dumps/failures, and optionally runs an evidence-grounded
OutcomeJudgeverdict. - Every N actions runs reflection (heuristic or LLM), auto-consults peer models after repeated exploit failures, and re-selects advisory skills when recon reveals new services/CVEs.
The entry point is run_exploit_agent, canonically defined in
tools/exploit_agent/runner/_impl.py and re-exported through the package root
(tools/exploit_agent/__init__.py), which first calls _sync_patchable_symbols
to propagate historical monkeypatches into the split modules.
File-by-file reference
| File | Role |
|---|---|
tools/exploit_agent/runner/_impl.py | The main loop: run_exploit_agent, CheckpointContext/Outcome/Hook, _InMemoryExperienceStore, attacker-OS resolution, budget/compaction/reflection wiring. |
runner/loop.py + runner.py | Package import + re-export shims exposing the packaged _impl loop through the package. |
loop.py | Deprecated re-export shim (DeprecationWarning) — kept for one release. |
policy.py | ExploitPermission, ExploitSettings, ExploitPolicy (approval + audit), ExploitRecord, tamper-evident audit hash chain. |
phase_tracker.py | _PhaseTracker — phase minima and can_terminate(). |
context.py | Context sizing/compaction (_build_compacted_messages), attack memory, ULTRATHINK reasoning advisory, token estimation, sanitize_output. |
prompt.py | build_exploit_system_prompt + advisory briefings (OPSEC, domain target, parallel sub-agents, capability guidance). |
model_client.py | _call_model_with_retry, _stream_model, _call_model_with_tools — retry/backoff, tool-call normalization, canonical context_window_tokens for long-session. Provider-neutral (ollama_client.py is its deprecation shim). |
tool_calls.py | Tool-call normalization/filtering, _ToolOutcomeTracker (blocked/compromise/failure taxonomy), blocked-replan prompts, retry correction. |
tool_catalog.py | Phase-aware tool narrowing (select_tools_for_phase) + local schema validation (validate_tool_call). |
reflection.py | _generate_reflection (heuristic), _llm_reflect_inline, _consult_peers_inline, injection-pattern sanitization. |
research_assistant.py | ResearchAssistant — bounded read-only research sidecar with its own model conversation and a fixed tool allowlist. |
skills.py | _maybe_reselect_skills — mid-run advisory skill re-selection, rate-guarded. |
outcome_classify.py | Legacy conservative text classifier (classify_exploit_result). |
outcome_truth.py | Authoritative normalization: normalize_action_result → ActionResult, tightened markers, OperationalStatus/ExploitOutcome. |
outcome_adapter.py | Flow A bridge into OutcomeJudge (judge_flow_a, build_observation). |
_common.py | Shared import surface (mirrors the old monolith's imports; lazy ScopeGate import). |
__init__.py | Package root re-exports + _sync_patchable_symbols + run_exploit_agent wrapper. |
Loop lifecycle
Init (before the round loop)
run_exploit_agent (tools/exploit_agent/runner/_impl.py) does, in order:
- Bind the target lock —
policy._target_ip,policy._locked_ip, andpolicy._allowed_targetsare set once (tools/exploit_agent/runner/_impl.py); an LLM cannot reassign them._resolve_allowed_targets(tools/exploit_agent/runner/_impl.py) unions the target withexploit.allowed_targetsfrom config. - Resolve attacker OS —
_resolve_attacker_os(tools/exploit_agent/runner/_impl.py) mapsexploit.attacker_os: autoviaplatform.system(), distinguishing Windows / Darwin / Linux (macOS no longer gets the Kali branch). - Build the context profile —
_build_context_profile(context.py:72) picks compaction thresholds per model alias (GLM 976K, DeepSeek 1M, Kimi 256K, Minimax 512K;_MODEL_CONTEXT_PROFILESatcontext.py:19), withmodels.info.<alias>.context_windowoverrides. - Wire optional subsystems — adaptive exploit mutator + ExperienceStore /
SemanticMemory (
tools/exploit_agent/runner/_impl.py), session resume (tools/exploit_agent/runner/_impl.py), attack memory (tools/exploit_agent/runner/_impl.py), attack plan load/create (tools/exploit_agent/runner/_impl.py). - Build advisory context blocks — preflight env probe (
tools/exploit_agent/runner/_impl.py), OPSEC briefing (tools/exploit_agent/runner/_impl.py), domain briefing (tools/exploit_agent/runner/_impl.py), parallel sub-agent briefing (tools/exploit_agent/runner/_impl.py), research assistant (tools/exploit_agent/runner/_impl.py). - Build the system prompt —
build_exploit_system_prompt(tools/exploit_agent/runner/_impl.py, defined atprompt.py:11), plus the research-assistant briefing (tools/exploit_agent/runner/_impl.py). - Seed messages — resume from session state or a fresh system + user
message (
tools/exploit_agent/runner/_impl.py). - Startup research — if a research assistant is enabled, auto-consult on
the known CVEs/service context (
tools/exploit_agent/runner/_impl.py).
The round loop
The main loop is for _round in range(max_rounds) at tools/exploit_agent/runner/_impl.py. Skeleton:
for _round in range(max_rounds):
messages = _refresh_attack_memory_message(messages, policy) # tools/exploit_agent/runner/_impl.py
messages = _refresh_reasoning_advisory_message(messages, _recent_reasoning, ultrathink_enabled) # tools/exploit_agent/runner/_impl.py
if max_duration and (time.time() - start_time) > max_duration: # time budget
final = await _stream_model(client, model, messages, ...) # tools/exploit_agent/runner/_impl.py
break
if not policy.can_proceed: # command budget
final = await _stream_model(client, model, messages, ...) # tools/exploit_agent/runner/_impl.py
break
if _should_compact_context(policy, _round, _current_tokens): # context compaction
messages = _build_compacted_messages(messages, system_prompt, plan, policy, ...) # tools/exploit_agent/runner/_impl.py
response = await _call_model_with_retry(client, model, messages, _round_tools(), ...) # tools/exploit_agent/runner/_impl.py
messages.append(response)
tool_calls, invalid_calls = _filter_and_validate_tool_calls(raw_tool_calls, all_tools=agent_tools) # tools/exploit_agent/runner/_impl.py
if not tool_calls and not invalid_calls: # agent finished
if enforce_phase_minima and not phase_tracker.can_terminate(): # tools/exploit_agent/runner/_impl.py
... # goal-complete (compromise/cred_dump) may still break # tools/exploit_agent/runner/_impl.py
break
for tc in tool_calls: # tools/exploit_agent/runner/_impl.py
name, args = func.get("name"), func.get("arguments")
approved = await policy.approve_action(action=name, command=analysis_payload(name, args), detail=detail) # tools/exploit_agent/runner/_impl.py
if not approved:
... # BLOCKED message + replan prompt (tools/exploit_agent/runner/_impl.py)
result = await session.call_tool(name, arguments=args) # tools/exploit_agent/runner/_impl.py
result_text = ... # parse (tools/exploit_agent/runner/_impl.py)
messages.append({"role": "tool", "tool_name": name, "content": sanitize_output(result_text, ...)}) # tools/exploit_agent/runner/_impl.py
_action_result = _normalize_result(tool_name=name, result_text=result_text, mcp_result=result) # tools/exploit_agent/runner/_impl.py
success = _action_result.operational_status == COMPLETED and not is_error and exit_code in (0, None) and not FAILURE # tools/exploit_agent/runner/_impl.py
if _action_result.is_compromise: outcome_tracker.record_compromise(...) # tools/exploit_agent/runner/_impl.py
elif _action_result.is_cred_dump: outcome_tracker.record_cred_dump() # tools/exploit_agent/runner/_impl.py
if policy.settings.outcome_judgment_flow_a:
_flowa = await _judge_flow_a(...) # tools/exploit_agent/runner/_impl.py
_last_record = await policy.record(action=name, ...) # tools/exploit_agent/runner/_impl.py
if is_exploit_action: outcome_tracker.record_exploit_success()/record_exploit_failure() # tools/exploit_agent/runner/_impl.py
if reflection_interval > 0 and action_count % reflection_interval == 0:
reflection = await _llm_reflect_inline(...) # tools/exploit_agent/runner/_impl.py
if outcome_tracker.should_consult_peers(peer_threshold):
peer_advisory = await _consult_peers_inline(...) # tools/exploit_agent/runner/_impl.py
banners = parse_service_banners(result_text) # tools/exploit_agent/runner/_impl.py
_maybe_reselect_skills(...) # tools/exploit_agent/runner/_impl.py
phase_tracker.record_action(...) # tools/exploit_agent/runner/_impl.py
plan.add_step(...); plan.mark_step_done(...) # tools/exploit_agent/runner/_impl.py
After outcome normalization, the loop appends one best-effort decision-log
record (tools/decision_log.py::log_decision) — {round, tool, outcome, failure_class (via tools/failure_taxonomy.py::classify_failure when not success), success, evidence_refs} — wrapped in try/except so observability
never breaks the loop. The blocked/terminal replan prompt is augmented with a
FAILURE_CLASS: … — RECOVERY: … hint from the taxonomy when the result text
classifies to a known class.
Termination
The loop exits when:
- The agent stops emitting tool calls. Before honoring that, the loop
checks phase minima via
_PhaseTracker.can_terminate()(only when permission is notread_onlyand the terminal-constraint tripwire has not fired). If minima are unmet the loop injects a "finish early" warning withremaining_requirements()and continues — EXCEPT when the goal-complete predicate holds: a confirmed compromise or cred-dump (_ToolOutcomeTrackercounts) lets the run terminate naturally even with the recon/enum/research minima unmet. The reporting minimum is satisfied without a tool: on the no-tool summary turn, whenreportingis the ONLY unmet phase, the loop callsphase_tracker.record_summary_turn()(the final EXPLOIT_RESULT summary IS the reporting action; no MCP tool maps to thereportingphase, and the working phase / narrowed tool surface stays unchanged). - Mid-run operator checkpoint (no path). When
checkpoint_hookis set (the WebUI/API service wires one; the plain CLI does not), the safe natural-termination boundary with phase minima met but no verified foothold raises ano_pathCheckpointContext. The operator cancontinue(with a fresh objective),change_goal,finish, orcancel. A second checkpoint requires new actions since the last one (decision-loop guard). A second checkpoint kind fires right after a verified compromise/cred-dump (accesskind). - Budgets. The wall-clock budget (
attack_max_duration_minutes, attack mode only) or the command budget (policy.can_proceed) triggers a forced "summarize your findings" final message and stops. - Terminal-constraint tripwire. Repeated blocked/unavailable outcomes on
the same canonical call (
_terminal_constraint_prompt,tools/exploit_agent/tool_calls.py) inject a constraint prompt, stream a final summary, and stop.
The final result dict carries the target, action count, audit path, full audit
records, messages, active skills, research-assistant stats, and the outcome
summary — this is what tools/eval_harness.py and the report generator consume.
Phase tracking
_PhaseTracker (tools/exploit_agent/phase_tracker.py) enforces minimum
actions per phase (recon ≥ 2, service_enumeration ≥ max(1, services
detected), vulnerability_research ≥ max(1, versions identified), reporting
≥ 1) before the agent may terminate (can_terminate()). The loop records
actions into phases via phase_tracker.record_action(...) in
tools/exploit_agent/runner/_impl.py (tool-name → phase mapping lives there).
Prompts (prompt.py)
build_exploit_system_prompt (prompt.py:11) assembles the system prompt
from ordered blocks:
- Target info (IP, OS, CVEs, service context).
- Runtime skills — either full advisory skill context or a skill-lookup
hint block (
prompt.py:45-68); both are explicitly advisory and never override scope/permission/approval/safety/audit. - Attacker environment + preflight env context.
- ULTRATHINK deep-reasoning block (
prompt.py:77-90) requiring a[REASONING]...[/REASONING]block, or the lighter chain-of-thought block (prompt.py:91-100). - Attack mode block with the RECON→ENUM→EXPLOIT→ESCALATE→LOOT→PIVOT→DONE
workflow (
prompt.py:102-115). - Peer model consultation guidance (
prompt.py:117-126). - OS-specific attacker guidance: Windows (Python-only, no pip, no netcat,
nmap crash codes are deterministic —
prompt.py:128-156), Darwin (prompt.py:157-168), Linux/Kali (prompt.py:169-188). - Local target playbook when the target is loopback (
prompt.py:190-213). - OPSEC / domain / parallel-agents briefings (kwargs).
- Capability discovery + hypothesis workflow (
build_capability_guidance, kwargcapability_guidance: str = ""— empty when off): when enabled, tells the model to inspect state withget_assessment_state, discover what fits withquery_capabilities/get_capability_details, record beliefs withrecord_hypothesis, drive the task graph withupdate_task, form hypotheses before acting, satisfy prerequisites via composition, classify failures instead of blindly retrying, validate findings, and stop when the goal is met. - Exploitation workflow + RULES (no fabricated PoC URLs — only
cve_to_pocis a trusted source; FILE & KEY HANDLING: never heredoc keys, usewrite_python_file(binary=True)—prompt.py:224-272). - Final-response format contract (
EXPLOIT_RESULT/SUMMARY/ACCESS_TYPE/DETAILS).
Advisory briefings: build_opsec_briefing (prompt.py:282, empty for
local/off targets), build_domain_briefing (prompt.py:322, empty for bare
IPs), build_parallel_agents_briefing (prompt.py:368, empty unless
swarm.parallel_enabled).
Model routing and peer models
The active model client is resolved through the provider registry
(tools/providers/registry.py — Ollama is one optional provider, see
providers.md); all chat calls go through the provider-neutral
model_client.py:
_call_model_with_retry(model_client.py:35) — async wrapper with exponential backoff (2^attempt) on transient errors, run viaasyncio.to_threadso the event loop never blocks. Catches_EXC_GROUP_CATCH(see below). On exhaustion returns a syntheticERROR: LLM server disconnected...assistant message with emptytool_calls— the loop treats that as a normal message. Retry logs attribute by client provider ([MODEL RETRY opencode_go])._stream_model(model_client.py:71) — streaming final-response gathering, same retry pattern._call_model_with_tools(model_client.py:131) — the sync core; normalizes tool calls via_normalize_tool_call.- The canonical
context_window_tokenskwarg is only sent whenlong_session_enabled(tools/exploit_agent/runner/_impl.py); ONLY the Ollama provider adapter translates it tooptions.num_ctx(tools/providers/ollama_provider.py:apply_context_window), so non-long runs stay byte-identical to the pre-long-session behavior.
Peer models (multi_model config) are advisory-only. The
consult_peer_models MCP tool and the in-process _consult_peers_inline
(reflection.py:273) share one budget counter (_consultation_count in
tools/mcp_tools/registry.py:145, synced to the MCP server). Peers are
resolved via _resolve_consult_aliases (registry.py:190 — intersection of
multi_model.consult_aliases with models.registry, minus the active
alias), called through the cached ModelRouter (_get_model_router,
registry.py:168; ModelRouter.get_client at tools/model_router.py:178),
with tools=None — peers can never execute tools. Each peer.chat(...)
is wrapped in _EXC_GROUP_CATCH (reflection.py:382-389). One
status='advisory' audit record is written per consult (reflection.py:405).
Permission model (policy.py)
Modes
ExploitPermission (policy.py:12): FULL_ACCESS, APPROVE_ONLY,
READ_ONLY. ExploitSettings (policy.py:22) carries the budgets
(effective_max_commands/effective_max_rounds at policy.py:83-88),
long-session flags, adaptive-exploit flags, and outcome_judgment_flow_a
(policy.py:76).
Permission resolution happens in tools/cli_exploit_settings.py:
_resolve_exploit_permission (cli_exploit_settings.py:12) — an unknown or
missing exploit.permission key falls back to READ_ONLY (the safe
baseline; recon relies on this). Attack mode only upgrades to FULL_ACCESS
when config explicitly says so (cli_exploit_settings.py:112-115); recon is
always READ_ONLY regardless of config (cli_exploit_settings.py:157-159).
How approval resolves
ExploitPolicy.approve_action (policy.py:368):
- Records a
SECURITY_EVENTwarning ifattack_modeis on withoutFULL_ACCESS(policy.py:385-392). - Denies when the command budget is exhausted — with a
status="denied"audit row (_record_denial). READ_ONLY→ records the action asstatus="proposed"and returnsFalse— propose-only, never executes (policy.py:396-407).FULL_ACCESS→_enforce_mission_scopeconsults the missionScopeGate(tool → category via_TOOL_ACTION_CATEGORY, assets via thecommand_analyzer/validation_utilsdestination extractors with the locked target as fallback): a forbidden category or out-of-scope asset recordsstatus="SCOPE_DENIED"and returnsFalse; otherwise auto-approves, increments_command_count, returnsTrue. No command-content inspection;scope_gate=Nonestays permissive; a gaterequires_human_approvalverdict still auto-approves.APPROVE_ONLY→ prompts the operator (ALLOW <host>), or delegates to an asyncApprovalProviderwhen one is set (policy.py:428-439). Every non-approved exit — operator denial, EOF/KeyboardInterrupt abort, or post-approval budget refusal — writes astatus="denied"row through the chained writer (_record_denial), so the audit chain carries the human decision.
Why policy is NOT the safety boundary
The one attack-mode safety is the target-IP allowlist lock, enforced in
the MCP tool layer, not in policy.py:
tools/mcp_shared._allowed_target_list(mcp_shared.py:494) unionsexploit.allowed_targetswith the runtime env varsEXPLOIT_TARGET,EXPLOIT_TARGET_IP,EXPLOIT_TARGET_DOMAIN, andEXPLOIT_DISCOVERED_TARGETS(set bytools/mcp_session.py:255-266andadd_discovered_targetatmcp_shared.py:537)._check_allowlist(mcp_shared.py:558) gates every target-touching tool throughis_target_in_allowlist(domains +*.wildcard+ CIDR supported).tools/mcp_tools/terminal._target_lock_blockextracts every destination (URL authorities,/dev/tcphosts, LHOST/RHOST, scanner targets, even hostnames) from free-text commands and refuses anything not in the allowlist (CLAUDE.md:317).
full_access auto-approves after the policy's mission-scope check
(tools/exploit_agent/policy.py approve_action → _enforce_mission_scope):
forbidden action categories and allowed/disallowed asset rules are enforced
with a SCOPE_DENIED audit row, while command content is not inspected and
the allowlist remains the destination lock. Do not re-add removed gates
without first ensuring the allowlist covers the path you are de-restricting
(CLAUDE.md). The _TOOL_ACTION_CATEGORY map (tool → ScopeGate action
category) is live again as the category input to that check — category names
must never collide with scope_gate._HARD_FORBIDDEN_SUBSTRINGS
(tests/test_exploit_action_category.py pins this).
Audit trail
Every action is recorded via ExploitPolicy.record (policy.py:468) to
exploit_workspace/<ip>/exploit_audit.jsonl as an ExploitRecord
(policy.py:141) with a tamper-evident SHA-256 hash chain
(prev_hash/hash, _record_chain_hash at policy.py:191). The chain is
verified at policy construction (verify_audit_chain, policy.py:204); a
broken chain warns but does not refuse to run. The MCP tool layer appends its
own unchained rows to the same file — those are skipped by the verifier
(policy.py:237-246). The in-memory _records list is a bounded ring buffer
(MAX_INMEMORY_AUDIT_RECORDS = 500, policy.py:138); the on-disk JSONL is
authoritative (read_audit_records, policy.py:544).
Outcome classification pipeline
Three layers, in increasing authority:
1. outcome_classify.py — legacy loose classifier
classify_exploit_result (outcome_classify.py:96) returns
{outcome, shell_type, privilege_level, evidence} with verdicts
compromise | cred_dump | partial | failure | unknown. Conservative but
loose: bare meterpreter, trailing $/#/> prompts, and bare
hashes/creds can still match (_SHELL_PATTERNS at outcome_classify.py:27).
Still used by tools/autonomous_orchestrator.py:815 and the adapter's lazy
fallback.
2. outcome_truth.py — authoritative normalization
normalize_action_result (outcome_truth.py:339) turns one MCP result into
a single ActionResult (outcome_truth.py:245) — the source of truth the
loop, swarm, report, and eval all consume. It separates:
operational_status(OperationalStatus,outcome_truth.py:46) — did the call complete?completed / failed / blocked / retryable / execution_unknown. A transport death is not a tool failure.exploit_outcome(ExploitOutcome,outcome_truth.py:54) — did the action achieve compromise? Only meaningful for exploit-validation tools (_EXPLOIT_VALIDATION_TOOLS,outcome_truth.py:66); recon/install tools always returnnoneso they cannot be misread as access.exit_code— defaults toNone(unknown), never fabricated as0(_extract_exit_code,outcome_truth.py:161).is_error— read from MCP structuredisError(_read_mcp_is_error,outcome_truth.py:307).
The tightened classifier classify_exploit_outcome (outcome_truth.py:187)
uses _STRONG_SHELL_PATTERNS (outcome_truth.py:91) — bare meterpreter,
bare root, trailing prompt chars, and bare credential words were removed as
sufficient evidence (they produced false compromises on
"No meterpreter session was created", "root cause", HTML ending in >).
ActionResult.verified_success (outcome_truth.py:284) is the flag that
gates access_achieved, compromised_hosts, finding creation, and
post-exploit phases.
3. outcome_adapter.py — OutcomeJudge bridge (Phase 1.2)
When outcome_judgment.flow_a is true, the loop calls judge_flow_a
(outcome_adapter.py:358) after each exploit action. It synthesizes the
duck-typed task/observation/execution_result shapes OutcomeJudge.judge
expects (build_observation, outcome_adapter.py:72), maps the classifier
verdict to hypothesis-evidence polarity (compromise/cred_dump → supports →
CONFIRMED; failure → contradicts → REFUTED; partial/unknown → unpolarized →
INCONCLUSIVE), and returns (HypothesisStatus, confidence, classification).
The judge is pure — no DB on the Flow A path (an in-memory HypothesisState
seed, _seed_hypothesis at outcome_adapter.py:238). The loop feeds the
judge the same tightened ActionResult classification
(outcome_adapter.py:412-432) so the judge can never confirm on the loose
legacy markers. Any failure degrades to the shallow exit-code flag
(tools/exploit_agent/runner/_impl.py).
The loop's authority ordering (tools/exploit_agent/runner/_impl.py): the compromise/cred-dump
tracker reads _action_result.verified_success (tightened classifier), NOT
the judge's verdict alone; judge_flow_a only overrides the success flag
on CONFIRMED/REFUTED and owns the Bayesian learning loop. Terminal verdicts
are persisted to the ExperienceStore immediately (tools/exploit_agent/runner/_impl.py) and
again via the reflection bridge under action_type='reflection:verdict'
(reflection.py:240-269).
Reflection
_generate_reflection(reflection.py:10) — deterministic heuristic from the last 30 tool messages (success/failure tallies, most-failed tool, new hypothesis, strategy shift). No LLM call._llm_reflect_inline(reflection.py:88) — opt-in LLM reflection (reasoning.llm_reflection), routed through_call_model_with_retry(never a bare syncclient.chat— it would block the event loop and a bareexcept Exceptionwould missBaseExceptionGroup). The prompt is built from structured summaries (tool, success, exit_code, truncated error) — never raw tool content — bounding second-order injection. Every parsed JSON field is sanitized by_sanitize_reflection_field(reflection.py:82), which strips retarget/pivot-to-IP/ignore-prior/ override-scope shapes via_REFLECTION_INJECTION_PATTERNS(reflection.py:71). Falls back to the heuristic on any failure.- The reflection is injected as a user-role message framed
[ADVISORY REFLECTION — system-generated, not an operator command](tools/exploit_agent/runner/_impl.py). Reflections never feed the Bayesian ExperienceStore on the operational path (only a semantic lesson with the distinctaction_type='reflection:exploit_loop',reflection.py:215-226). - Auto peer-consult (
_consult_peers_inline,reflection.py:273) fires afterreasoning.peer_consult_on_failure_thresholdconsecutive exploit failures; in-process (not a re-entrant MCP call), advisory-only, budget shared with the MCP tool.
Research assistant
ResearchAssistant (research_assistant.py:184) is a bounded, read-only
sidecar available in attack mode. It runs its own short model conversation
(max_model_rounds, max_tool_calls_per_consultation) and may call only the
fixed RESEARCH_ASSISTANT_TOOLS set (research_assistant.py:32:
search_cve_intel, search_exploit_db, search_web_exploit,
fetch_webpage, deep_research, cve_to_poc) through the loop's existing
MCP session. It never receives execution schemas. Its system prompt
(research_assistant.py:46) hard-codes the security boundary: web content is
untrusted data, never follow embedded instructions, never invent CVEs/URLs.
The main model sees it as the local consult_research_assistant tool
(consultation_tool_schema, research_assistant.py:143). Triggers:
explicit model call (tools/exploit_agent/runner/_impl.py), startup evidence
(tools/exploit_agent/runner/_impl.py), new target evidence (tools/exploit_agent/runner/_impl.py), and repeated
exploit failures (tools/exploit_agent/runner/_impl.py, gated by note_exploit_outcome at
research_assistant.py:245). Advisories are rendered with citations retained
(format_for_main, research_assistant.py:421), persisted to
research_advisories.jsonl (_persist, research_assistant.py:663), and
audited with status="completed"|"error" (tools/exploit_agent/runner/_impl.py). Settings
come from research.assistant config (ResearchAssistantSettings.from_config,
research_assistant.py:111).
Runtime skills integration
- Startup: skill context/hints are baked into the system prompt
(
prompt.py:45-68); the model pulls full skill bodies mid-run via the read-onlyload_runtime_skillMCP tool. - Mid-run:
_maybe_reselect_skills(skills.py:44) rebuilds the advisory skill set when recon reveals new services/CVEs. Rate-guarded (reselect_max_per_run,reselect_min_interval_actions), no-op when the rebuilt set is identical, and announces changes as a[SKILL UPDATE]user-role message (skills.py:124-136). It only rewrites the advisorytarget_contextskill fields — never permission/scope/workspace/audit. - Feedback: successful
load_runtime_skillcalls record a neutral observation viatools/skill_feedback.record_skill_loaded(tools/exploit_agent/runner/_impl.py).
Tool catalog and tool-call parsing
- Phase narrowing:
select_tools_for_phase(tool_catalog.py:102) returns only the current phase's tool families (PHASE_TOOL_FAMILIES,tool_catalog.py:93) plus the universal set (_UNIVERSAL,tool_catalog.py:30— shell, python file, workspace, skills, peer/research consult). Nested control-plane tools (create_attack_plan,replan,start_autonomous_campaign, package installers) are hidden from every phase (_HIDDEN,tool_catalog.py:83) so the worker agent cannot clobber the main loop's plan. Unknown phase or empty result → full list fallback. - Schema validation:
validate_tool_call(tool_catalog.py:165) checks required fields, primitive types, and enums locally so a malformed call never wastes a round on MCP dispatch. - Parsing:
_normalize_tool_call(tool_calls.py:29) coerces string-encoded arguments to dicts;_filter_and_validate_tool_calls(tool_calls.py:328) drops empty/malformed calls and returns structuredRECOVERABLE_ERRORmessages fed back to the model (tools/exploit_agent/runner/_impl.py). - Retry correction:
_attempt_retry_correction(tool_calls.py:382) auto-fixes malformed target IPs and shell-command IPs on the first transport failure (tools/exploit_agent/runner/_impl.py). - Blocked-outcome tracking:
_ToolOutcomeTracker(tool_calls.py:89) counts consecutive blocked/unavailable outcomes per canonical call (_canonical_tool_key,tool_calls.py:81) and drives the terminal-constraint tripwire (terminal_constraint_reached,tool_calls.py:134), plus the richer taxonomy (record_compromise/record_cred_dump/record_partial/record_exploit_failure) and theshould_escalate/should_pivotsignals the orchestrator consumes (tool_calls.py:194-230).
Exception-group handling (tools/exceptions.py)
anyio task groups raise BaseExceptionGroup (PEP 654) on MCP subprocess
death — not a subclass of Exception, so bare except Exception silently
swallows the real error. Every MCP-touching catch in this package uses:
_EXC_GROUP_CATCH(tools/exceptions.py:38-41) —(Exception, BaseExceptionGroup)on Python 3.11+,(Exception,)below._is_exception_group(tools/exceptions.py:15) and_log_nested_exceptions(tools/exceptions.py:22) — recursive unpacking of the nested tree.
Required sites: session.call_tool (tools/exploit_agent/runner/_impl.py), the peer-consult loop
(reflection.py:382), the research assistant's consultation
(research_assistant.py:396), and the model retry wrappers
(model_client.py:35, 71). The loop also separates transport failures
(the MCP call itself, tools/exploit_agent/runner/_impl.py) from parse failures (our own
result parsing, tools/exploit_agent/runner/_impl.py) — conflating the two used to mis-report
a parsing bug as a tool failure and send the model down the wrong replanning
path.
Reporting evidence/findings back
- Audit trail: every action →
ExploitRecordinexploit_audit.jsonl(hash-chained), returned in the final result dict (tools/exploit_agent/runner/_impl.py). - Attack memory: durable per-target facts captured per tool result
(
_capture_attack_memory,context.py:294) and re-injected each round as the[ATTACK_MEMORY_V1]user message (_refresh_attack_memory_message,context.py:281). - Context compaction:
_build_compacted_messages(context.py:538) replaces old history with a deterministic summary carryingEVIDENCE AND WORKSPACE REFERENCES(_extract_context_references,context.py:350), failed/observed tool outcomes, and continuation rules. - ULTRATHINK reasoning advisory: the model's
[REASONING]block is parsed (_parse_reasoning_block,context.py:178) and fed back next round as a fenced, advisory-only message (_refresh_reasoning_advisory_message,context.py:224). - Event sink: phase/assistant/tool_request/tool_start/tool_result events
for the WebUI (
_emit,tools/exploit_agent/runner/_impl.py;_EVENT_TYPEattools/exploit_agent/runner/_impl.py). - Final result:
tools/exploit_agent/runner/_impl.py— audit records, outcome summary, plan JSON, session state, research-assistant stats, attack-memory path.
Config keys that affect the agent
| Key | Effect |
|---|---|
exploit.permission | full_access / approve_only / read_only; missing/unknown → read_only (cli_exploit_settings.py:12). |
exploit.attack_mode | Enables the attack workflow + raised budgets. |
exploit.max_rounds, max_commands_per_session, attack_max_rounds, attack_max_commands, attack_max_duration_minutes | Loop budgets (ExploitSettings.effective_*, policy.py:83-88). |
exploit.allowed_targets, require_explicit_allowlist | The target-IP lock (mcp_shared.py:494, 558). |
exploit.attacker_os, shell, msfconsole_path | OS prompt branch + terminal shell. |
exploit.forbidden_actions, disallowed_assets | Enforced in full_access — parsed into the ScopeGate handed to ExploitPolicy (tools/exploit_session.py) and consulted by _enforce_mission_scope (policy.py): a mapped tool category or out-of-scope asset is denied with a SCOPE_DENIED audit row. Swarm critic + Flow B still honor their own lists. |
exploit.adaptive_exploits_enabled / adaptive_exploits.* | Exploit mutator + ExperienceStore wiring (tools/exploit_agent/runner/_impl.py). |
exploit.outcome_judgment_flow_a / outcome_judgment.* | OutcomeJudge bridge (tools/exploit_agent/runner/_impl.py; thresholds at outcome_adapter.py:341-355). |
long_session.* | Canonical context_window_tokens chat kwarg (translated to options.num_ctx by the Ollama adapter), message checkpointing, raised budgets (tools/exploit_agent/runner/_impl.py, context.py:613). |
reasoning.* | chain_of_thought, ultrathink, reflection_every_n_actions, llm_reflection, peer_consult_on_failure_threshold. |
multi_model.* | Peer consultation: enabled, consult_aliases, max_consultations. |
research.assistant.* | Research sidecar settings (research_assistant.py:111). |
skills.* | Startup hints + reselect_* mid-run re-selection. |
memory.* | attack_memory_enabled, attack_memory_max_context_chars, semantic_enabled, experience_*. |
models.registry / models.info.<alias>.context_window | Model aliases + compaction profile overrides (context.py:60-110). |
swarm.parallel_enabled | Parallel sub-agent delegation briefing (prompt.py:368). |
Things that will bite you
-
Async exception groups. anyio task groups raise
BaseExceptionGroup, which is NOT anExceptionsubclass. Any code wrappingsession.call_tool,stdio_client, orClientSession.initialize()must catch_EXC_GROUP_CATCHand unpack with_log_nested_exceptions(tools/exceptions.py:38-41). A bareexcept Exceptionhides the real error and can crash the loop. The research assistant and peer-consult paths are the classic places this regresses (research_assistant.py:396,reflection.py:382). -
Policy is not the safety boundary — the allowlist is.
full_accessauto-approves every action with no command-content/scope/pivot inspection (policy.py:408-415). The one attack-mode safety is the target-IP allowlist lock in the MCP tool layer (mcp_shared._allowed_target_list+terminal._target_lock_block). If you re-add a removed gate, first ensure the allowlist covers the path you are de-restricting. Recon staysread_onlyvia the missing-key fallback in_resolve_exploit_permission(cli_exploit_settings.py:12) — never "upgrade" recon. -
Outcome truth vs. model claim. The model's
EXPLOIT_RESULT: successtext and a bareexit_code == 0are NOT evidence. The authoritative signal isActionResult.verified_successfromnormalize_action_result(outcome_truth.py:339), which requires strong shell/root/SYSTEM markers and only classifies exploit-validation tools. The judge (judge_flow_a) is fed the same tightened classification — it can never confirm on baremeterpreteror a trailing$. Gateaccess_achieved/ findings / post-exploit onverified_success, not on the judge alone. -
The audit chain is shared with unchained MCP rows. The MCP tool layer appends its own records (no
hash/prev_hash) to the sameexploit_audit.jsonl.verify_audit_chainskips them (policy.py:237-246); don't "fix" the verifier to require hashes on every row, and don't trim the in-memory_recordsring buffer expecting the JSONL to shrink — the JSONL is the authoritative store. -
Two flows share the workspace.
mcp_exploit_server.pyandtools/exploit_agent/both write intoexploit_workspace/. Lab build: path-traversal protection was removed — the operator box is unrestricted; only the target-IP allowlist lock remains (CLAUDE.md:356). -
_sync_patchable_symbolsis load-bearing for tests. Historical tests patch names liketools.exploit_agent._stream_model; the package root refreshes the split modules' globals before each run (__init__.py:114-144). If you move a function between modules, update the patchables map or tests will silently patch the wrong module. -
Reflection and peer advisories are user-role messages. They carry higher instruction-trust weight than tool-role messages, so every field is sanitized (
_sanitize_reflection_field,reflection.py:82) and the messages are fenced as advisory. Never inject raw tool content into a reflection prompt — use the structured summaries (reflection.py:139-154).