Exploit Agent — Runner (tools/exploit_agent/runner/)
Round loop, outcome pipeline, advisory hooks, and context assembly for the
single-target loop. Canonical implementation:
tools/exploit_agent/runner/_impl.py (~2.6K lines); runner/loop.py is a
plain from ._impl import ... re-export, and runner/context.py /
runner/phase.py are re-export shims over the package-level context.py /
phase_tracker.py. (Implementation note: the task brief named
runner/tool_catalog.py, but the file lives at
tools/exploit_agent/tool_catalog.py — there is no runner/tool_catalog.py.)
Entry point run_exploit_agent (_impl.py:391); companionship doc
loop.md covers init sequence, phase tracking, and budgets in the same file.
Architecture
run_exploit_agent (_impl.py:391)
├─ init: target lock → attacker OS → context profile → stores/mutator →
│ planner/session → memory → briefings → system prompt → seed messages
├─ per round: refresh advisories → budgets → compaction → model call →
│ validate → dispatch tools → outcome pipeline → hooks → phase/summary
└─ return final_result dict (target_ip, total_actions, audit_path, workspace,
records, messages, outcome_summary, plan, session_state, ...)
| Module | Symbol | Kind | Line | Role |
|---|---|---|---|---|
runner/_impl.py | run_exploit_agent | async def | 391 | Round loop entry; all args keyword-only |
runner/_impl.py | CheckpointContext | dataclass | 188 | Evidence snapshot for the operator hook |
runner/_impl.py | CheckpointOutcome | dataclass | 207 | Operator decision (continue/change_goal/privesc/finish/cancel) |
runner/_impl.py | CheckpointHook | Protocol | 228 | async (ctx) -> CheckpointOutcome | None |
runner/_impl.py | _InMemoryExperienceStore | class | 286 | No-DB fallback for the adaptive loop |
runner/_impl.py | _resolve_attacker_os | def | 238 | auto → Windows/Darwin/Linux |
runner/_impl.py | _resolve_allowed_targets | def | 342 | Target + exploit.allowed_targets union |
runner/_impl.py | _load_attack_memory_settings | def | 372 | (enabled, max_chars) from memory.* |
runner/_impl.py | _emit / _EVENT_TYPE | def/const | 76/67 | Best-effort WebUI event emit |
runner/_impl.py | _build_killchain_machine | def | 95 | Opt-in kill-chain machine or None |
runner/_impl.py | _should_snapshot_for_action | def | 139 | Fail-open snapshot gate |
runner/_impl.py | _build_snapshot_manager | def | 154 | Fail-open SnapshotManager |
runner/_impl.py | _counterfactual_enabled | def | 166 | replay_simulator.counterfactual toggle |
runner/loop.py | — | re-export | 1 | from ._impl import ... (wheel-importable) |
runner/context.py | — | re-export | 1 | tools.exploit_agent.context surface |
runner/phase.py | — | re-export | 1 | _PhaseTracker surface |
tool_catalog.py | select_tools_for_phase | def | 207 | Phase-narrowed tool schemas |
tool_catalog.py | validate_tool_call | def | 267 | Pre-dispatch schema check |
tool_catalog.py | PHASE_TOOL_FAMILIES | dict | 198 | Phase → tool-name families |
async def run_exploit_agent(
*,
client: object,
model: str,
session: object,
exploit_tools: list[dict[str, object]],
policy: ExploitPolicy,
target_ip: str,
target_cve: str = "",
target_os: str | None = None,
known_cves: list[str] | None = None,
service_context: str = "",
reports_dir: Path = Path("reports"),
experience_store: object | None = None,
semantic_memory: object | None = None,
scope_gate: "ScopeGate | None" = None,
skill_registry: SkillRegistry | None = None,
skills_cfg: dict[str, object] | None = None,
skill_embedder: object | None = None,
config: dict[str, object] | None = None,
heartbeat: object = None,
original_target: str | None = None,
resolved_ip: str | None = None,
event_sink: object = None,
checkpoint_hook: CheckpointHook | None = None,
clock: Callable[[], float] | None = None,
sleep: Any | None = None,
) -> dict[str, object]:
Round loop lifecycle (_impl.py:1084)
for _round in range(max_rounds) (max_rounds = policy.settings.effective_max_rounds):
- Heartbeat update + round header (
ui.round_header, skipped round 0). - Refresh advisory messages:
_refresh_attack_memory_message,_refresh_reasoning_advisory_message(single in-flight each). - Budget gates: time (
_budget_exceeded) andpolicy.can_proceed— either streams a final summary via_stream_modeland breaks. - Compaction:
_should_compact_context→_build_compacted_messages+_checkpoint_messages(long-session resume). response = await _call_model_with_retry(client, model, messages, _round_tools(), ...)where_round_tools()narrows schemas to the current phase (_impl.py:1065).tool_calls, invalid = _filter_and_validate_tool_calls(raw, all_tools); malformed calls get aRECOVERABLE_ERRORtool message (+ replan prompt); non-recoverable ones count toward the terminal-constraint limit.- No-tool turn: phase-minima enforcement (
can_terminate, finish-early push-back), goal-complete break on verified compromise/cred-dump,no_pathcheckpoint hook, else break. - Per tool call: availability check → research-assistant branch → repeated
policy.approve_action→ optional pre-destructive snapshot →heartbeat+tool_startemit →session.call_tool(one auto-retry via_attempt_retry_correction) → parse content blocks → blocked/success handling → outcome pipeline →policy.record+session_mgr.record_action→tool_resultemit (secret-masked) → hooks → banner parsing/phase/plan.
# Example: minimal standalone invocation (all args keyword-only)
result = await run_exploit_agent(
client=model_client,
model="glm",
session=mcp_session,
exploit_tools=tool_schemas,
policy=policy,
target_ip="10.0.0.50",
)
assert result["total_actions"] >= 0
Outcome pipeline: normalize → classify → judge
One ActionResult per tool result, threaded through every downstream consumer
(loop, swarm, report, eval). Authority flows strictly downward: the tracker
reads the normalized verdict, never the judge alone.
| Stage | Symbol | Location | Contract |
|---|---|---|---|
| Normalize | normalize_action_result(*, tool_name, result_text, mcp_result=None, exit_code_hint=None) | outcome_truth.py:347 | ActionResult: operational_status (completed/failed/blocked/retryable/execution_unknown) × exploit_outcome (compromise/cred_dump/partial/failure/unknown/none); exit code defaults to None, never fabricated 0 |
| Classify | classify_exploit_outcome(result_text) | outcome_truth.py:195 | Tightened markers: ^COMPROMISE:, meterpreter session N, uid=0(, NT AUTHORITY\SYSTEM, root@host:# + command; creds require colon/dumped/hash-hex. Legacy loose classify_exploit_result (outcome_classify.py:96) kept for the adapter fallback only |
| Judge | judge_flow_a(*, config, policy, result_text, tool_name, detail, exit_code, target_ip, plan, action_result=None) | outcome_adapter.py:352 | Builds/caches OutcomeJudge on policy._flow_a_judge, synthesizes the observation via build_observation (outcome_adapter.py:72), returns (HypothesisStatus, confidence, classification) or None |
In-loop wiring (_impl.py:1844-2057):
_action_result = _normalize_result(tool_name=name, result_text=result_text, mcp_result=result)
exit_code = _action_result.exit_code # None preserved; never fabricated as 0
success = (_action_result.operational_status == _OpStatus.COMPLETED
and not _action_result.is_error
and exit_code in (0, None)
and _action_result.exploit_outcome != _ExploitOutcome.FAILURE)
if _action_result.is_compromise:
outcome_tracker.record_compromise(shell_type=..., privilege_level=...)
elif _action_result.is_cred_dump:
outcome_tracker.record_cred_dump()
_flowa = await _judge_flow_a(..., action_result=_action_result)
_ToolOutcomeTracker (tool_calls.py:95): threshold=3 consecutive
blocked/unavailable → terminal_constraint_reached; record_compromise /
record_cred_dump / record_partial monotonic counters;
record_exploit_success / record_exploit_failure drive the peer-consult
trigger; should_escalate / should_pivot signals for the orchestrator.
Only _EXPLOIT_VALIDATION_TOOLS (outcome_truth.py:67) can yield a
compromise — recon/install/workspace output classifies as none.
Tool catalog (tool_catalog.py)
_round_tools() intersects the full schema list with the current phase family
(_impl.py:1065-1070), always keeping _UNIVERSAL (shell, workspace,
skills, advisory consults, state/capability tools) and hiding _HIDDEN
(nested control-plane + package/clone noise). Unknown phase or empty
selection falls back to the full list.
def select_tools_for_phase(
all_tools: list[dict[str, Any]],
phase: str,
*,
available_mcp_names: set[str] | None = None,
) -> list[dict[str, Any]]: ...
def validate_tool_call(
name: str,
args: dict[str, Any],
all_tools: list[dict[str, Any]],
) -> str | None: ... # None = valid; else short error fed back to the model
| Phase | Family (PHASE_TOOL_FAMILIES, :198) |
|---|---|
recon | _UNIVERSAL + recon/domain + browser-read |
service_enumeration | _UNIVERSAL + recon + exploit + browser read/mutate |
vulnerability_research | _UNIVERSAL + CVE/research + browser-read |
validation | _UNIVERSAL + exploit + browser read/mutate |
reporting | _UNIVERSAL + research + browser-read |
Reflection / peer-consult / reselect hooks
All three are advisory-only user-role injections — they never touch permission, scope, workspace, or audit.
| Hook | Symbol | Location | Trigger |
|---|---|---|---|
| LLM reflection | _llm_reflect_inline(client, model, messages, plan, action_count, *, semantic_memory, policy, target_ip, experience_store=None, verdict_signal=None) | reflection.py:91 | Every reflection_every_n_actions (ultrathink tightens to ultrathink_reflection_interval); gated by agent.reflection_enabled; heuristic _generate_reflection fallback; writes one reflection:exploit_loop semantic lesson + optional reflection:verdict Bayesian row on terminal verdicts |
| Peer consult | _consult_peers_inline(config, question, context, *, policy, target_ip, action_count) | reflection.py:280 | outcome_tracker.should_consult_peers(peer_consult_on_failure_threshold) + multi_model_enabled; in-process (tools=None), shares the max_consultations budget with the consult_peer_models MCP tool |
| Skill reselect | _maybe_reselect_skills(*, policy, state, action_count, new_cves, registry, skills_cfg, messages, recent_tool="", experience_store=None) | skills.py:44 | New services/CVEs from banner parsing; rate-guarded by reselect_max_per_run (default 3) + reselect_min_interval_actions (default 5); _SkillReselectState (skills.py:21) dedupes repeats |
Checkpoints (CheckpointContext/CheckpointOutcome/CheckpointHook,
_impl.py:188-232): access fires right after the authoritative classifier
confirms compromise/cred-dump (decision deferred until after audit +
bookkeeping); no_path fires at the safe natural-termination boundary
(minima met, no foothold). Both guarded against decision loops via
_last_access_action / _last_no_path_action. finish/cancel outcomes
set the break path; cancel sets final_result["cancelled_by_operator"].
Context assembly (context.py)
| Symbol | Line | Role |
|---|---|---|
_build_context_profile(model) | 80 | Per-alias window/compact-at/keep-full/output-cap (glm 976K→340K … default 128K→65%); models.info.<alias>.context_window override |
_estimate_context_tokens(messages) | 152 | tiktoken cl100k when installed, else len//3; +4/msg overhead |
_should_compact_context(policy, round_index, current_tokens) | 614 | Attack-mode only; current >= compact_at + min-gap (context_summarize_every, default 10 — Implementation note: policy.py:53 default is 10, loop doc cites 5; trust policy.py) |
_build_compacted_messages(*, messages, system_prompt, plan, policy, ...) | 556 | System + fresh memory message + one deterministic summary + last keep_full verbatim turns |
_refresh_attack_memory_message(messages, policy) | 300 | Single durable-memory user message after system prompt |
_refresh_reasoning_advisory_message(messages, recent, ultrathink) | 243 | Single ULTRATHINK [REASONING] advisory (last 3, ≤400 chars each) |
_parse_reasoning_block(content) | 197 | Pure string parse, sanitized one-liner or None |
_checkpoint_messages(session_mgr, messages) | 622 | Persist when persist_messages (long-session crash resume) |
sanitize_output(text, max_length=12000) | 645 | Strip ANSI/control chars; cp1252 round-trip on Windows only |
Config keys
| Key | Effect |
|---|---|
exploit.attacker_os | auto/Windows/Linux/Darwin system-prompt branch |
exploit.allowed_targets | Union with locked target (_resolve_allowed_targets) |
memory.attack_memory_enabled / attack_memory_max_context_chars | Attack-memory store (default on / 6000) |
memory.semantic_enabled / experience_min_samples / experience_time_decay_days | Semantic lessons + Bayesian gating (defaults 3 / 90.0) |
long_session.enabled / persist_messages / request_timeout_seconds | num_ctx threading + checkpointing + timeout |
agent.decision_log_enabled | Per-action decision_log.jsonl (default true) |
agent.reflection_enabled | In-loop reflection hook (default true) |
agent.capability_discovery_enabled / state_tools_enabled / task_graph_enabled / planner_hints_enabled | Capability-guidance briefing blocks |
outcome_judgment.flow_a + outcome_judgment.* | judge_flow_a + OutcomeJudge thresholds |
multi_model.enabled / max_consultations | Peer-consult availability + budget |
skills.reselect_mid_run / reselect_max_per_run / reselect_min_interval_actions / reselect_sticky_defaults | Mid-run skill updates |
reasoning.llm_reflection / peer_consult_on_failure_threshold / ultrathink* | Reflection depth + consult trigger |
research.assistant.* | ResearchAssistantSettings.from_config |
killchain.enabled | Kill-chain machine + briefing (default off) |
snapshots.enabled + replay_simulator.counterfactual | Pre-destructive snapshot + revert-and-retry rows |
Related documentation
- Exploit Agent overview
- Exploit Agent policy
- Exploit Agent context
- Exploit Agent loop
- Exploit Agent prompts
- Swarm overview
- Kernel overview
- Run service
Source map
tools/exploit_agent/runner/_impl.pytools/exploit_agent/runner/loop.pytools/exploit_agent/runner/context.pytools/exploit_agent/runner/phase.pytools/exploit_agent/runner/__init__.pytools/exploit_agent/tool_catalog.pytools/exploit_agent/context.pytools/exploit_agent/outcome_truth.pytools/exploit_agent/outcome_adapter.pytools/exploit_agent/outcome_classify.pytools/exploit_agent/reflection.pytools/exploit_agent/skills.pytools/exploit_agent/phase_tracker.pytools/exploit_agent/tool_calls.py