hexharness 1.0.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (136) hide show
  1. hexharness/__init__.py +7 -0
  2. hexharness/__main__.py +214 -0
  3. hexharness/agent/__init__.py +4 -0
  4. hexharness/agent/context.py +19 -0
  5. hexharness/agent/loop.py +203 -0
  6. hexharness/agent/orchestrator.py +143 -0
  7. hexharness/agent/subagents.py +99 -0
  8. hexharness/agent/workflow.py +118 -0
  9. hexharness/client/__init__.py +104 -0
  10. hexharness/control/__init__.py +32 -0
  11. hexharness/control/budget.py +37 -0
  12. hexharness/control/decision.py +33 -0
  13. hexharness/control/hitl.py +41 -0
  14. hexharness/control/kill_switch.py +64 -0
  15. hexharness/control/plane.py +135 -0
  16. hexharness/control/policy.py +73 -0
  17. hexharness/control/pricing.py +30 -0
  18. hexharness/control/roe.py +50 -0
  19. hexharness/control/scope_guard.py +81 -0
  20. hexharness/control/secrets.py +47 -0
  21. hexharness/control/vault.py +125 -0
  22. hexharness/daemon/__init__.py +154 -0
  23. hexharness/engagement.py +99 -0
  24. hexharness/engagement_builder.py +96 -0
  25. hexharness/engine.py +322 -0
  26. hexharness/events/__init__.py +5 -0
  27. hexharness/events/bus.py +29 -0
  28. hexharness/events/store.py +93 -0
  29. hexharness/events/types.py +51 -0
  30. hexharness/evidence/__init__.py +4 -0
  31. hexharness/evidence/findings.py +43 -0
  32. hexharness/evidence/store.py +93 -0
  33. hexharness/knowledge/__init__.py +5 -0
  34. hexharness/knowledge/attack.py +61 -0
  35. hexharness/knowledge/corpus/api_testing.md +19 -0
  36. hexharness/knowledge/corpus/credential_access.md +15 -0
  37. hexharness/knowledge/corpus/network_recon.md +14 -0
  38. hexharness/knowledge/corpus/privilege_escalation.md +14 -0
  39. hexharness/knowledge/corpus/tools_analysis.md +30 -0
  40. hexharness/knowledge/corpus/tools_evidence_reporting.md +21 -0
  41. hexharness/knowledge/corpus/tools_exploitation.md +40 -0
  42. hexharness/knowledge/corpus/tools_meta.md +54 -0
  43. hexharness/knowledge/corpus/tools_osint.md +29 -0
  44. hexharness/knowledge/corpus/tools_recon.md +56 -0
  45. hexharness/knowledge/corpus/web_sqli.md +15 -0
  46. hexharness/knowledge/corpus/web_ssrf.md +17 -0
  47. hexharness/knowledge/corpus/web_xss.md +17 -0
  48. hexharness/knowledge/corpus/workflow_chaining.md +58 -0
  49. hexharness/knowledge/data/attack_techniques.json +699 -0
  50. hexharness/knowledge/data/cwe.json +3878 -0
  51. hexharness/knowledge/rag.py +80 -0
  52. hexharness/knowledge/tool.py +47 -0
  53. hexharness/observability/__init__.py +5 -0
  54. hexharness/observability/otel.py +79 -0
  55. hexharness/protocol/__init__.py +68 -0
  56. hexharness/providers/__init__.py +27 -0
  57. hexharness/providers/anthropic.py +116 -0
  58. hexharness/providers/base.py +28 -0
  59. hexharness/providers/context_window.py +42 -0
  60. hexharness/providers/fake.py +39 -0
  61. hexharness/providers/google.py +140 -0
  62. hexharness/providers/ollama.py +58 -0
  63. hexharness/providers/openai.py +207 -0
  64. hexharness/providers/openai_compatible.py +65 -0
  65. hexharness/providers/router.py +76 -0
  66. hexharness/providers/types.py +91 -0
  67. hexharness/recovery/__init__.py +5 -0
  68. hexharness/recovery/snapshot.py +59 -0
  69. hexharness/reporting/__init__.py +0 -0
  70. hexharness/reporting/build.py +30 -0
  71. hexharness/reporting/generate.py +53 -0
  72. hexharness/reporting/model.py +95 -0
  73. hexharness/reporting/render.py +111 -0
  74. hexharness/reporting/templates/default.html.j2 +55 -0
  75. hexharness/sandbox/__init__.py +3 -0
  76. hexharness/sandbox/docker/Dockerfile +40 -0
  77. hexharness/sandbox/executor.py +200 -0
  78. hexharness/sandbox/image.py +47 -0
  79. hexharness/session.py +52 -0
  80. hexharness/skills/__init__.py +4 -0
  81. hexharness/skills/engine.py +88 -0
  82. hexharness/skills/library/exploitation/playbook.md +38 -0
  83. hexharness/skills/library/exploitation/skill.yaml +6 -0
  84. hexharness/skills/library/reverse-shell-handler/playbook.md +26 -0
  85. hexharness/skills/library/reverse-shell-handler/skill.yaml +6 -0
  86. hexharness/skills/library/sqli-triage/playbook.md +40 -0
  87. hexharness/skills/library/sqli-triage/skill.yaml +6 -0
  88. hexharness/skills/library/subdomain-enumeration/playbook.md +34 -0
  89. hexharness/skills/library/subdomain-enumeration/skill.yaml +6 -0
  90. hexharness/skills/library/workflow-chaining/playbook.md +67 -0
  91. hexharness/skills/library/workflow-chaining/skill.yaml +6 -0
  92. hexharness/skills/slash.py +39 -0
  93. hexharness/skills/tool.py +52 -0
  94. hexharness/tools/__init__.py +4 -0
  95. hexharness/tools/base.py +54 -0
  96. hexharness/tools/mcp.py +208 -0
  97. hexharness/tools/native/__init__.py +5 -0
  98. hexharness/tools/native/binary.py +205 -0
  99. hexharness/tools/native/browser.py +126 -0
  100. hexharness/tools/native/engagement_tools.py +46 -0
  101. hexharness/tools/native/evidence_tools.py +107 -0
  102. hexharness/tools/native/exec.py +54 -0
  103. hexharness/tools/native/exploit.py +103 -0
  104. hexharness/tools/native/extend.py +229 -0
  105. hexharness/tools/native/fs.py +79 -0
  106. hexharness/tools/native/knowledge.py +42 -0
  107. hexharness/tools/native/net.py +74 -0
  108. hexharness/tools/native/osint.py +182 -0
  109. hexharness/tools/native/parallel.py +84 -0
  110. hexharness/tools/native/postman.py +143 -0
  111. hexharness/tools/native/recon.py +208 -0
  112. hexharness/tools/native/report_tools.py +51 -0
  113. hexharness/tools/native/revshell.py +215 -0
  114. hexharness/tools/native/services.py +194 -0
  115. hexharness/tools/native/shodan.py +79 -0
  116. hexharness/tools/native/websearch.py +105 -0
  117. hexharness/tools/native/workflow_tool.py +36 -0
  118. hexharness/tools/registry.py +27 -0
  119. hexharness/transport/__init__.py +99 -0
  120. hexharness/tui/__init__.py +19 -0
  121. hexharness/tui/__main__.py +19 -0
  122. hexharness/tui/app.py +892 -0
  123. hexharness/tui/approver.py +51 -0
  124. hexharness/tui/config.py +100 -0
  125. hexharness/tui/providers.py +183 -0
  126. hexharness/tui/screens.py +915 -0
  127. hexharness/tui/widgets.py +70 -0
  128. hexharness/voice/__init__.py +3 -0
  129. hexharness/voice/dictation.py +142 -0
  130. hexharness-1.0.0.dist-info/METADATA +225 -0
  131. hexharness-1.0.0.dist-info/RECORD +136 -0
  132. hexharness-1.0.0.dist-info/WHEEL +5 -0
  133. hexharness-1.0.0.dist-info/entry_points.txt +2 -0
  134. hexharness-1.0.0.dist-info/licenses/LICENSE +202 -0
  135. hexharness-1.0.0.dist-info/licenses/NOTICE +12 -0
  136. hexharness-1.0.0.dist-info/top_level.txt +1 -0
hexharness/__init__.py ADDED
@@ -0,0 +1,7 @@
1
+ """HexHarness — agentic pentesting harness.
2
+
3
+ The value is not the agent loop (that is standard) but the domain CONTROL PLANE:
4
+ scope, ROE, evidence, approvals, budget. See hexharness.control.
5
+ """
6
+
7
+ __version__ = "0.1.0"
hexharness/__main__.py ADDED
@@ -0,0 +1,214 @@
1
+ """Thin CLI.
2
+
3
+ # run a task against an existing engagement
4
+ python -m hexharness run "Look up CWE-79" --engagement engagements/example.engagement.yaml
5
+
6
+ # describe scope in natural language; the agent drafts the engagement, you confirm,
7
+ # then it runs the first task under it — all in one flow
8
+ python -m hexharness init "External test of acme.example and 203.0.113.0/24, \
9
+ exclude vpn.acme.example, enumeration only, business hours" --task "Resolve www.acme.example"
10
+
11
+ `run` is the default when no subcommand is given (back-compat). The in-process entry
12
+ point; the client/daemon WS split is a separate path.
13
+ """
14
+ from __future__ import annotations
15
+
16
+ import argparse
17
+ import asyncio
18
+ import re
19
+ import sys
20
+ from pathlib import Path
21
+
22
+ from hexharness.control.hitl import CLIApprover
23
+ from hexharness.control.policy import Autonomy, Mode, Phase
24
+ from hexharness.control.secrets import GetpassSecretRequester
25
+ from hexharness.engine import Engine
26
+
27
+ _DRAFT_SYSTEM = (
28
+ "You are setting up a new pentest engagement. Read the operator's natural-language "
29
+ "brief and call the engagement_draft tool EXACTLY ONCE with a structured spec: "
30
+ "scope.domains, scope.cidrs, scope.exclusions, roe.max_risk/max_autonomy/max_phase, "
31
+ "optional time windows and budget. Infer a short engagement name. Do not invent scope "
32
+ "the operator did not give. After drafting, briefly summarize what you proposed."
33
+ )
34
+
35
+ _PATH_RE = re.compile(r"Draft written to (\S+) \(")
36
+
37
+
38
+ def _build_mode(args) -> Mode:
39
+ return Mode(autonomy=Autonomy[args.autonomy.upper()], phase=Phase[args.phase.upper()])
40
+
41
+
42
+ async def _run(args) -> str:
43
+ from hexharness.providers.anthropic import AnthropicProvider
44
+
45
+ provider = AnthropicProvider()
46
+ engine = Engine.from_engagement(
47
+ args.engagement, provider=provider, requested_mode=_build_mode(args),
48
+ approver=CLIApprover(), secret_requester=GetpassSecretRequester(), kill_trigger_file=args.kill_file,
49
+ )
50
+ engine.kill_switch.install_signal_handler()
51
+ engine.kill_switch.start_file_watch()
52
+ try:
53
+ return await engine.loop(provider=provider, model=args.model).run(args.prompt)
54
+ finally:
55
+ engine.close_sandbox() # remove the session container; /workspace persists
56
+
57
+
58
+ async def _init(args) -> str:
59
+ from hexharness.providers.anthropic import AnthropicProvider
60
+
61
+ provider = AnthropicProvider()
62
+ # Locked bootstrap engine: empty scope, report-only, can ONLY draft an engagement.
63
+ boot = Engine.bootstrap(out_dir=args.out, approver=CLIApprover())
64
+ out = await boot.loop(provider=provider, model=args.model).run(args.brief)
65
+ print(out)
66
+
67
+ m = _PATH_RE.search(out)
68
+ if not m:
69
+ return "No draft was produced. Rephrase the brief and try again."
70
+ path = Path(m.group(1))
71
+
72
+ # HUMAN gate: the operator approves the ROE before anything runs under it (invariant #4).
73
+ print("\n" + "=" * 60)
74
+ print(path.read_text())
75
+ print("=" * 60)
76
+ try:
77
+ answer = input(f"Activate {path.name} and run under it? [y/N] ").strip().lower()
78
+ except EOFError:
79
+ answer = ""
80
+ if answer not in ("y", "yes"):
81
+ return f"Not activated. Draft left inert at {path}."
82
+
83
+ if not args.task:
84
+ return f"Activated: {path}. Run a task with:\n python -m hexharness run \"<task>\" --engagement {path}"
85
+
86
+ engine = Engine.from_engagement(
87
+ path, provider=provider, requested_mode=_build_mode(args),
88
+ approver=CLIApprover(), secret_requester=GetpassSecretRequester(), kill_trigger_file=args.kill_file,
89
+ )
90
+ engine.kill_switch.install_signal_handler()
91
+ engine.kill_switch.start_file_watch()
92
+ return await engine.loop(provider=provider, model=args.model).run(args.task)
93
+
94
+
95
+ def _parser() -> argparse.ArgumentParser:
96
+ p = argparse.ArgumentParser(prog="hexharness")
97
+ sub = p.add_subparsers(dest="cmd", required=True)
98
+
99
+ def _common(sp):
100
+ sp.add_argument("--autonomy", default="interactive", choices=[a.name.lower() for a in Autonomy])
101
+ sp.add_argument("--phase", default="recon", choices=[ph.name.lower() for ph in Phase])
102
+ sp.add_argument("--model", default=None)
103
+ sp.add_argument("--kill-file", default=None,
104
+ help="touch this path from anywhere to trigger the out-of-band kill switch")
105
+
106
+ r = sub.add_parser("run", help="run a task against an existing engagement")
107
+ r.add_argument("prompt")
108
+ r.add_argument("--engagement", default="engagements/example.engagement.yaml")
109
+ _common(r)
110
+
111
+ i = sub.add_parser("init", help="draft a new engagement from a natural-language brief, then run it")
112
+ i.add_argument("brief", help="natural-language scope/ROE description")
113
+ i.add_argument("--task", default=None, help="first task to run once the engagement is activated")
114
+ i.add_argument("--out", default="engagements", help="directory to write the engagement file into")
115
+ _common(i)
116
+
117
+ t = sub.add_parser("tui", help="launch the terminal UI")
118
+ # No default: launched without --engagement, the TUI starts blank (no scope, no resumed
119
+ # history) and the operator configures a new engagement by describing the target.
120
+ t.add_argument("--engagement", default=None)
121
+
122
+ sub.add_parser("build-image", help="build the HexHarness sandbox image (Kali + tools)")
123
+
124
+ v = sub.add_parser("verify", help="verify a session's audit log (SHA-256 hash chain) for tampering")
125
+ v.add_argument("--engagement", default="engagements/example.engagement.yaml")
126
+
127
+ rp = sub.add_parser("report", help="render a client report from an engagement's confirmed findings")
128
+ rp.add_argument("--engagement", default="engagements/example.engagement.yaml")
129
+ rp.add_argument("--out", default=None, help="output path; extension picks the format (.html/.pdf/.docx/.md)")
130
+ rp.add_argument("--template", default=None, help="template path (defaults to the bundled HTML template)")
131
+ rp.add_argument("--no-generative", action="store_true", help="skip the LLM exec-summary/risk-narrative")
132
+ rp.add_argument("--model", default=None)
133
+ return p
134
+
135
+
136
+ def _verify(args) -> str:
137
+ from hexharness import session as sess
138
+ from hexharness.engagement import Engagement
139
+ from hexharness.events.store import EventStore, HashChainError
140
+
141
+ name = Engagement.load(args.engagement).name
142
+ db = sess.db_path(name)
143
+ if not db.exists():
144
+ return f"no session log for '{name}' at {db} — nothing to verify."
145
+ store = EventStore(str(db))
146
+ n = len(store.all())
147
+ try:
148
+ store.verify()
149
+ except HashChainError as exc:
150
+ return f"TAMPERED: audit log for '{name}' failed verification at — {exc} ({n} events)"
151
+ return f"OK: audit log for '{name}' is intact — {n} events, hash chain verified."
152
+
153
+
154
+ async def _report(args) -> str:
155
+ from hexharness import session as sess
156
+ from hexharness.engagement import Engagement
157
+ from hexharness.evidence.store import EvidenceStore
158
+ from hexharness.events.store import EventStore
159
+ from hexharness.reporting.build import build_report
160
+
161
+ eng = Engagement.load(args.engagement)
162
+ db = sess.db_path(eng.name)
163
+ if not db.exists():
164
+ return f"no session for '{eng.name}' at {db} — run a task first so there are findings."
165
+ events = EventStore(str(db))
166
+ evidence = EvidenceStore(str(db), events=events)
167
+ provider = None
168
+ if not args.no_generative:
169
+ from hexharness.providers.anthropic import AnthropicProvider
170
+
171
+ provider = AnthropicProvider()
172
+ out = args.out or str(sess.session_dir(eng.name) / f"report-{eng.name}.html")
173
+ path = await build_report(eng, evidence, out, provider=provider,
174
+ template=args.template, model_name=args.model)
175
+ return f"report written to {path} ({len(evidence.confirmed())} confirmed findings)."
176
+
177
+
178
+ def main() -> int:
179
+ argv = sys.argv[1:]
180
+ _subcommands = ("run", "init", "tui", "build-image", "verify", "report", "-h", "--help")
181
+ if argv and argv[0] not in _subcommands:
182
+ argv = ["run", *argv] # back-compat: bare prompt => run
183
+ args = _parser().parse_args(argv)
184
+
185
+ if args.cmd == "verify":
186
+ print(_verify(args))
187
+ return 0
188
+
189
+ if args.cmd == "build-image":
190
+ from hexharness.sandbox.image import DEFAULT_SANDBOX_IMAGE, build_image
191
+
192
+ ok, out = asyncio.run(build_image(DEFAULT_SANDBOX_IMAGE, on_output=print))
193
+ print(f"\n{'built' if ok else 'failed'}: {DEFAULT_SANDBOX_IMAGE}")
194
+ return 0 if ok else 1
195
+
196
+ if args.cmd == "tui":
197
+ from hexharness.tui.app import HexTUI # textual only imported here
198
+
199
+ HexTUI(engagement=args.engagement).run()
200
+ return 0
201
+
202
+ coro = {"init": _init, "report": _report}.get(args.cmd, _run)(args)
203
+ try:
204
+ print(asyncio.run(coro))
205
+ return 0
206
+ except KeyboardInterrupt:
207
+ return 130
208
+ except Exception as exc: # noqa: BLE001
209
+ print(f"error: {exc}", file=sys.stderr)
210
+ return 1
211
+
212
+
213
+ if __name__ == "__main__":
214
+ raise SystemExit(main())
@@ -0,0 +1,4 @@
1
+ from hexharness.agent.context import ExecContext
2
+ from hexharness.agent.loop import AgentLoop
3
+
4
+ __all__ = ["ExecContext", "AgentLoop"]
@@ -0,0 +1,19 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+ from datetime import datetime
5
+
6
+ from hexharness.control.policy import Mode
7
+
8
+
9
+ @dataclass(frozen=True)
10
+ class ExecContext:
11
+ """Per-subagent execution context. The mode lives HERE, not as a global — each
12
+ subagent (Recon, WebApp, ...) can run at a different autonomy/phase under the same
13
+ ROE ceiling."""
14
+
15
+ engagement_id: str
16
+ subagent_id: str
17
+ mode: Mode
18
+ # Pinned clock for deterministic ROE time-window checks in tests; None = real now.
19
+ now: datetime | None = None
@@ -0,0 +1,203 @@
1
+ """Single-agent loop. The orchestrator-worker split (phase 8) composes many of these;
2
+ each still routes every tool call through the same chokepoint.
3
+
4
+ INVARIANT #1 lives here: the only place a tool is executed is `_execute`, and it is
5
+ reached only after `control.authorize(...)` returned ALLOW. There is no other path.
6
+ """
7
+ from __future__ import annotations
8
+
9
+ from hexharness.agent.context import ExecContext
10
+ from hexharness.control.plane import ControlPlane
11
+ from hexharness.control.pricing import usd_cost
12
+ from hexharness.events.store import EventStore
13
+ from hexharness.events.types import EventType
14
+ from hexharness.providers.base import LLMProvider
15
+ from hexharness.providers.types import (
16
+ Message,
17
+ Role,
18
+ StopReason,
19
+ TextBlock,
20
+ ToolResultBlock,
21
+ )
22
+ from hexharness.tools.registry import ToolRegistry
23
+
24
+ DEFAULT_SYSTEM = (
25
+ "You are a pentesting assistant operating inside HexHarness. Every tool call is "
26
+ "mediated by a control plane that enforces scope, rules of engagement, and human "
27
+ "approval. If a tool is denied, respect it and adapt — do not retry a denied action.\n\n"
28
+ "exec_command runs each invocation in a SEPARATE, EPHEMERAL Docker container (docker "
29
+ "run --rm): nothing you install or change in one call survives to the next. Only files "
30
+ "under /workspace persist (it is mounted from the host and shared with file_read/"
31
+ "file_write). So do NOT try to `pip install`/`apt-get install` a tool in one call and "
32
+ "use it in another — the second container starts clean. If you need a tool that is not "
33
+ "already in the image, install it and use it within the SAME exec_command call (chain "
34
+ "with 'sh -c'), or write outputs to /workspace. For driving a web browser (navigation, "
35
+ "clicks, screenshots) use the dedicated `browser` tool — it runs Playwright on the host, "
36
+ "not in the sandbox, so never install Playwright via exec_command."
37
+ )
38
+
39
+
40
+ class AgentLoop:
41
+ def __init__(
42
+ self,
43
+ *,
44
+ provider: LLMProvider,
45
+ control: ControlPlane,
46
+ registry: ToolRegistry,
47
+ events: EventStore,
48
+ ctx: ExecContext,
49
+ system: str = DEFAULT_SYSTEM,
50
+ model: str | None = None,
51
+ max_iterations: int = 12,
52
+ kill_switch=None,
53
+ on_text=None,
54
+ on_thinking=None,
55
+ compact_threshold: float = 0.8,
56
+ ):
57
+ self.provider = provider
58
+ self.control = control
59
+ self.registry = registry
60
+ self.events = events
61
+ self.ctx = ctx
62
+ self.system = system
63
+ self.model = model
64
+ self.max_iterations = max_iterations
65
+ self.kill_switch = kill_switch
66
+ self.on_text = on_text
67
+ self.on_thinking = on_thinking
68
+ self.compact_threshold = compact_threshold
69
+ # Persistent conversation across run() calls, so the model remembers prior turns
70
+ # and compaction has something to compact.
71
+ self.conversation: list[Message] = []
72
+ self.last_input_tokens = 0
73
+
74
+ def _model_id(self) -> str:
75
+ return self.model or getattr(self.provider, "default_model", "") or ""
76
+
77
+ def context_ratio(self) -> float:
78
+ """Fraction of the model's context window used by the last call (for the UI)."""
79
+ from hexharness.providers.context_window import usage_ratio
80
+
81
+ return usage_ratio(self._model_id(), self.last_input_tokens)
82
+
83
+ async def compact(self, *, reason: str = "manual") -> int:
84
+ """Summarize the conversation into durable notes and replace the history with a
85
+ single summary message. Returns how many messages were collapsed."""
86
+ if len(self.conversation) < 2:
87
+ return 0
88
+ before = len(self.conversation)
89
+ summary_req = [
90
+ *self.conversation,
91
+ Message.user_text(
92
+ "Compact this pentest session into terse durable notes: objectives, scope, "
93
+ "confirmed/candidate findings, decisions, and open threads. Preserve facts "
94
+ "needed to continue; drop chatter."
95
+ ),
96
+ ]
97
+ resp = await self.provider.complete(
98
+ summary_req, system="You compact a pentest session transcript into durable notes.",
99
+ model=self.model,
100
+ )
101
+ self.conversation = [Message.user_text(f"[compacted session summary]\n{resp.text()}")]
102
+ self.last_input_tokens = resp.usage.input_tokens
103
+ await self.events.append(
104
+ EventType.CONTEXT_COMPACTED,
105
+ {"reason": reason, "messages_before": before, "messages_after": len(self.conversation)},
106
+ )
107
+ return before - len(self.conversation)
108
+
109
+ def _system_with_scope(self) -> str:
110
+ """System prompt + the live engagement boundary (scope, ROE, mode) so the model
111
+ knows where it may act and does not waste turns on auto-denied targets."""
112
+ scope = self.control.scope_guard.scope
113
+ roe = self.control.roe
114
+ lines = [self.system, "", "## Engagement boundary (ENFORCED — out-of-scope or over-ROE actions are auto-denied)"]
115
+ if scope.domains:
116
+ lines.append("In-scope domains: " + ", ".join(scope.domains))
117
+ if scope.cidrs:
118
+ lines.append("In-scope CIDRs: " + ", ".join(scope.cidrs))
119
+ if scope.exclusions:
120
+ lines.append("EXCLUDED (never touch): " + ", ".join(scope.exclusions))
121
+ if not scope.domains and not scope.cidrs:
122
+ lines.append("In-scope targets: (none set — scope-sensitive tools will be denied)")
123
+ lines.append(
124
+ f"ROE ceiling: max_risk={roe.max_risk.name.lower()}, "
125
+ f"max_autonomy={roe.max_autonomy.name.lower()}, max_phase={roe.max_phase.name.lower()}"
126
+ )
127
+ lines.append(
128
+ f"Current mode: {self.ctx.mode.autonomy.name.lower()}/{self.ctx.mode.phase.name.lower()}. "
129
+ "Act only within this scope and ROE; do not attempt targets or risk levels beyond them."
130
+ )
131
+ return "\n".join(lines)
132
+
133
+ async def run(self, user_prompt: str) -> str:
134
+ from hexharness.providers.context_window import should_compact
135
+
136
+ await self.events.append(EventType.USER_PROMPT, {"text": user_prompt, "subagent": self.ctx.subagent_id})
137
+ # Auto-compact BEFORE adding the new turn if the last call was near the window.
138
+ if should_compact(self._model_id(), self.last_input_tokens, self.compact_threshold):
139
+ await self.compact(reason="auto")
140
+ self.conversation.append(Message.user_text(user_prompt))
141
+ messages = self.conversation
142
+ system = self._system_with_scope() # rebuilt each turn so scope/ROE changes show up
143
+
144
+ for _ in range(self.max_iterations):
145
+ if self.kill_switch and self.kill_switch.triggered:
146
+ return f"[killed: {self.kill_switch.reason}]"
147
+
148
+ resp = await self.provider.complete(
149
+ messages, tools=self.registry.specs(), system=system, model=self.model,
150
+ on_text=self.on_text, on_thinking=self.on_thinking,
151
+ )
152
+ self.last_input_tokens = resp.usage.input_tokens
153
+ self.control.budget.add_tokens(resp.usage.total_tokens)
154
+ self.control.budget.add_usd(usd_cost(resp.model or self.model or "", resp.usage))
155
+ await self.events.append(
156
+ EventType.MODEL_RESPONSE,
157
+ {"stop_reason": resp.stop_reason.value, "tokens": resp.usage.total_tokens},
158
+ )
159
+ await self.events.append(
160
+ EventType.BUDGET_UPDATED,
161
+ {"tokens_used": self.control.budget.tokens_used, "usd_used": round(self.control.budget.usd_used, 4)},
162
+ )
163
+ messages.append(Message(role=Role.ASSISTANT, content=resp.content))
164
+
165
+ tool_uses = resp.tool_uses()
166
+ if resp.stop_reason is not StopReason.TOOL_USE or not tool_uses:
167
+ return resp.text()
168
+
169
+ results: list[ToolResultBlock] = []
170
+ for tu in tool_uses:
171
+ results.append(await self._handle_tool_use(tu))
172
+ messages.append(Message(role=Role.USER, content=results)) # type: ignore[arg-type]
173
+
174
+ return "[max iterations reached]"
175
+
176
+ async def _handle_tool_use(self, tu) -> ToolResultBlock:
177
+ tool = self.registry.get(tu.name)
178
+ if tool is None:
179
+ # Fail-closed: unknown tool is never executed.
180
+ return ToolResultBlock(tool_use_id=tu.id, content=f"unknown tool: {tu.name}", is_error=True)
181
+
182
+ # THE chokepoint. No branch executes the tool without passing here first.
183
+ decision = await self.control.authorize(self.ctx, tool, tu.input)
184
+ if not decision.allowed:
185
+ return ToolResultBlock(
186
+ tool_use_id=tu.id,
187
+ content=f"DENIED by {decision.gate}: {decision.reason}",
188
+ is_error=True,
189
+ )
190
+ return await self._execute(tool, tu)
191
+
192
+ async def _execute(self, tool, tu) -> ToolResultBlock:
193
+ await self.events.append(EventType.TOOL_STARTED, {"tool": tool.name, "input": tu.input})
194
+ try:
195
+ output = await tool.run(tu.input)
196
+ # ponytail: truncate the copy stored in the event/shown in the TUI; the model
197
+ # still gets the full output via the ToolResultBlock below.
198
+ await self.events.append(EventType.TOOL_FINISHED,
199
+ {"tool": tool.name, "ok": True, "output": output[:2000]})
200
+ return ToolResultBlock(tool_use_id=tu.id, content=output)
201
+ except Exception as exc: # noqa: BLE001
202
+ await self.events.append(EventType.TOOL_FINISHED, {"tool": tool.name, "ok": False, "error": str(exc)})
203
+ return ToolResultBlock(tool_use_id=tu.id, content=f"tool error: {exc}", is_error=True)
@@ -0,0 +1,143 @@
1
+ """Orchestrator-worker coordination (phase 8, part 2).
2
+
3
+ The orchestrator LLM plans an objective into a list of ``{role, task}`` subtasks.
4
+ Each subtask is handed to a worker that runs its OWN AgentLoop with:
5
+
6
+ - an ISOLATED ExecContext (distinct subagent_id, the role's phase),
7
+ - its OWN message history (AgentLoop.run builds a fresh list — workers never
8
+ share messages, that is the context isolation),
9
+ - a registry filtered to the role's tools.
10
+
11
+ Workers reuse the PARENT control plane, registry source, and event store: every
12
+ worker tool call still routes through ControlPlane.authorize with its own ctx —
13
+ the orchestrator never bypasses the chokepoint. Results are aggregated and
14
+ returned; delegation start/finish is traced on the event log.
15
+ """
16
+ from __future__ import annotations
17
+
18
+ import json
19
+ from collections.abc import Callable
20
+ from typing import Any
21
+
22
+ from hexharness.agent.context import ExecContext
23
+ from hexharness.agent.loop import AgentLoop
24
+ from hexharness.agent.subagents import ROLES, Role
25
+ from hexharness.control.plane import ControlPlane
26
+ from hexharness.control.policy import Autonomy, Mode
27
+ from hexharness.events.store import EventStore
28
+ from hexharness.events.types import EventType
29
+ from hexharness.providers.base import LLMProvider
30
+ from hexharness.tools.registry import ToolRegistry
31
+
32
+ ORCHESTRATOR_SYSTEM = (
33
+ "You are the lead pentest orchestrator. Decompose the objective into subtasks for "
34
+ "specialist workers. Reply with ONLY a JSON array of objects "
35
+ '{"role": <one of: ' + ", ".join(ROLES) + '>, "task": <imperative instruction>}. '
36
+ "No prose, no code fences."
37
+ )
38
+
39
+
40
+ class Orchestrator:
41
+ def __init__(
42
+ self,
43
+ *,
44
+ provider: LLMProvider,
45
+ control: ControlPlane,
46
+ registry: ToolRegistry,
47
+ events: EventStore,
48
+ engagement_id: str,
49
+ model: str | None = None,
50
+ autonomy: Autonomy = Autonomy.INTERACTIVE,
51
+ max_iterations: int = 12,
52
+ kill_switch: Any = None,
53
+ # Per-worker provider. Default: reuse the planning provider. A factory lets a
54
+ # caller (or test) give each worker an independent scripted provider.
55
+ make_worker_provider: Callable[[Role], LLMProvider] | None = None,
56
+ ):
57
+ self.provider = provider
58
+ self.control = control
59
+ self.registry = registry
60
+ self.events = events
61
+ self.engagement_id = engagement_id
62
+ self.model = model
63
+ self.autonomy = autonomy
64
+ self.max_iterations = max_iterations
65
+ self.kill_switch = kill_switch
66
+ self._make_worker_provider = make_worker_provider
67
+
68
+ async def run(self, objective: str) -> dict[str, Any]:
69
+ plan = await self._plan(objective)
70
+ workers: list[dict[str, Any]] = []
71
+ for i, item in enumerate(plan):
72
+ workers.append(await self._delegate(ROLES[item["role"]], item["task"], i))
73
+ return {"objective": objective, "plan": plan, "workers": workers}
74
+
75
+ async def _plan(self, objective: str) -> list[dict[str, str]]:
76
+ resp = await self.provider.complete(
77
+ [self._user(objective)], system=ORCHESTRATOR_SYSTEM, model=self.model
78
+ )
79
+ self.control.budget.add_tokens(resp.usage.total_tokens)
80
+ return _parse_plan(resp.text())
81
+
82
+ async def _delegate(self, role: Role, task: str, i: int) -> dict[str, Any]:
83
+ subagent_id = f"{role.name}-{i}" # distinct per worker => isolated identity
84
+ # Invariant #4: active mode = min(requested, ROE ceiling) — clamp BOTH axes,
85
+ # not just autonomy, so a role's phase can never exceed the engagement ceiling.
86
+ mode = self.control.roe.clamp_mode(Mode(autonomy=self.autonomy, phase=role.default_phase))
87
+ ctx = ExecContext(engagement_id=self.engagement_id, subagent_id=subagent_id, mode=mode)
88
+ await self.events.append(
89
+ EventType.DELEGATION_STARTED,
90
+ {"subagent": subagent_id, "role": role.name, "phase": mode.phase.name, "task": task},
91
+ )
92
+ loop = AgentLoop(
93
+ provider=self._worker_provider(role),
94
+ control=self.control, # SHARED chokepoint — authorize runs per worker ctx
95
+ registry=self._filtered_registry(role),
96
+ events=self.events,
97
+ ctx=ctx,
98
+ system=role.system_prompt,
99
+ model=self.model,
100
+ max_iterations=self.max_iterations,
101
+ kill_switch=self.kill_switch,
102
+ )
103
+ result = await loop.run(task)
104
+ await self.events.append(
105
+ EventType.DELEGATION_FINISHED,
106
+ {"subagent": subagent_id, "role": role.name, "result": result},
107
+ )
108
+ return {"role": role.name, "subagent_id": subagent_id,
109
+ "phase": mode.phase.name, "task": task, "result": result}
110
+
111
+ def _worker_provider(self, role: Role) -> LLMProvider:
112
+ return self._make_worker_provider(role) if self._make_worker_provider else self.provider
113
+
114
+ def _filtered_registry(self, role: Role) -> ToolRegistry:
115
+ reg = ToolRegistry()
116
+ # ponytail: ToolRegistry has no public iterator; read its dict directly.
117
+ for tool in self.registry._tools.values(): # noqa: SLF001
118
+ if role.tool_filter(tool):
119
+ reg.register(tool)
120
+ return reg
121
+
122
+ @staticmethod
123
+ def _user(text: str):
124
+ from hexharness.providers.types import Message
125
+
126
+ return Message.user_text(text)
127
+
128
+
129
+ def _parse_plan(text: str) -> list[dict[str, str]]:
130
+ """Pull the JSON array out of the model's reply; keep only well-formed, known roles.
131
+ Fail-closed: anything unparseable yields an empty plan (no workers spawned)."""
132
+ start, end = text.find("["), text.rfind("]")
133
+ if start == -1 or end <= start:
134
+ return []
135
+ try:
136
+ raw = json.loads(text[start : end + 1])
137
+ except json.JSONDecodeError:
138
+ return []
139
+ return [
140
+ {"role": item["role"], "task": str(item["task"])}
141
+ for item in raw
142
+ if isinstance(item, dict) and item.get("role") in ROLES and item.get("task")
143
+ ]