Verified-only kill-chain state machine (opt-in, default OFF). The LLM proposes; the machine verifies: a transition commits only after the edge's verify check specs pass through the shared evaluator. There is deliberately no unverified-transition code path — killchain.require_verification toggles reporting verbosity only.
str-based so values serialize cleanly to JSON. parse is case/alias-insensitive (root → shell_as_root, domain_admin → da, …) and raises ValueError when unknown. Applicability is edge-driven: states appearing in EDGES are the ones a target can move between.
Each edge is one verified transition: edge_id, from_state/to_state, playbook (ordered MCP tool calls), verify (check specs in the shared evaluator vocabulary), evidence_type. Playbook args may carry {target_ip}/{user}/{password} placeholders resolved from the transition context (only str values are formatted, so context cannot inject structure).
Edge
From → To
Playbook → Verify
tcp_reachable
discovered → reachable
nmap port probe → http_request port check
service_confirmed
reachable → service_access
get_service_fingerprint → banner probe
cred_harvest
service_access → creds_in_hand
login POST → http_login
cred_ssh_login
creds_in_hand → shell_as_user
sshpass id → shell_commanduid=
cred_smb_login
creds_in_hand → service_access
lateral_exec whoami → session probe
cred_http_login
creds_in_hand → service_access
(no playbook) → http_login
msf_validated_exploit
service_access → shell_as_user
run_msf_module → session probe
file_upload_webshell
service_access → shell_as_user
upload POST → webshell URL probe
privesc_sudo_to_root
shell_as_user → shell_as_root
sudo -n id → uid=0( probe
domain_login_validate
creds_in_hand → domain_creds
lateral_exec → NetExec [+] logon probe
kerberoast_to_da
domain_creds → da
kerberoast → DCSync ::: hash-line probe
Symbol
Kind
Description
STUB_EDGES
frozenset
Empty — every registered edge ships a working verify story; excluded from BFS
get_edge(edge_id)
def
Registry lookup or None
edges_from(state)
def
Non-stub edges from a state ([] on unparseable state)
all_edges(*, include_stubs=False)
def
Full registry
resolve_placeholders(value, context)
def
Safe {placeholder} formatting; missing keys left as-is so the failure is visible
One playbook step through the MCP tool layer (tool_executor preferred, else live session.call_tool) — allowlist + audit apply unchanged
_executor()
def
Check executor: injected or lazily built default_check_executor
_resolve_edge(from, to, edge_id)
def
Edge resolution with (edge, error) tuple; unknown states/edges are blocked results, not exceptions
attempt_transition(...)
async
Propose → run playbook → verify_flag_check every spec → commit only if all pass; returns {success, edge_id, from_state, to_state, steps, checks, error/evidence_ref}
_commit(target, edge, ...)
def
Merges attack_state onto the host node (CONFIRMED), writes an evidence node + OBSERVED_ON edge, returns ev:killchain:<edge>:<ts>
status(target)
def
{target, state, applicable_edges, goal_state, path_to_goal} (default state discovered)
plan(target, goal_state="")
def
BFS shortest edge-id path over non-stub edges (BloodHound shortest-path, generic)
_log_decision(...)
def
verified_transition / verification_failed decision-log events; never breaks the loop
goal_state defaults to shell_as_root (DEFAULT_GOAL_STATE); verbose_reporting follows require_verification. MCP-death BaseExceptionGroup is caught via _EXC_GROUP_CATCH on both playbook and verify paths.
Registered only when killchain.enabled is true (default false). The machine is wired with an in-process tool executor (same decorated functions the agent calls, so every playbook step re-applies the allowlist + audit) and AttackGraphStore at <workspace>/killchain_graph.db (or killchain.graph_db).
Tool
Signature
Notes
killchain_status
(target)
Read-only: state, applicable edges, path to goal. Audit-only, no target touch