Skip to content
BreachPilot

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:

ModeValueBehavior
FULL_ACCESSfull_accessAuto-approves after the mission-scope check. No command-content inspection.
APPROVE_ONLYapprove_onlyEvery action needs an operator ALLOW <host>. Every non-approved exit writes a denied audit row.
READ_ONLYread_onlyPropose-only. Records the action as proposed and returns False without prompting or executing.

Resolution notes:

  • ExploitSettings.permission defaults to APPROVE_ONLY at 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 unknown exploit.permission key falls back to READ_ONLY, so a partial config never silently becomes live.
  • Recon is always READ_ONLY regardless of config (tools/cli_exploit_settings.py).
  • ExploitSettings.preapproved is True when permission is FULL_ACCESS or attack_mode is on. An attack_mode-without-FULL_ACCESS run records a SECURITY_EVENT warning row (status="warning") but continues through the normal gate.
  • Budgets: effective_max_commands honors agent_max_actions when positive, else attack_max_commands (attack mode) / max_commands_per_session; effective_max_rounds picks attack_max_rounds / max_rounds the 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:

  1. Attack-mode mismatch warning. If attack_mode is on without FULL_ACCESS, record a SECURITY_EVENT warning row first.
  2. Budget check. If can_proceed is False (_command_count >= effective_max_commands), record a denied row with detail command budget exhausted and return False.
  3. READ_ONLY short-circuit. Record the action with approved=False, status="proposed" and return False. No prompt, no execution.
  4. FULL_ACCESS path. Call _enforce_mission_scope(action, command) (next section). If it returns False, a SCOPE_DENIED row is already recorded — return False. Otherwise increment _command_count and return True. Command content is not inspected here.
  5. APPROVE_ONLY path. Set needs_human, release the approval lock, then either delegate to the async approval_provider.approve(action, command, detail, host) when one is configured, or render the EXPLOIT ACTION REQUIRES APPROVAL banner and read one line via prompt_func through asyncio.to_thread. Approval requires the exact answer ALLOW <host>. EOFError/KeyboardInterrupt records a denied row (approval prompt aborted (EOF/interrupt)). An operator no records operator 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")
ToolCategory
dump_credentials, kerberoastcredential_dumping
lateral_execlateral_movement
generate_payload, msf_generate_payload, run_msf_module, msf_run_exploit, msf_run_auxiliarypayload_generation
run_exploit_terminal, write_python_file, run_python_file, msfconsole_command, msf_interact_session, msf_run_post_module, msf_run_resource_scriptexploit_execution
anything elseauthorized_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/tcp hosts, LHOST/RHOST, socket-connect / HTTP-connection hosts, PeerAddr, bare-host args after network verbs) plus tools/validation_utils.extract_ips_from_command (bare dotted-IPv4 backstop).
  • _preauthorized_asset skips loopback/unspecified addresses (localhost, ::1, 0.0.0.0, *), the immutable _locked_ip, and _allowed_targets members (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's list_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=None denies with a SCOPE_DENIED row (no mission gate: fail closed) — the gate is required on full_access. (Implementation note: the swarm path historically ran without a gate; treat any scope_gate=None full-access run as denied per the current code.)
  • allowed=False denies with a SCOPE_DENIED row carrying the gate's reason.
  • allowed=True with requires_human_approval=True denies too, with a requires_human_approval: <reason> detail — except under the high_authorized_testing risk profile, which still auto-approves. This is the documented lab posture, not a blanket deny.
  • allowed=True without requires_human_approval proceeds 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_count gates stay removed.
  • The target-IP lock lives at the MCP tool layer, not in the policy. tools/mcp_shared._allowed_target_list unions EXPLOIT_TARGET with exploit.allowed_targets (plus resolved/discovered domain targets), and @require_allowlist plus tools/mcp_tools/terminal._target_lock_block refuse off-target destinations.
  • tool_catalog.py controls visibility, not authorization. select_tools_for_phase narrows the per-round tool schemas via PHASE_TOOL_FAMILIES (plus the always-visible _UNIVERSAL set, minus the _HIDDEN nested control-plane tools), and validate_tool_call rejects malformed calls locally against their MCP schema. Neither function approves anything — every narrowed, validated call still passes through approve_action and 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_hash is the SHA-256 of the record's canonical JSON (json.dumps with sort_keys=True) excluding the hash field. Record N+1's prev_hash must equal record N's hash.
  • record() links under _audit_lock so concurrent writers form a strict chain, appends to the on-disk JSONL, and advances _last_hash.
  • _load_last_hash seeds 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 raises RuntimeError — fail closed — rather than appending to a compromised log.
  • The on-disk JSONL is authoritative. The in-memory _records list is a bounded ring buffer (MAX_INMEMORY_AUDIT_RECORDS = 500); the final-run report reconstructs the full set via read_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

ControlModuleFlow A exploit path?Answers
ScopeGate.check_scopescope_gate.pyYes — consulted on full_access via _enforce_mission_scopeWhether an action may touch an asset (allow/deny rules, forbidden categories, third-party detection, rate limit, risk gating).
RiskController.assess_actionlegacy/risk_controller.pyNo — Flow B onlyHow an allowed target may be touched (destructive-pattern blocking, exploit/pivot/credential gates, budgets, high-risk human approval).
ExploitPolicy.approve_actiontools/exploit_agent/policy.pyYes — the Flow A gatePermission mode, budget, mission-scope consult, operator prompt.
MCP @require_allowlist + _target_lock_blocktools/mcp_tools/Yes — independent gateDestination allowlist (the target-IP lock).

Details:

  • ScopeGate.check_scope(asset, action_type, tool_name="", risk_level="low", enforce_rate_limit=True) returns a ScopeCheckResult (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_testing profile sets requires_human_approval). The policy always calls it with risk_level="high".
  • RiskController (canonical: legacy/risk_controller.py; the root risk_controller.py is a DeprecationWarning shim) is not consulted by ExploitPolicy. 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's ScopeGate enforces 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
  • 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.pyExploitPermission, ExploitSettings, _TOOL_ACTION_CATEGORY, ExploitPolicy, ExploitRecord, hash-chain helpers.
  • tools/exploit_agent/tool_catalog.pyselect_tools_for_phase, validate_tool_call, PHASE_TOOL_FAMILIES (visibility only, not authorization).
  • tools/command_analyzer.py_extract_destinations destination extractors.
  • tools/validation_utils.pyextract_ips_from_command, is_fqdn, is_target_in_allowlist.
  • tools/exploit_session.py_build_exploit_scope_gate, run_exploit_session wiring.
  • tools/cli_exploit_settings.py_resolve_exploit_permission, build_cli_exploit_settings.
  • scope_gate.pyScopeGate, ScopeCheckResult, check_scope.
  • legacy/risk_controller.pyRiskController, RiskAssessment, assess_action (Flow B only).
  • tools/swarm/agents/critic_agent.py — swarm forbidden_actions check.
  • tests/test_exploit_action_category.py — pins the category-name collision constraint.
source: repo docs (build sync)Edit this page on GitHub →