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.
- hexharness/__init__.py +7 -0
- hexharness/__main__.py +214 -0
- hexharness/agent/__init__.py +4 -0
- hexharness/agent/context.py +19 -0
- hexharness/agent/loop.py +203 -0
- hexharness/agent/orchestrator.py +143 -0
- hexharness/agent/subagents.py +99 -0
- hexharness/agent/workflow.py +118 -0
- hexharness/client/__init__.py +104 -0
- hexharness/control/__init__.py +32 -0
- hexharness/control/budget.py +37 -0
- hexharness/control/decision.py +33 -0
- hexharness/control/hitl.py +41 -0
- hexharness/control/kill_switch.py +64 -0
- hexharness/control/plane.py +135 -0
- hexharness/control/policy.py +73 -0
- hexharness/control/pricing.py +30 -0
- hexharness/control/roe.py +50 -0
- hexharness/control/scope_guard.py +81 -0
- hexharness/control/secrets.py +47 -0
- hexharness/control/vault.py +125 -0
- hexharness/daemon/__init__.py +154 -0
- hexharness/engagement.py +99 -0
- hexharness/engagement_builder.py +96 -0
- hexharness/engine.py +322 -0
- hexharness/events/__init__.py +5 -0
- hexharness/events/bus.py +29 -0
- hexharness/events/store.py +93 -0
- hexharness/events/types.py +51 -0
- hexharness/evidence/__init__.py +4 -0
- hexharness/evidence/findings.py +43 -0
- hexharness/evidence/store.py +93 -0
- hexharness/knowledge/__init__.py +5 -0
- hexharness/knowledge/attack.py +61 -0
- hexharness/knowledge/corpus/api_testing.md +19 -0
- hexharness/knowledge/corpus/credential_access.md +15 -0
- hexharness/knowledge/corpus/network_recon.md +14 -0
- hexharness/knowledge/corpus/privilege_escalation.md +14 -0
- hexharness/knowledge/corpus/tools_analysis.md +30 -0
- hexharness/knowledge/corpus/tools_evidence_reporting.md +21 -0
- hexharness/knowledge/corpus/tools_exploitation.md +40 -0
- hexharness/knowledge/corpus/tools_meta.md +54 -0
- hexharness/knowledge/corpus/tools_osint.md +29 -0
- hexharness/knowledge/corpus/tools_recon.md +56 -0
- hexharness/knowledge/corpus/web_sqli.md +15 -0
- hexharness/knowledge/corpus/web_ssrf.md +17 -0
- hexharness/knowledge/corpus/web_xss.md +17 -0
- hexharness/knowledge/corpus/workflow_chaining.md +58 -0
- hexharness/knowledge/data/attack_techniques.json +699 -0
- hexharness/knowledge/data/cwe.json +3878 -0
- hexharness/knowledge/rag.py +80 -0
- hexharness/knowledge/tool.py +47 -0
- hexharness/observability/__init__.py +5 -0
- hexharness/observability/otel.py +79 -0
- hexharness/protocol/__init__.py +68 -0
- hexharness/providers/__init__.py +27 -0
- hexharness/providers/anthropic.py +116 -0
- hexharness/providers/base.py +28 -0
- hexharness/providers/context_window.py +42 -0
- hexharness/providers/fake.py +39 -0
- hexharness/providers/google.py +140 -0
- hexharness/providers/ollama.py +58 -0
- hexharness/providers/openai.py +207 -0
- hexharness/providers/openai_compatible.py +65 -0
- hexharness/providers/router.py +76 -0
- hexharness/providers/types.py +91 -0
- hexharness/recovery/__init__.py +5 -0
- hexharness/recovery/snapshot.py +59 -0
- hexharness/reporting/__init__.py +0 -0
- hexharness/reporting/build.py +30 -0
- hexharness/reporting/generate.py +53 -0
- hexharness/reporting/model.py +95 -0
- hexharness/reporting/render.py +111 -0
- hexharness/reporting/templates/default.html.j2 +55 -0
- hexharness/sandbox/__init__.py +3 -0
- hexharness/sandbox/docker/Dockerfile +40 -0
- hexharness/sandbox/executor.py +200 -0
- hexharness/sandbox/image.py +47 -0
- hexharness/session.py +52 -0
- hexharness/skills/__init__.py +4 -0
- hexharness/skills/engine.py +88 -0
- hexharness/skills/library/exploitation/playbook.md +38 -0
- hexharness/skills/library/exploitation/skill.yaml +6 -0
- hexharness/skills/library/reverse-shell-handler/playbook.md +26 -0
- hexharness/skills/library/reverse-shell-handler/skill.yaml +6 -0
- hexharness/skills/library/sqli-triage/playbook.md +40 -0
- hexharness/skills/library/sqli-triage/skill.yaml +6 -0
- hexharness/skills/library/subdomain-enumeration/playbook.md +34 -0
- hexharness/skills/library/subdomain-enumeration/skill.yaml +6 -0
- hexharness/skills/library/workflow-chaining/playbook.md +67 -0
- hexharness/skills/library/workflow-chaining/skill.yaml +6 -0
- hexharness/skills/slash.py +39 -0
- hexharness/skills/tool.py +52 -0
- hexharness/tools/__init__.py +4 -0
- hexharness/tools/base.py +54 -0
- hexharness/tools/mcp.py +208 -0
- hexharness/tools/native/__init__.py +5 -0
- hexharness/tools/native/binary.py +205 -0
- hexharness/tools/native/browser.py +126 -0
- hexharness/tools/native/engagement_tools.py +46 -0
- hexharness/tools/native/evidence_tools.py +107 -0
- hexharness/tools/native/exec.py +54 -0
- hexharness/tools/native/exploit.py +103 -0
- hexharness/tools/native/extend.py +229 -0
- hexharness/tools/native/fs.py +79 -0
- hexharness/tools/native/knowledge.py +42 -0
- hexharness/tools/native/net.py +74 -0
- hexharness/tools/native/osint.py +182 -0
- hexharness/tools/native/parallel.py +84 -0
- hexharness/tools/native/postman.py +143 -0
- hexharness/tools/native/recon.py +208 -0
- hexharness/tools/native/report_tools.py +51 -0
- hexharness/tools/native/revshell.py +215 -0
- hexharness/tools/native/services.py +194 -0
- hexharness/tools/native/shodan.py +79 -0
- hexharness/tools/native/websearch.py +105 -0
- hexharness/tools/native/workflow_tool.py +36 -0
- hexharness/tools/registry.py +27 -0
- hexharness/transport/__init__.py +99 -0
- hexharness/tui/__init__.py +19 -0
- hexharness/tui/__main__.py +19 -0
- hexharness/tui/app.py +892 -0
- hexharness/tui/approver.py +51 -0
- hexharness/tui/config.py +100 -0
- hexharness/tui/providers.py +183 -0
- hexharness/tui/screens.py +915 -0
- hexharness/tui/widgets.py +70 -0
- hexharness/voice/__init__.py +3 -0
- hexharness/voice/dictation.py +142 -0
- hexharness-1.0.0.dist-info/METADATA +225 -0
- hexharness-1.0.0.dist-info/RECORD +136 -0
- hexharness-1.0.0.dist-info/WHEEL +5 -0
- hexharness-1.0.0.dist-info/entry_points.txt +2 -0
- hexharness-1.0.0.dist-info/licenses/LICENSE +202 -0
- hexharness-1.0.0.dist-info/licenses/NOTICE +12 -0
- hexharness-1.0.0.dist-info/top_level.txt +1 -0
hexharness/__init__.py
ADDED
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,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
|
hexharness/agent/loop.py
ADDED
|
@@ -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
|
+
]
|