Exploit Policy
Permission modes, the approve_action gate, full-access mission-scope
enforcement, and the tamper-evident audit chain for the Flow A exploit loop.
Canonical module: tools/exploit_agent/policy.py.
This page is the policy deep-dive. For the loop lifecycle, prompts, and outcome pipeline see exploit-agent.md; for the layered safety model see safety-model.md.
Permission modes
ExploitPermission (tools/exploit_agent/policy.py) has three members:
| Mode | Value | Behavior |
|---|---|---|
FULL_ACCESS | full_access | Auto-approves after the mission-scope check. No command-content inspection. |
APPROVE_ONLY | approve_only | Every action needs an operator ALLOW <host>. Every non-approved exit writes a denied audit row. |
READ_ONLY | read_only | Propose-only. Records the action as proposed and returns False without prompting or executing. |
Resolution notes:
ExploitSettings.permissiondefaults toAPPROVE_ONLYat the dataclass level, but every CLI entry path resolves the effective permission from config first via_resolve_exploit_permission(tools/cli_exploit_settings.py): a missing or unknownexploit.permissionkey falls back toREAD_ONLY, so a partial config never silently becomes live.- Recon is always
READ_ONLYregardless of config (tools/cli_exploit_settings.py). ExploitSettings.preapprovedisTruewhen permission isFULL_ACCESSorattack_modeis on. Anattack_mode-without-FULL_ACCESSrun records aSECURITY_EVENTwarning row (status="warning") but continues through the normal gate.- Budgets:
effective_max_commandshonorsagent_max_actionswhen positive, elseattack_max_commands(attack mode) /max_commands_per_session;effective_max_roundspicksattack_max_rounds/max_roundsthe same way.
The approve_action gate
async def approve_action(self, action: str, command: str, detail: str = "") -> bool:
action is the concrete MCP tool name, command is the analysis payload for
that call, detail is a short human-readable description. In order:
- Attack-mode mismatch warning. If
attack_modeis on withoutFULL_ACCESS, record aSECURITY_EVENTwarning row first. - Budget check. If
can_proceedisFalse(_command_count >= effective_max_commands), record adeniedrow with detailcommand budget exhaustedand returnFalse. READ_ONLYshort-circuit. Record the action withapproved=False,status="proposed"and returnFalse. No prompt, no execution.FULL_ACCESSpath. Call_enforce_mission_scope(action, command)(next section). If it returnsFalse, aSCOPE_DENIEDrow is already recorded — returnFalse. Otherwise increment_command_countand returnTrue. Command content is not inspected here.APPROVE_ONLYpath. Setneeds_human, release the approval lock, then either delegate to the asyncapproval_provider.approve(action, command, detail, host)when one is configured, or render theEXPLOIT ACTION REQUIRES APPROVALbanner and read one line viaprompt_functhroughasyncio.to_thread. Approval requires the exact answerALLOW <host>.EOFError/KeyboardInterruptrecords adeniedrow (approval prompt aborted (EOF/interrupt)). An operatornorecordsoperator denied action in approve_only mode. An approval re-acquires the lock and re-checks the budget before incrementing_command_count.
Concurrency: approval serializes on self._lock; audit appends serialize on
a separate self._audit_lock because record() is called from inside
approve_action's lock block and asyncio.Lock is not reentrant.
Full-access mission-scope enforcement
On the FULL_ACCESS path the policy consults the mission ScopeGate
threaded in via scope_gate= (built by _build_exploit_scope_gate in
tools/exploit_session.py from exploit.forbidden_actions /
exploit.disallowed_assets plus allowed_assets=[target_ip]; attack mode
uses risk profile high_authorized_testing, otherwise
standard_authorized). load_from_db() is intentionally skipped there — the
standalone exploit path has no mission DB row.
Tool-to-category map
action_type = _TOOL_ACTION_CATEGORY.get(action, "authorized_test")
| Tool | Category |
|---|---|
dump_credentials, kerberoast | credential_dumping |
lateral_exec | lateral_movement |
generate_payload, msf_generate_payload, run_msf_module, msf_run_exploit, msf_run_auxiliary | payload_generation |
run_exploit_terminal, write_python_file, run_python_file, msfconsole_command, msf_interact_session, msf_run_post_module, msf_run_resource_script | exploit_execution |
| anything else | authorized_test (neutral fallback) |
A tool whose category is listed in the gate's forbidden_actions is denied.
Constraint (pinned by tests/test_exploit_action_category.py): category
names must never collide with scope_gate._HARD_FORBIDDEN_SUBSTRINGS, or the
mapped tool would be auto-blocked even in full_access.
Asset derivation
_mission_scope_assets(command) builds the deterministic asset list the gate
must vet, using the sanctioned extractors — no hand-rolled regex:
- Candidates come from
tools/command_analyzer._extract_destinations(URL authorities,/dev/tcphosts,LHOST/RHOST, socket-connect / HTTP-connection hosts,PeerAddr, bare-host args after network verbs) plustools/validation_utils.extract_ips_from_command(bare dotted-IPv4 backstop). _preauthorized_assetskips loopback/unspecified addresses (localhost,::1,0.0.0.0,*), the immutable_locked_ip, and_allowed_targetsmembers (locked target plus operator-authorized callback/C2 hosts), so reverse shells to the operator listener keep working.- Non-IP tokens must pass
tools/validation_utils.is_fqdn; extractor noise is dropped rather than denied-on-junk. Hostname vetting additionally requires_gate_authorizes_host_assets(the gate'slist_scope()["allow"]contains a non-IP pattern); with IP/CIDR-only allow rules, hostname authorization is delegated to the MCP-layer allowlist lock. - Fallback: when nothing destination-shaped extracts (e.g.
check_os), the locked target itself is vetted so the gate can still forbid the category or disallow the target outright.
Gate consult
result = gate.check_scope(
asset,
action_type,
tool_name=action,
risk_level="high",
enforce_rate_limit=self.settings.enforce_rate_limit,
)
Fail-closed rules in _enforce_mission_scope:
scope_gate=Nonedenies with aSCOPE_DENIEDrow (no mission gate: fail closed) — the gate is required onfull_access. (Implementation note: the swarm path historically ran without a gate; treat anyscope_gate=Nonefull-access run as denied per the current code.)allowed=Falsedenies with aSCOPE_DENIEDrow carrying the gate's reason.allowed=Truewithrequires_human_approval=Truedenies too, with arequires_human_approval: <reason>detail — except under thehigh_authorized_testingrisk profile, which still auto-approves. This is the documented lab posture, not a blanket deny.allowed=Truewithoutrequires_human_approvalproceeds to the next asset. All assets must pass.
Denial rows use approved_by="scope_gate"; human/budget denials use
approved_by="operator" (or None for budget exhaustion).
What the policy does not do
- No command-content inspection on the attack path. Destructive
commands, egress, reverse shells, credential dumping, Metasploit, and
Python write/run are all allowed against authorized targets in
full_access. The former_check_command_safety/_gate_pivot_and_countgates stay removed. - The target-IP lock lives at the MCP tool layer, not in the policy.
tools/mcp_shared._allowed_target_listunionsEXPLOIT_TARGETwithexploit.allowed_targets(plus resolved/discovered domain targets), and@require_allowlistplustools/mcp_tools/terminal._target_lock_blockrefuse off-target destinations. tool_catalog.pycontrols visibility, not authorization.select_tools_for_phasenarrows the per-round tool schemas viaPHASE_TOOL_FAMILIES(plus the always-visible_UNIVERSALset, minus the_HIDDENnested control-plane tools), andvalidate_tool_callrejects malformed calls locally against their MCP schema. Neither function approves anything — every narrowed, validated call still passes throughapprove_actionand the MCP-layer allowlist.
Hash-chain audit
Every action is recorded via ExploitPolicy.record() to
exploit_audit.jsonl inside the run workspace as an ExploitRecord
(timestamp, target_ip, action, approved, status, command,
detail, attempt_id, exit_code, code_sha256, duration_seconds,
approved_by, source_ip, session_id, full_args,
scope_check_result, prev_hash, hash):
{
"action": "run_exploit_terminal",
"approved": false,
"status": "SCOPE_DENIED",
"approved_by": "scope_gate",
"prev_hash": "9f2c…",
"hash": "41bd…"
}
Chain mechanics:
_record_chain_hashis the SHA-256 of the record's canonical JSON (json.dumpswithsort_keys=True) excluding thehashfield. Record N+1'sprev_hashmust equal record N'shash.record()links under_audit_lockso concurrent writers form a strict chain, appends to the on-disk JSONL, and advances_last_hash._load_last_hashseeds the tail from the last chained row of an existing log so the chain continues across runs.verify_audit_chain(path)recomputes the chain end-to-end at policy construction. A broken chain raisesRuntimeError— fail closed — rather than appending to a compromised log.- The on-disk JSONL is authoritative. The in-memory
_recordslist is a bounded ring buffer (MAX_INMEMORY_AUDIT_RECORDS = 500); the final-run report reconstructs the full set viaread_audit_records(). - The MCP tool layer appends its own unchained rows (no
hash/prev_hash) to the same file; the verifier skips them.
Implementation note: the exact MCP-layer writer module was not re-verified
in this pass — the policy docstring names tools.mcp_shared helpers, but
the current tools/mcp_shared.py exposes only config, limiter, search, and
HTTP-hardening helpers. The skip-unchained-rows behavior in
verify_audit_chain is as coded regardless of which module writes those
rows.
Common status values observed in code: proposed (READ_ONLY),
denied (operator/prompt/budget refusals), SCOPE_DENIED (mission-scope
refusals), warning (attack-mode mismatch SECURITY_EVENT), unknown
(the record() default).
Relation to scope_gate and risk_controller
| Control | Module | Flow A exploit path? | Answers |
|---|---|---|---|
ScopeGate.check_scope | scope_gate.py | Yes — consulted on full_access via _enforce_mission_scope | Whether an action may touch an asset (allow/deny rules, forbidden categories, third-party detection, rate limit, risk gating). |
RiskController.assess_action | legacy/risk_controller.py | No — Flow B only | How an allowed target may be touched (destructive-pattern blocking, exploit/pivot/credential gates, budgets, high-risk human approval). |
ExploitPolicy.approve_action | tools/exploit_agent/policy.py | Yes — the Flow A gate | Permission mode, budget, mission-scope consult, operator prompt. |
MCP @require_allowlist + _target_lock_block | tools/mcp_tools/ | Yes — independent gate | Destination allowlist (the target-IP lock). |
Details:
ScopeGate.check_scope(asset, action_type, tool_name="", risk_level="low", enforce_rate_limit=True)returns aScopeCheckResult(allowed,reason,matched_scope_rule,risk_level,requires_human_approval,is_third_party,rate_limit_remaining,retry_after_seconds). Checks run in order: forbidden-action exact match, hard-forbidden substrings (denial_of_service,destructive_exploit,social_engineering,physical_attack,malware,credential_theft,brute_force,dos,overload,crash,saturate), third-party detection, deny rules, allow rules, token-bucket rate limit, then risk gating (risk_level="high"under a non-high_authorized_testingprofile setsrequires_human_approval). The policy always calls it withrisk_level="high".RiskController(canonical:legacy/risk_controller.py; the rootrisk_controller.pyis aDeprecationWarningshim) is not consulted byExploitPolicy. Its budget/destructive/high-risk gates belong to the frozen Flow B loop. Do not edit it for the attack path.- Additional enforcement that exists outside the policy: the swarm critic
agent checks the swarm mission's
forbidden_actions(tools/swarm/agents/critic_agent.py), and Flow B'sScopeGateenforces its own lists on the orchestrator's no-MCP Path B.
Config keys that feed this page:
exploit:
permission: full_access # full_access | approve_only | read_only; missing/unknown -> read_only
attack_mode: true
forbidden_actions: [] # ScopeGate categories via _TOOL_ACTION_CATEGORY + swarm critic
disallowed_assets: [] # ScopeGate deny rules on the full_access path
allowed_targets: [] # operator-authorized callback/C2 hosts (+ target-IP lock union)
require_explicit_allowlist: true
enforce_rate_limit: true # forwarded to ScopeGate.check_scope
max_commands_per_session: 50
attack_max_commands: 150
Related documentation
- Exploit agent (Flow A) — loop lifecycle, permission-model summary, tool catalog, outcome pipeline.
- Safety model — layered controls, target-IP lock, MCP safety boundary, development rules.
Source map
tools/exploit_agent/policy.py—ExploitPermission,ExploitSettings,_TOOL_ACTION_CATEGORY,ExploitPolicy,ExploitRecord, hash-chain helpers.tools/exploit_agent/tool_catalog.py—select_tools_for_phase,validate_tool_call,PHASE_TOOL_FAMILIES(visibility only, not authorization).tools/command_analyzer.py—_extract_destinationsdestination extractors.tools/validation_utils.py—extract_ips_from_command,is_fqdn,is_target_in_allowlist.tools/exploit_session.py—_build_exploit_scope_gate,run_exploit_sessionwiring.tools/cli_exploit_settings.py—_resolve_exploit_permission,build_cli_exploit_settings.scope_gate.py—ScopeGate,ScopeCheckResult,check_scope.legacy/risk_controller.py—RiskController,RiskAssessment,assess_action(Flow B only).tools/swarm/agents/critic_agent.py— swarmforbidden_actionscheck.tests/test_exploit_action_category.py— pins the category-name collision constraint.