@murk-security/runner-linux-arm64 0.0.0-stage → 0.7.0

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 (30) hide show
  1. package/README.md +2 -2
  2. package/bin/harness +0 -0
  3. package/package.json +15 -4
  4. package/runners/basic_harness_run.py +1271 -0
  5. package/runners/checklists/anonymous.json +24 -0
  6. package/runners/cve_feed.json +53 -0
  7. package/runners/prod_env.py +1686 -0
  8. package/runners/profile.py +307 -0
  9. package/runners/recon.py +396 -0
  10. package/runners/remediate.py +429 -0
  11. package/runners/report.py +365 -0
  12. package/runners/skills/api/idor-enumeration/SKILL.md +42 -0
  13. package/runners/skills/exploit/authz-differential/SKILL.md +62 -0
  14. package/runners/skills/exploit/business-logic/SKILL.md +53 -0
  15. package/runners/skills/exploit/client-side-exploitation/SKILL.md +63 -0
  16. package/runners/skills/exploit/client-side-rendering/SKILL.md +57 -0
  17. package/runners/skills/exploit/credential-chaining/SKILL.md +56 -0
  18. package/runners/skills/exploit/known-cve-validation/SKILL.md +46 -0
  19. package/runners/skills/exploit/path-traversal/SKILL.md +44 -0
  20. package/runners/skills/exploit/reflected-xss/SKILL.md +43 -0
  21. package/runners/skills/exploit/service-exploitation/SKILL.md +64 -0
  22. package/runners/skills/exploit/service-probing/SKILL.md +51 -0
  23. package/runners/skills/exploit/sqli-inference/SKILL.md +46 -0
  24. package/runners/skills/exploit/ssrf-oob/SKILL.md +48 -0
  25. package/runners/skills/exploit/state-change-exploitation/SKILL.md +72 -0
  26. package/runners/skills/exploit/stateful-authz/SKILL.md +50 -0
  27. package/runners/skills/recon/subdomain-takeover/SKILL.md +45 -0
  28. package/runners/skills/web/content-discovery/SKILL.md +59 -0
  29. package/runners/skills/web/graphql-exposure/SKILL.md +40 -0
  30. package/runners/skills.py +174 -0
@@ -0,0 +1,1271 @@
1
+ #!/usr/bin/env python3
2
+ """Blackglass product runner adapter for basic-harness (teams+directions).
3
+
4
+ In-product sibling of the multiagent runner's adapter. The control-plane's
5
+ RunnerSupervisor spawns this as:
6
+
7
+ python3 $BASIC_HARNESS_ROOT/runners/basic_harness_run.py \
8
+ --spec <session>/input-spec.json --session-dir <session> --events stdout
9
+
10
+ It reads the RunSpec, runs the basic-harness Go binary against the target through
11
+ prod_env.py (READ-ONLY; identities from BLACKGLASS_SECRETS_JSON), and emits Blackglass
12
+ events on stdout: lifecycle events (run.started/completed/failed) on the main thread,
13
+ plus a LIVE activity stream tailed from the harness's own event log. Like the
14
+ multiagent adapter -- which follows `cmd/run-events` -- we map core events to v2 public
15
+ events; basic-harness has no run-events subcommand, so a follower thread tails the
16
+ harness state DB's `events` table (and newly-confirmed findings in `nodes`) directly
17
+ and emits mapped events with periodic heartbeats so the dashboard timeline stays live.
18
+
19
+ No Go-core change: the harness is driven entirely through its existing flags. Exit
20
+ codes mirror the core runner contract: 0 ok, 2 invalid input, 3 policy refusal,
21
+ 4 cancelled.
22
+ """
23
+
24
+ from __future__ import annotations
25
+
26
+ import argparse
27
+ import datetime as dt
28
+ import hashlib
29
+ import json
30
+ import os
31
+ import re
32
+ import signal
33
+ import sqlite3
34
+ import subprocess
35
+ import sys
36
+ import threading
37
+ import time
38
+ import uuid
39
+ from pathlib import Path
40
+ from urllib.parse import urlparse
41
+
42
+ ROOT = Path(os.environ.get("BASIC_HARNESS_ROOT", Path(__file__).resolve().parent.parent))
43
+ HARNESS = os.environ.get("BASIC_HARNESS_BIN", str(ROOT / "bin" / "harness"))
44
+ PROD_ENV = str(Path(__file__).resolve().parent / "prod_env.py")
45
+ PROFILE_PY = os.environ.get("PROFILE_PY", str(Path(__file__).resolve().parent / "profile.py"))
46
+ KEY_FILE = os.environ.get("BASIC_HARNESS_KEY_FILE", str(Path.home() / "Code" / "keys" / ".env"))
47
+
48
+ DEFAULT_MODEL = os.environ.get("BASIC_HARNESS_MODEL", "z-ai/glm-5.3-flash")
49
+ # Premium models sit at two roles; everything else runs on the flash base model:
50
+ # - COORDINATOR (kimi-k3): the per-team synthesis locus that authors every finding. The
51
+ # Juice Shop sweep (2026-10-28) showed K3 here wins on recall + category coverage, while
52
+ # the SAME model at the orchestrator made recall worse and was unstable (a full-length
53
+ # run that scored 0). This is where the expensive reasoning earns its keep.
54
+ # - ORCHESTRATOR (glm-5.3): the council chair + members that prioritize directions across a
55
+ # large surface. Kept premium (but NOT k3) on purpose: the sweep ran single-planner
56
+ # (baseline) orchestration on one mid-size target, which under-weights the orchestrator's
57
+ # real value on big multi-host targets where council planning over a capable model pays
58
+ # off. In council mode all 3 members and the chair run on this model (orchestrator.go).
59
+ DEFAULT_COORD_MODEL = os.environ.get("BASIC_HARNESS_COORD_MODEL", "moonshotai/kimi-k3")
60
+ DEFAULT_ORCH_MODEL = os.environ.get("BASIC_HARNESS_ORCH_MODEL", "z-ai/glm-5.3")
61
+ DEFAULT_ORCHESTRATOR = os.environ.get("BASIC_HARNESS_ORCHESTRATOR", "council")
62
+
63
+ # Remediation (patch generation) runs a small best-of-N + reviewer pipeline, NOT the flash
64
+ # base model: a patch is one cheap job (not a 45-min scan), so premium models earn their keep
65
+ # on correctness. Generator = glm-5.3 (full, stronger than flash); reviewer = kimi-k3 (the
66
+ # strongest reasoner, used to judge "does this actually fix it, minimally, no regression").
67
+ # These literals ship in the hosted path (BASIC_HARNESS_* env is not forwarded to the runner).
68
+ DEFAULT_REMEDIATE_MODEL = os.environ.get("BASIC_HARNESS_REMEDIATE_MODEL", DEFAULT_ORCH_MODEL)
69
+ DEFAULT_REMEDIATE_REVIEWER = os.environ.get("BASIC_HARNESS_REMEDIATE_REVIEWER", DEFAULT_COORD_MODEL)
70
+ try:
71
+ DEFAULT_REMEDIATE_CANDIDATES = max(1, int(os.environ.get("BASIC_HARNESS_REMEDIATE_CANDIDATES", "3")))
72
+ except ValueError:
73
+ DEFAULT_REMEDIATE_CANDIDATES = 3
74
+
75
+ # Two scan profiles, selected by RunSpec.scanProfile (default 'full'). PR = fast, cheap,
76
+ # diff-scoped gate that must return in minutes so it can block a PR; FULL = the deep
77
+ # whole-repo audit. The control plane curates the child env, so these LITERALS (not the
78
+ # BASIC_HARNESS_* env, which only reaches local dev) are what ship in the hosted path.
79
+ # Each profile sets the model placement, whether the council runs, breadth, and the cost
80
+ # backstop; the time cap comes from policy.maxDurationMinutes, falling back to the
81
+ # profile's default_minutes. See [[murk-pricing-launch-plan]].
82
+ SCAN_PROFILES: dict[str, dict] = {
83
+ "pr": {
84
+ "orchestrator": "baseline", # no council: keep the PR gate fast and cheap
85
+ "coord_model": "", # flash everywhere incl. the coordinator
86
+ "orch_model": "",
87
+ "teams": "3", "cells": "2", "team_calls": "40", "workers": "6",
88
+ "max_cost": "3", "default_minutes": 10.0,
89
+ },
90
+ "full": {
91
+ "orchestrator": DEFAULT_ORCHESTRATOR, # council
92
+ "coord_model": DEFAULT_COORD_MODEL, # kimi-k3 at the synthesis locus
93
+ "orch_model": DEFAULT_ORCH_MODEL, # glm-5.3 council chair + members
94
+ "teams": "5", "cells": "3", "team_calls": "60", "workers": "8",
95
+ "max_cost": "15", "default_minutes": 45.0,
96
+ },
97
+ }
98
+
99
+
100
+ def scan_profile_for(spec: dict) -> tuple[str, dict]:
101
+ """Resolve (name, config) for a RunSpec's scanProfile. Unknown/absent -> 'full'
102
+ (the pre-two-profile default), so existing assessments keep their behavior."""
103
+ name = str((spec or {}).get("scanProfile") or "full").lower()
104
+ if name not in SCAN_PROFILES:
105
+ name = "full"
106
+ return name, SCAN_PROFILES[name]
107
+
108
+
109
+ def _pr_changed_files(checkout: str, base: str, limit: int = 100) -> list[str]:
110
+ """Files changed between `base` and HEAD in the checkout (name-only). Safe: returns
111
+ [] on any git error, so a scoping failure degrades to a whole-repo PR scan rather
112
+ than breaking the run."""
113
+ try:
114
+ out = subprocess.run(
115
+ ["git", "-C", checkout, "diff", "--name-only", f"{base}...HEAD"],
116
+ capture_output=True, text=True, timeout=30, check=False,
117
+ )
118
+ files = [ln.strip() for ln in out.stdout.splitlines() if ln.strip()]
119
+ return files[:limit]
120
+ except (OSError, subprocess.SubprocessError):
121
+ return []
122
+
123
+
124
+ def _pr_scope_text(files: list[str]) -> str:
125
+ """Brief addendum that focuses a PR scan on the changed files + their blast radius."""
126
+ listing = "\n".join(f"- {f}" for f in files)
127
+ return (
128
+ "\n\n## PR scope (focus here)\n"
129
+ "This is a fast pull-request gate. Concentrate on the code CHANGED in this PR and "
130
+ "the code that directly calls into or is reached from it (its blast radius). Do NOT "
131
+ "attempt a whole-repo audit. Changed files:\n" + listing
132
+ )
133
+ HEARTBEAT_S = 10.0
134
+
135
+
136
+ class Emitter:
137
+ """Serialize public events onto stdout with a single monotonic sequence.
138
+
139
+ Thread-safe: the main-thread lifecycle emits and the follower-thread activity
140
+ emits share one counter and one lock, so lines never interleave and sequence
141
+ numbers stay strictly increasing.
142
+ """
143
+
144
+ def __init__(self, run_id: str):
145
+ self.run_id = run_id
146
+ self.seq = 0
147
+ self.start_ts = time.time()
148
+ self._lock = threading.Lock()
149
+
150
+ def emit(self, type_: str, payload: dict, source: dict | None = None) -> None:
151
+ with self._lock:
152
+ self.seq += 1
153
+ event = {
154
+ "protocolVersion": "2",
155
+ "eventId": uuid.uuid4().hex,
156
+ "runId": self.run_id,
157
+ "sequence": self.seq,
158
+ "timestamp": dt.datetime.now(dt.timezone.utc).isoformat().replace("+00:00", "Z"),
159
+ "type": type_,
160
+ "source": source or {},
161
+ "payload": payload,
162
+ }
163
+ sys.stdout.write(json.dumps(event) + "\n")
164
+ sys.stdout.flush()
165
+
166
+
167
+ def fingerprint(title: str) -> str:
168
+ # Stable across runs so the control plane dedupes a recurring finding per application.
169
+ return "bh-" + hashlib.sha256((title or "").strip().lower().encode("utf-8")).hexdigest()[:24]
170
+
171
+
172
+ _EVIDENCE_RE = re.compile(r"Evidence:\s*\[([0-9,\s]+)\]")
173
+ MAX_RECIPE_OPS = 24 # bound a reproduction recipe (findings cite a handful of calls)
174
+
175
+
176
+ def reproduction_ops(con, body: str) -> list[dict]:
177
+ """Rebuild a finding's replayable recipe from the evidence it cited. A confirmed
178
+ finding embeds its supporting evidence ids in the body as 'Evidence: [id id ...]';
179
+ each id is an env_log row whose `request` is the exact {op, args} that was sent.
180
+ We fetch those requests in id order (= the order they were run) and keep op+args,
181
+ so a later retest can replay precisely the calls that proved the finding. Read-only
182
+ and best-effort: any parse/DB hiccup yields no recipe rather than raising."""
183
+ m = _EVIDENCE_RE.search(body or "")
184
+ if not m:
185
+ return []
186
+ ids = [int(tok) for tok in re.split(r"[,\s]+", m.group(1).strip()) if tok.isdigit()]
187
+ if not ids:
188
+ return []
189
+ ids = ids[:MAX_RECIPE_OPS]
190
+ placeholders = ",".join("?" * len(ids))
191
+ ops: list[dict] = []
192
+ try:
193
+ for (req,) in con.execute(
194
+ f"SELECT request FROM env_log WHERE id IN ({placeholders}) ORDER BY id", ids
195
+ ).fetchall():
196
+ try:
197
+ r = json.loads(req) if req else {}
198
+ except (json.JSONDecodeError, TypeError):
199
+ continue
200
+ if isinstance(r, dict) and r.get("op"):
201
+ ops.append({"op": str(r.get("op")), "args": r.get("args") if isinstance(r.get("args"), dict) else {}})
202
+ except sqlite3.Error:
203
+ return []
204
+ return ops
205
+
206
+
207
+ _CODE_RE = re.compile(r"\nCode:\s*(.+)")
208
+
209
+
210
+ def _source_from_body(body: str) -> dict | None:
211
+ """Extract a structured source location {file, line} from a finding's body.
212
+
213
+ A confirmed finding's body carries its code citations on a 'Code: path:line, ...'
214
+ line (team.go writes refs there). The control plane stores this as finding.source
215
+ and both the dashboard's 'Create patch' button and the remediate flow gate on
216
+ source.file -- without it the button is disabled and no patch can be generated.
217
+ We take the FIRST ref (unix paths have no ':', so the last ':<n>' is the line;
218
+ 'path:12-20' ranges keep the start line). Best-effort: None when absent/unparseable."""
219
+ m = _CODE_RE.search(body or "")
220
+ if not m or not m.group(1).strip():
221
+ return None
222
+ first = re.split(r"[,\s]+", m.group(1).strip())[0]
223
+ mm = re.match(r"^(.*):(\d+)(?:-\d+)?$", first)
224
+ if mm:
225
+ return {"file": mm.group(1), "line": int(mm.group(2))}
226
+ return {"file": first} if first else None
227
+
228
+
229
+ MAX_SNIPPET = 800 # bound every evidence/code snippet (sensitive data + report size)
230
+ _ALL_CODE_RE = re.compile(r"\nCode:\s*(.+)")
231
+
232
+
233
+ def _clip(s: str, n: int = MAX_SNIPPET) -> str:
234
+ s = s or ""
235
+ return s if len(s) <= n else s[:n] + " …[truncated]"
236
+
237
+
238
+ def _response_proof(resp_text: str) -> dict:
239
+ """A bounded, structured view of what a cited env call actually returned — the proof.
240
+ Read verbatim from env_log.response; never synthesized."""
241
+ out: dict = {}
242
+ try:
243
+ r = json.loads(resp_text) if resp_text else {}
244
+ except (json.JSONDecodeError, TypeError):
245
+ return {"snippet": _clip(str(resp_text))}
246
+ if isinstance(r, dict):
247
+ for k in ("status", "ok", "authenticated", "host", "path"):
248
+ if k in r:
249
+ out[k] = r[k]
250
+ body = r.get("body", r.get("text", r))
251
+ out["snippet"] = _clip(body if isinstance(body, str) else json.dumps(body, ensure_ascii=False))
252
+ else:
253
+ out["snippet"] = _clip(json.dumps(r, ensure_ascii=False))
254
+ return out
255
+
256
+
257
+ def evidence_ops(con, body: str) -> list[dict]:
258
+ """The cited env calls as {evidence_id, op, args, response} — request AND the real
259
+ response that proved the finding. Read verbatim from env_log; best-effort, no recipe
260
+ on any DB/parse hiccup rather than raising or guessing."""
261
+ m = _EVIDENCE_RE.search(body or "")
262
+ if not m:
263
+ return []
264
+ ids = [int(t) for t in re.split(r"[,\s]+", m.group(1).strip()) if t.isdigit()][:MAX_RECIPE_OPS]
265
+ if not ids:
266
+ return []
267
+ ph = ",".join("?" * len(ids))
268
+ ops: list[dict] = []
269
+ try:
270
+ for eid, req, resp in con.execute(
271
+ f"SELECT id, request, response FROM env_log WHERE id IN ({ph}) ORDER BY id", ids
272
+ ).fetchall():
273
+ try:
274
+ r = json.loads(req) if req else {}
275
+ except (json.JSONDecodeError, TypeError):
276
+ continue
277
+ if isinstance(r, dict) and r.get("op"):
278
+ ops.append({
279
+ "evidence_id": eid,
280
+ "op": str(r.get("op")),
281
+ "args": r.get("args") if isinstance(r.get("args"), dict) else {},
282
+ "response": _response_proof(resp),
283
+ })
284
+ except sqlite3.Error:
285
+ return []
286
+ return ops
287
+
288
+
289
+ def _read_code_snippet(checkout: str | None, file: str | None, line: int | None, ctx: int = 3) -> str:
290
+ """The actual source lines around a cited path:line, read from the checkout the harness
291
+ audited. Returns '' (never a guess) if the file/line is missing or escapes the checkout."""
292
+ if not checkout or not file:
293
+ return ""
294
+ try:
295
+ base = Path(checkout).resolve()
296
+ target = (base / file).resolve()
297
+ if base not in target.parents and target != base:
298
+ return "" # path-traversal guard: only inside the checkout
299
+ if not target.is_file():
300
+ return ""
301
+ text = target.read_text(errors="replace").splitlines()
302
+ if line and line > 0:
303
+ lo, hi = max(0, line - 1 - ctx), min(len(text), line + ctx)
304
+ return _clip("\n".join(f"{lo + 1 + i}: {text[lo + i]}" for i in range(hi - lo)))
305
+ return _clip("\n".join(text[: ctx * 2]))
306
+ except OSError:
307
+ return ""
308
+
309
+
310
+ def code_refs(body: str, checkout: str | None) -> tuple[list[dict], list[str]]:
311
+ """All cited code locations ('Code: a:12, b:30-40') as {file, line, snippet} plus their
312
+ 'file:line' strings. Snippets are read from the checkout; absent when unavailable."""
313
+ m = _ALL_CODE_RE.search(body or "")
314
+ if not m or not m.group(1).strip():
315
+ return [], []
316
+ refs: list[dict] = []
317
+ strs: list[str] = []
318
+ for tok in re.split(r"[,\s]+", m.group(1).strip())[:MAX_RECIPE_OPS]:
319
+ mm = re.match(r"^(.*?):(\d+)(?:-\d+)?$", tok)
320
+ if mm:
321
+ ref = {"file": mm.group(1), "line": int(mm.group(2))}
322
+ elif tok:
323
+ ref = {"file": tok}
324
+ else:
325
+ continue
326
+ strs.append(tok)
327
+ snip = _read_code_snippet(checkout, ref.get("file"), ref.get("line"))
328
+ if snip:
329
+ ref["snippet"] = snip
330
+ refs.append(ref)
331
+ return refs, strs
332
+
333
+
334
+ def parse_meta(raw: str | None) -> dict:
335
+ """A finding node's `meta` is a JSON object of the model-authored assessment
336
+ (severity, category, cwe, impact, remediation) that team.go writes for a
337
+ confirmed finding. Best-effort: a bad/absent value yields {}."""
338
+ if not raw:
339
+ return {}
340
+ try:
341
+ m = json.loads(raw)
342
+ return m if isinstance(m, dict) else {}
343
+ except (json.JSONDecodeError, TypeError):
344
+ return {}
345
+
346
+
347
+ # HTTP-family ops that name a request (path/method/host, like the `http` op's args).
348
+ _REQUEST_OPS = ("http", "login", "render", "oob")
349
+
350
+
351
+ def _request_url(origin: str, host: str | None, path: str) -> str:
352
+ """Absolute URL for a cited request. A recon-pivoted request carries its own `host`
353
+ (a subdomain), which takes precedence over the primary origin's host."""
354
+ if host:
355
+ scheme = (urlparse(origin).scheme or "https") if origin else "https"
356
+ sep = "" if path.startswith("/") else "/"
357
+ return f"{scheme}://{host}{sep}{path}"
358
+ if origin and path.startswith("/"):
359
+ return origin.rstrip("/") + path
360
+ return path
361
+
362
+
363
+ def endpoint_and_steps(ops: list[dict] | None, origin: str) -> tuple[dict | None, list[str]]:
364
+ """Derive the affected endpoint and human reproduction steps from the cited env ops.
365
+ Deterministic and evidence-derived (not guessed): the endpoint is the last concrete
366
+ request that names a path -- the call that demonstrates the issue. Honours a
367
+ recon-pivoted `host`, treats `login` as POST, and expands a `diff` op's sub-requests."""
368
+ steps: list[str] = []
369
+ endpoint: dict | None = None
370
+
371
+ def _request(name: str, args: dict) -> None:
372
+ nonlocal endpoint
373
+ path = args.get("path") if isinstance(args.get("path"), str) else None
374
+ host = args.get("host") if isinstance(args.get("host"), str) else None
375
+ who = args.get("identity") or args.get("as")
376
+ if path:
377
+ method = str(args.get("method") or ("POST" if name == "login" else "GET")).upper()
378
+ label = (host + path) if host else path
379
+ step = f"{method} {label}" + (f" (as {who})" if who else "")
380
+ steps.append(step)
381
+ endpoint = {"method": method, "path": path, "url": _request_url(origin, host, path)}
382
+ elif name == "login" and who:
383
+ steps.append(f"Sign in as {who}")
384
+ else:
385
+ subject = args.get("subject") or who or ""
386
+ steps.append(f"{name} {subject}".strip())
387
+
388
+ for op in ops or []:
389
+ name = str(op.get("op") or "")
390
+ if not name:
391
+ continue
392
+ args = op.get("args") if isinstance(op.get("args"), dict) else {}
393
+ if name == "diff" and isinstance(args.get("requests"), list):
394
+ for sub in args["requests"]: # a differential oracle runs 2+ http-shaped requests
395
+ if isinstance(sub, dict):
396
+ _request("http", sub)
397
+ elif name in _REQUEST_OPS:
398
+ _request(name, args)
399
+ else:
400
+ subject = args.get("subject") or args.get("identity") or args.get("as") or ""
401
+ steps.append(f"{name} {subject}".strip())
402
+ return endpoint, steps
403
+
404
+
405
+ def finding_payload(title: str, body: str, ops: list[dict] | None = None,
406
+ fingerprint_override: str | None = None, verification: str | None = None,
407
+ meta: dict | None = None, origin: str = "",
408
+ con=None, checkout: str | None = None) -> dict:
409
+ title = (title or "Untitled finding").strip()
410
+ body = (body or "").strip()
411
+ meta = meta or {}
412
+ # With a DB connection, build the concrete, verbatim proof the finding cited: each env
413
+ # call's request AND response, and every code reference (with a snippet from the audited
414
+ # checkout). All read from the run's own records -- nothing is generated. Without a
415
+ # connection (unit tests / callers that pass ops directly), fall back to the given ops.
416
+ code_ref_list: list[dict] = []
417
+ ref_strings: list[str] = []
418
+ if con is not None:
419
+ ops = evidence_ops(con, body)
420
+ code_ref_list, ref_strings = code_refs(body, checkout)
421
+ payload = {
422
+ # On a retest we re-emit under the ORIGINAL finding's fingerprint so the control
423
+ # plane reconciles identity (fingerprint = sha256(title), but the retest title may
424
+ # paraphrase, so the adapter passes the matched original's fingerprint explicitly).
425
+ "fingerprint": fingerprint_override or fingerprint(title),
426
+ "title": title[:300],
427
+ "verification": verification or "unverified", # 'reproduced' when re-confirmed on a retest
428
+ "confidence": "high", # emitted only for harness-confirmed findings
429
+ "description": body[:4000],
430
+ "evidence": body[:2000],
431
+ }
432
+ # The assessment the model authored for this finding (team.go gates a confirmed
433
+ # finding on it). Each field is passed through only when present -- never defaulted
434
+ # to a guessed severity/category, so an unrated finding stays honestly unrated.
435
+ for key in ("severity", "category", "cwe", "impact", "remediation"):
436
+ val = meta.get(key)
437
+ if isinstance(val, str) and val.strip():
438
+ payload[key] = val.strip()
439
+ endpoint, steps = endpoint_and_steps(ops, origin)
440
+ if endpoint:
441
+ payload["endpoint"] = endpoint
442
+ if steps:
443
+ payload["steps"] = steps[:MAX_RECIPE_OPS]
444
+ if ops or code_ref_list:
445
+ # The replayable recipe + proof: the cited env ops (request + the real response
446
+ # each returned) and the cited code locations with snippets. A retest replays the
447
+ # ops; the report/chat/MCP surface the responses + code as concrete evidence.
448
+ repro: dict = {}
449
+ if ops:
450
+ repro["ops"] = ops
451
+ if code_ref_list:
452
+ repro["codeRefs"] = code_ref_list
453
+ payload["reproduction"] = repro
454
+ if ref_strings:
455
+ payload["evidenceRefs"] = ref_strings
456
+ src = _source_from_body(body)
457
+ if src and src.get("file"):
458
+ # Structured source location (file[:line]) parsed from the finding's code
459
+ # citations, so the dashboard can offer 'Create patch' and the remediate flow
460
+ # knows which file to read. Absent when the finding cited no path:line.
461
+ payload["source"] = src
462
+ return payload
463
+
464
+
465
+ def sign_in_test_accounts(spec: dict, em: "Emitter") -> dict:
466
+ """Sign every username-and-password test account in, and report each outcome as an
467
+ identity.authenticated event (the dashboard shows whether the account works). Returns
468
+ {identity id: {cookie, headers} | {error}} for the agents' environment processes
469
+ (prod_env.load_shared_sessions). Credentials never leave prod_env."""
470
+ import prod_env # sibling module; imported here so other run kinds don't load it
471
+
472
+ try:
473
+ target = prod_env.Target(spec, prod_env.load_secrets())
474
+ except SystemExit: # no origin to sign in to
475
+ return {}
476
+ shared: dict[str, dict] = {}
477
+ for iid, ident in target.identities.items():
478
+ if not prod_env.is_password_account(ident):
479
+ continue
480
+ result = prod_env.sign_in(target, iid)
481
+ ok = bool(result.get("authenticated"))
482
+ em.emit("identity.authenticated", {
483
+ "identityId": iid,
484
+ "ok": ok,
485
+ "status": result.get("status"),
486
+ "detail": str(result.get("note") or result.get("error") or "")[:500],
487
+ })
488
+ shared[iid] = ({"cookie": target.sessions.get(iid, ""), "headers": target.session_headers.get(iid, {})}
489
+ if ok else {"error": str(result.get("error") or "the sign-in failed")})
490
+ return shared
491
+
492
+
493
+ def run_prescan(spec_path: str, session: Path, tier: str) -> dict | None:
494
+ """Deterministic posture pre-pass over the target's public surface (security
495
+ headers, cookie flags, TLS, exposed paths, version->CVE) via profile.py. The
496
+ output profile.json is consumed by prod_env.py to STEER the agents toward the
497
+ signals it found (BLACKGLASS_PROFILE), and returned here to carry the coverage +
498
+ posture observations into the report. Best-effort: any failure or timeout disables
499
+ it without blocking the scan, and it never emits findings itself (signals, not
500
+ verdicts -- the agents confirm and the model assesses anything that is a real
501
+ vulnerability, so nothing is reported on a hardcoded heuristic)."""
502
+ out = session / "profile.json"
503
+ try:
504
+ subprocess.run(
505
+ [sys.executable, PROFILE_PY, "--spec", spec_path, "--out", str(out), "--tier", tier],
506
+ stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, timeout=120, check=False,
507
+ )
508
+ except (subprocess.SubprocessError, OSError):
509
+ return None
510
+ if not out.exists():
511
+ return None
512
+ try:
513
+ data = json.loads(out.read_text())
514
+ return data if isinstance(data, dict) else None
515
+ except (json.JSONDecodeError, OSError):
516
+ return None
517
+
518
+
519
+ def write_report(session: Path, run_id: str, spec: dict, origin: str, scan_name: str,
520
+ report_model: str, recs: list, prescan: dict | None, started_ts: float) -> None:
521
+ """Assemble the customer report and write it to the session dir as report.md, which
522
+ the control plane serves at GET /api/runs/:id/report. Deterministic sections plus an
523
+ isolated LLM-authored executive summary (best-effort). Never raises: a report problem
524
+ must not fail the scan."""
525
+ try:
526
+ import report as _report
527
+ try:
528
+ import remediate as _rem # reused: isolated OpenRouter chat + key loader
529
+ except Exception: # noqa: BLE001
530
+ _rem = None
531
+ views = [payload for _fid, _title, payload in recs]
532
+ idents = [str(i.get("id") or i.get("kind")) for i in (spec.get("identities") or [])
533
+ if isinstance(i, dict) and (i.get("id") or i.get("kind"))]
534
+ run_meta = {
535
+ "target": origin or (spec.get("target") or {}).get("name") or "the target application",
536
+ "scanProfile": scan_name,
537
+ "runId": run_id,
538
+ "hosts": (spec.get("scope") or {}).get("allowedHosts") or [],
539
+ "actionTier": (spec.get("policy") or {}).get("actionTier") or "passive (read-only)",
540
+ "identities": idents,
541
+ "startedAt": time.strftime("%Y-%m-%d", time.gmtime(started_ts)),
542
+ "durationMin": max(0.0, (time.time() - started_ts) / 60.0),
543
+ }
544
+ key = os.environ.get("OPENROUTER_API_KEY") or ""
545
+ if not key and _rem is not None and os.path.exists(KEY_FILE):
546
+ try:
547
+ key = _rem.load_key(KEY_FILE)
548
+ except Exception: # noqa: BLE001
549
+ key = ""
550
+ call = _rem.call_model if _rem is not None else None
551
+ # LLM-authored executive summary: try the configured report model, then fall back
552
+ # to the base model (proven via the gateway) before the deterministic summary.
553
+ # Failures are logged to stderr (harness.log) so a silent fallback is diagnosable.
554
+ log = lambda msg: sys.stderr.write(f"[report] {msg}\n") # noqa: E731
555
+ narrative = None
556
+ for m in dict.fromkeys([report_model, DEFAULT_MODEL]): # ordered, de-duplicated
557
+ if not m:
558
+ continue
559
+ narrative = _report.llm_summary(views, prescan, run_meta, key or None, m, call=call, log=log)
560
+ if narrative:
561
+ break
562
+ if not narrative:
563
+ narrative = _report._fallback_summary(views, prescan)
564
+ (session / "report.md").write_text(
565
+ _report.build_report(run_meta=run_meta, findings=views, prescan=prescan, narrative=narrative))
566
+ except Exception as exc: # noqa: BLE001 — report generation is best-effort
567
+ sys.stderr.write(f"report generation skipped: {exc}\n")
568
+
569
+
570
+ def map_event(kind: str, agent: str, data: dict):
571
+ """Map one harness `events` row to a Blackglass v2 public-event (type, payload, source).
572
+
573
+ Emits only flat, allowlisted keys (sanitizePublicEvent projects to the per-type
574
+ allowlist). Noisy/internal kinds (agent.msg, llm.*, node.add, board.*, tool.output)
575
+ are dropped to keep the timeline legible.
576
+ """
577
+ src = {"agentId": agent} if agent else {}
578
+ if kind == "agent.start":
579
+ return "agent.started", {
580
+ "id": agent, "agentType": data.get("role"), "status": "running",
581
+ "model": data.get("model"), "requestedModel": data.get("model"),
582
+ }, src
583
+ if kind == "agent.end":
584
+ return "agent.completed", {
585
+ "id": agent, "agentType": data.get("role"), "status": "completed",
586
+ "summary": str(data.get("reason") or data.get("finish") or ""),
587
+ }, src
588
+ if kind == "tool.start":
589
+ return "activity.recorded", {"tool": data.get("name"), "outcome": "started", "callId": data.get("call")}, src
590
+ if kind == "tool.end":
591
+ return "activity.recorded", {
592
+ "tool": data.get("name"), "outcome": "ok",
593
+ "callId": data.get("call"), "durationMs": data.get("ms"),
594
+ }, src
595
+ if kind == "env.call":
596
+ # data = {id, request:{op,args}, response:{...}}. A `login` against a NAMED test
597
+ # identity becomes a first-class sign-in-outcome event so the control plane can
598
+ # record whether that credential actually worked (the only auth that matters is
599
+ # the one exercised in the real scan context).
600
+ req = data.get("request") if isinstance(data.get("request"), dict) else {}
601
+ resp = data.get("response") if isinstance(data.get("response"), dict) else {}
602
+ op = str((req.get("op") if isinstance(req, dict) else None) or "request")
603
+ if op == "login":
604
+ iargs = req.get("args") if isinstance(req.get("args"), dict) else {}
605
+ identity = iargs.get("identity") or iargs.get("as")
606
+ if identity:
607
+ return "identity.authenticated", {
608
+ "identityId": str(identity),
609
+ "ok": bool(resp.get("authenticated")),
610
+ "status": resp.get("status"),
611
+ "detail": str(resp.get("note") or resp.get("error") or "")[:500],
612
+ }, src
613
+ return "activity.recorded", {"tool": "http", "outcome": "ok", "summary": op}, src
614
+ if kind in ("round.phase", "round.start", "round.gather", "round.digest", "cell.start"):
615
+ return "phase.changed", {"phase": str(data.get("phase") or kind.split(".")[-1])}, None
616
+ return None
617
+
618
+
619
+ class Follower(threading.Thread):
620
+ """Tail the harness state DB live: map `events` rows to activity events and stream
621
+ newly-confirmed findings, emitting heartbeats so the dashboard timer advances."""
622
+
623
+ def __init__(self, db: str, em: Emitter, emitted_findings: set[int], emit_findings: bool = True,
624
+ origin: str = "", checkout: str | None = None):
625
+ super().__init__(daemon=True)
626
+ self.db = db
627
+ self.em = em
628
+ self.emitted = emitted_findings
629
+ self.origin = origin # for deriving each finding's endpoint URL from its cited ops
630
+ self.checkout = checkout # source tree, for reading cited code snippets into findings
631
+ # A retest run suppresses live finding emission: findings are matched back to the
632
+ # originals and re-emitted (with the original fingerprint) in the final reconcile.
633
+ self.emit_findings = emit_findings
634
+ self._stopped = threading.Event() # NB: not `_stop` -- that shadows Thread._stop
635
+
636
+ def stop(self) -> None:
637
+ self._stopped.set()
638
+
639
+ def _connect(self):
640
+ # Open with mode=rw (uri), NOT a plain connect: a plain connect would CREATE the
641
+ # db file, and the harness refuses to start if its -db path already exists -- so
642
+ # the follower must never create it. mode=rw also reads WAL correctly (mode=ro
643
+ # cannot, as it can't create the -wal/-shm sidecars). query_only guards writes.
644
+ uri = f"file:{self.db}?mode=rw"
645
+ for _ in range(120): # wait for the harness to create the DB after spawn
646
+ try:
647
+ con = sqlite3.connect(uri, uri=True, timeout=2)
648
+ con.execute("PRAGMA query_only=1")
649
+ if con.execute("SELECT 1 FROM sqlite_master WHERE type='table' AND name='events'").fetchone():
650
+ return con
651
+ con.close()
652
+ except sqlite3.Error:
653
+ pass
654
+ if self._stopped.is_set():
655
+ break
656
+ time.sleep(0.3)
657
+ return None
658
+
659
+ def run(self) -> None:
660
+ con = self._connect()
661
+ if con is None:
662
+ return
663
+ last_seq = 0
664
+ last_hb = time.time()
665
+ draining = False
666
+ try:
667
+ while True:
668
+ try:
669
+ for seq, kind, agent, data in con.execute(
670
+ "SELECT seq, kind, agent, CAST(data AS TEXT) FROM events WHERE seq > ? ORDER BY seq",
671
+ (last_seq,),
672
+ ).fetchall():
673
+ last_seq = seq
674
+ try:
675
+ d = json.loads(data) if data else {}
676
+ except json.JSONDecodeError:
677
+ d = {}
678
+ mapped = map_event(kind, agent or "", d if isinstance(d, dict) else {})
679
+ if mapped:
680
+ self.em.emit(*mapped)
681
+ if self.emit_findings:
682
+ for fid, title, body, meta in con.execute(
683
+ "SELECT id, title, body, coalesce(meta,'') FROM nodes WHERE kind='finding' AND status='confirmed' ORDER BY id"
684
+ ).fetchall():
685
+ if fid not in self.emitted:
686
+ self.emitted.add(fid)
687
+ self.em.emit("finding.created", finding_payload(
688
+ title, body, meta=parse_meta(meta), origin=self.origin,
689
+ con=con, checkout=self.checkout))
690
+ except sqlite3.Error:
691
+ pass
692
+ now = time.time()
693
+ if now - last_hb >= HEARTBEAT_S:
694
+ self.em.emit("heartbeat", {"uptimeMs": int((now - self.em.start_ts) * 1000)})
695
+ last_hb = now
696
+ if draining:
697
+ break
698
+ if self._stopped.is_set():
699
+ draining = True # one more pass drains the tail before exiting
700
+ time.sleep(0.6)
701
+ finally:
702
+ con.close()
703
+
704
+
705
+ CHECKLISTS = Path(__file__).resolve().parent / "checklists"
706
+
707
+
708
+ RECON_ENABLED = os.environ.get("BASIC_HARNESS_RECON", "1") != "0"
709
+
710
+
711
+ def _coverage_floor() -> str:
712
+ """Render the checklist as a COVERAGE FLOOR appended to the brief.
713
+
714
+ This is deliberately framed as a floor, not a cage: it guarantees the standard
715
+ external attack surfaces are not skipped, while leaving the orchestrator free to
716
+ open novel directions beyond the list. The `web` surface is reachable through the
717
+ http op; the `recon` surface is included only when recon ops are enabled (Phase 2),
718
+ so the agent is never pointed at a capability it cannot call. Opt-in items (e.g.
719
+ token-gated code search) are omitted from the floor.
720
+ """
721
+ try:
722
+ data = json.loads((CHECKLISTS / "anonymous.json").read_text())
723
+ except (OSError, json.JSONDecodeError):
724
+ return ""
725
+ surfaces = data.get("surfaces") or {}
726
+ items = [i for i in surfaces.get("web", []) if not i.get("opt_in")]
727
+ if RECON_ENABLED:
728
+ items += [i for i in surfaces.get("recon", []) if not i.get("opt_in")]
729
+ floor = ""
730
+ if items:
731
+ lines = [f"- {i['title']}: {i['hint']}" for i in items]
732
+ floor = (
733
+ "\n\nCOVERAGE FLOOR (consider ALL of these surfaces; this is a floor, not a limit -- "
734
+ "pursue any other promising direction you find, and do NOT let the run orbit only this list):\n"
735
+ + "\n".join(lines)
736
+ )
737
+ return floor + _skill_index()
738
+
739
+
740
+ def _skill_index() -> str:
741
+ """Advertise the on-demand deep playbooks (skill library). The index (name +
742
+ when-to-use) is cheap always-on context; the prober loads a full playbook with
743
+ the get_skill op only when a direction matches. Breadth stays with the coverage
744
+ floor above; skills add depth on demand (and the two will converge as more
745
+ surfaces become skills)."""
746
+ try:
747
+ import skills # sibling module
748
+ idx = skills.render_index()
749
+ except Exception: # noqa: BLE001 - never let the skill index break the brief
750
+ return ""
751
+ if not idx:
752
+ return ""
753
+ return ("\n\nDEEP PLAYBOOKS (load with the get_skill op): when a direction matches a playbook's "
754
+ "trigger below (e.g. GraphQL, IDOR, subdomain/takeover), you MUST FIRST call "
755
+ "get_skill with that playbook's name and follow its methodology before probing that "
756
+ "surface. These are detailed procedures, not a checklist:\n" + idx)
757
+
758
+
759
+ def build_source_audit_brief(has_identities: bool) -> str:
760
+ """Mission for a SOURCE-ONLY (grey/white-box) target: a repository is connected but
761
+ there is NO reachable live host. The evidence is the code itself, so the audit is
762
+ driven through the reader (ask_reader -> list_files/grep/read_file), NOT live recon
763
+ against a placeholder host. Findings cite path:line, which the harness checks and the
764
+ patch flow consumes. Without this branch the brief points the team at a nonexistent
765
+ origin and the whole run finds nothing (see the 2026-10-30 zero-finding incident)."""
766
+ roles = ("You also have named application identities/roles (see list_identities): reason about "
767
+ "what each role can reach in the code versus what it should, and flag missing or broken "
768
+ "authorization checks.\n\n") if has_identities else ""
769
+ return (
770
+ "Perform a thorough SECURITY CODE AUDIT of the connected source repository. There is NO "
771
+ "running instance and NO reachable host -- do NOT attempt HTTP requests or external "
772
+ "reconnaissance; there is nothing to connect to and a probe only wastes the budget. Your "
773
+ "evidence is the SOURCE CODE itself.\n\n"
774
+ "Work through the reader (ask_reader -> list_files, grep, read_file). First MAP the app: its "
775
+ "entry points (routes/handlers/controllers/CLI), how untrusted input enters, how it flows to "
776
+ "dangerous sinks (SQL, shell/command, filesystem paths, deserialization, templates, HTTP "
777
+ "responses, redirects), and where authentication, authorization, and secret handling live. "
778
+ "Then HUNT for concrete vulnerabilities and trace each one from the untrusted source to the "
779
+ "sink.\n\n" + roles +
780
+ "Cover at least these classes: injection (SQLi, OS command, SSTI, path traversal, unsafe "
781
+ "deserialization), broken authentication / session handling, broken access control / IDOR / "
782
+ "missing authorization, SSRF, XSS, insecure or missing crypto, hardcoded secrets or "
783
+ "credentials, sensitive-data exposure, unsafe file upload/handling, open redirect, and "
784
+ "insecure configuration.\n\n"
785
+ "Report EACH distinct vulnerability you can substantiate with record_finding: name the "
786
+ "vulnerability class, cite the exact code location as path:line for every claim (the lines you "
787
+ "actually read), describe the source->sink data flow that makes it exploitable, and give the "
788
+ "concrete impact. Before confirming, follow the real code path and check for guards or "
789
+ "validation that would make it a false positive. Be exhaustive -- enumerate ALL the distinct "
790
+ "issues you can prove, not just the first few."
791
+ ) + _skill_index()
792
+
793
+
794
+ def build_brief(spec: dict) -> str:
795
+ target_obj = spec.get("target") or {}
796
+ live_origins = target_obj.get("origins") or []
797
+ has_source = bool((target_obj.get("repository") or {}).get("checkoutPath"))
798
+ has_identities = bool(spec.get("identities"))
799
+
800
+ if spec.get("prompt"):
801
+ # Honor an explicit operator brief as the primary mission; still append the
802
+ # coverage floor so the wide net applies even to a custom-scoped run.
803
+ return str(spec["prompt"]) + _coverage_floor()
804
+
805
+ # Source-only (grey/white-box): a repo is connected but there is no reachable live
806
+ # origin (scope.allowedHosts carries only a sentinel like 'source-only.invalid').
807
+ # Audit the CODE through the reader rather than probing a host that does not exist.
808
+ if has_source and not live_origins:
809
+ return build_source_audit_brief(has_identities)
810
+
811
+ origins = live_origins or (spec.get("scope") or {}).get("allowedHosts") or []
812
+ target = origins[0] if origins else "the target application"
813
+
814
+ if has_identities:
815
+ mission = (
816
+ f"Assess {target} for security vulnerabilities and data exposure. You have named "
817
+ "identities (see list_identities): compare what each role CAN reach against what it "
818
+ "SHOULD, and report each demonstrated access-control / over-exposure violation with the "
819
+ "acting identity, the exact request, and the proving response. Identities are one lever, "
820
+ "not the whole job -- also probe the anonymous (unauthenticated) surface aggressively."
821
+ )
822
+ else:
823
+ # No credentials provisioned: this is the common case (most targets are anonymous
824
+ # by default). Pivot hard to the external-attacker surface instead of stalling on
825
+ # the absence of identities.
826
+ mission = (
827
+ f"Assess {target} as an external, unauthenticated attacker with NO credentials. This is "
828
+ "the normal starting posture -- do NOT treat the absence of identities as a blocker or an "
829
+ "excuse to stop; most real exposure is reachable anonymously. Hunt relentlessly across the "
830
+ "reachable surface and report each demonstrated vulnerability or data exposure with the "
831
+ "exact request and the proving response. External RECONNAISSANCE is REQUIRED, not optional: "
832
+ "you MUST use the recon ops to enumerate the verified root domain -- crt and dns for "
833
+ "subdomains and dangling/takeover records, storage for public buckets, registries for "
834
+ "public artifacts -- and then pivot http to the hosts you discover. Do not stop at the "
835
+ "primary origin."
836
+ )
837
+
838
+ grey = ""
839
+ if has_source:
840
+ # Grey-box: a live host AND a source checkout. Point the team at the reader so it
841
+ # corroborates what it sees over HTTP against the actual code (and finds issues the
842
+ # black-box surface hides), citing path:line.
843
+ grey = (" A READ-ONLY SOURCE CHECKOUT of this application is also connected: use ask_reader "
844
+ "(the reader reads the code and cites path:line) both to find vulnerabilities in the "
845
+ "code and to confirm or refute what you observe over HTTP -- corroborate every finding "
846
+ "against the actual code path.")
847
+ return mission + " This is a READ-ONLY assessment (GET/HEAD only)." + grey + _coverage_floor()
848
+
849
+
850
+ def build_retest_brief(findings: list[dict]) -> str:
851
+ """A constrained brief for a RETEST run: re-verify each previously-confirmed finding
852
+ and judge only whether it still holds. No open-ended hunting -- the harness's validator
853
+ does the anti-false-positive work. Each finding is re-tested by the method it supports:
854
+ a finding with recorded env ops replays them; a CODE finding (a source location, no env
855
+ ops -- e.g. a source-only target, or a fix we just opened a PR for) is re-verified by
856
+ re-reading the cited code in the CURRENT checkout, which after a merge reflects the fix."""
857
+ lines = []
858
+ for i, f in enumerate(findings, 1):
859
+ title = (f.get("title") or "").strip()
860
+ desc = (f.get("description") or "").strip()
861
+ ops = (f.get("reproduction") or {}).get("ops") or []
862
+ src = f.get("source") or {}
863
+ if ops:
864
+ steps = "; ".join(
865
+ f"{o.get('op')} {json.dumps(o.get('args') or {}, separators=(',', ':'))}" for o in ops
866
+ )
867
+ how = f"RE-TEST (env): re-run these steps -- {steps}"
868
+ elif src.get("file"):
869
+ loc = str(src["file"]) + (f":{src['line']}" if src.get("line") else "")
870
+ how = (f"RE-TEST (code): this is a code finding at {loc}. Re-read that code in the CURRENT "
871
+ "checkout with the reader (ask_reader -> list_files/grep/read_file) and decide whether "
872
+ "the vulnerability is still present or has been fixed (input now validated/escaped, the "
873
+ "sink guarded, or the vulnerable path removed).")
874
+ else:
875
+ how = "RE-TEST: reconstruct the check from the description above."
876
+ lines.append(f"{i}. TITLE: {title}\n WHAT IT WAS: {desc[:500]}\n {how}")
877
+ listing = "\n".join(lines)
878
+ return (
879
+ "RE-TEST MODE. The findings below were CONFIRMED on a previous assessment of this exact "
880
+ "target. Your ONLY job is to re-verify each one as-is; do NOT hunt for new issues.\n\n"
881
+ "For EACH finding, re-test it by the method noted (re-run the env steps, or re-read the cited "
882
+ "source in the current checkout), then judge from what you observe:\n"
883
+ "- If it STILL holds (the insecure behavior still happens, or the vulnerable code is still "
884
+ "present), record a finding with verdict CONFIRMED, using the EXACT title shown.\n"
885
+ "- If it appears FIXED (the behavior no longer occurs -- blocked/403/404, the leaked data is "
886
+ "gone, the mutation refused -- or the code now validates/guards the input or the vulnerable path "
887
+ "is gone), record verdict REFUTED with that title.\n"
888
+ "Cite the exact request and response, or the path:line, that shows the current behavior.\n\n"
889
+ f"FINDINGS TO RE-TEST:\n{listing}"
890
+ )
891
+
892
+
893
+ def _match_original(title: str, findings: list[dict]) -> dict | None:
894
+ """Map a re-tested finding's (possibly paraphrased) title back to the original it
895
+ re-verifies: exact (case-insensitive) match first, then substring, then best token
896
+ overlap above a threshold. Returns the original finding dict or None."""
897
+ t = (title or "").strip().lower()
898
+ if not t:
899
+ return None
900
+ for f in findings:
901
+ if (f.get("title") or "").strip().lower() == t:
902
+ return f
903
+ tw = set(re.findall(r"[a-z0-9]+", t))
904
+ best, best_score = None, 0.0
905
+ for f in findings:
906
+ ft = (f.get("title") or "").strip().lower()
907
+ if not ft:
908
+ continue
909
+ if t in ft or ft in t:
910
+ return f
911
+ fw = set(re.findall(r"[a-z0-9]+", ft))
912
+ if tw and fw:
913
+ score = len(tw & fw) / len(tw | fw)
914
+ if score > best_score:
915
+ best, best_score = f, score
916
+ return best if best_score >= 0.6 else None
917
+
918
+
919
+ def _run_remediate(spec: dict, em: "Emitter") -> int:
920
+ """Generate a code patch for ONE finding -- no harness run. Reads the finding's source
921
+ from the connected repo checkout, asks the model for a minimal unified diff, validates
922
+ it applies, and emits the patch on the finding; the control plane opens the PR."""
923
+ finding = (spec.get("remediate") or {}).get("finding") or {}
924
+ fp = finding.get("fingerprint")
925
+ checkout = ((spec.get("target") or {}).get("repository") or {}).get("checkoutPath")
926
+ if not fp or not (finding.get("source") or {}).get("file"):
927
+ em.emit("run.completed", {"stop": "halt", "findings": 0, "ok": True,
928
+ "message": "finding has no source location to patch"})
929
+ return 0
930
+ if not checkout:
931
+ em.emit("run.failed", {"category": "runtime",
932
+ "message": "no source repository is connected; connect a repo to generate patches",
933
+ "stop": "error"})
934
+ return 1
935
+ import remediate # stdlib-only isolated patch generator; imported only for this kind
936
+ key = remediate.load_key(KEY_FILE)
937
+ gen_model = spec.get("model") or DEFAULT_REMEDIATE_MODEL
938
+ em.emit("phase.changed", {"phase": "remediating"})
939
+ patch = remediate.generate_patch(
940
+ checkout, finding, key, gen_model,
941
+ reviewer_model=DEFAULT_REMEDIATE_REVIEWER,
942
+ candidates=DEFAULT_REMEDIATE_CANDIDATES,
943
+ )
944
+ em.emit("finding.updated", {"fingerprint": fp, "title": finding.get("title") or "Finding", "patch": patch})
945
+ ok = patch.get("status") != "failed"
946
+ verified = patch.get("verified")
947
+ notes = (patch.get("review") or {}).get("notes") or ""
948
+ if not ok:
949
+ message = f"patch generation failed: {patch.get('error', '')}"
950
+ elif not patch.get("applies"):
951
+ message = "patch generated but did not apply cleanly; review the diff"
952
+ elif verified is True:
953
+ message = f"patch generated, reviewed, and applies cleanly ({patch.get('strategy', 'diff')})"
954
+ elif verified is False:
955
+ message = f"patch applies but the reviewer flagged it — review before merging{(': ' + notes) if notes else ''}"
956
+ else:
957
+ message = "patch generated and applies cleanly"
958
+ em.emit("run.completed", {"stop": "halt", "findings": 1 if ok else 0, "ok": True, "message": message})
959
+ return 0
960
+
961
+
962
+ def main() -> int:
963
+ ap = argparse.ArgumentParser()
964
+ ap.add_argument("--spec", required=True)
965
+ ap.add_argument("--session-dir", required=True)
966
+ ap.add_argument("--events", default="stdout")
967
+ a = ap.parse_args()
968
+
969
+ spec = json.loads(Path(a.spec).read_text())
970
+ run_id = spec.get("runId") or "unknown"
971
+ em = Emitter(run_id)
972
+ session = Path(a.session_dir)
973
+ session.mkdir(parents=True, exist_ok=True)
974
+
975
+ # Policy gate: this runner is non-destructive only.
976
+ if bool((spec.get("policy") or {}).get("destructiveActions", False)):
977
+ em.emit("run.failed", {"category": "policy",
978
+ "message": "basic-harness runner is read-only; destructiveActions must be false",
979
+ "stop": "policy"})
980
+ return 3
981
+
982
+ # Egress gate (fail-closed): a RunSpec may require full network confinement.
983
+ # This adapter cannot confine itself, so unless the host signals it provides the
984
+ # jail (BLACKGLASS_EGRESS_CONFINED=1, set by a provisioned confined runner), we
985
+ # refuse rather than reach the target unconfined. 'best_effort' (local dev) runs.
986
+ if (spec.get("egress") or {}).get("isolation") == "required" and os.environ.get("BLACKGLASS_EGRESS_CONFINED") != "1":
987
+ em.emit("run.failed", {"category": "policy",
988
+ "message": ("egress isolation 'required' but host confinement is not signaled "
989
+ "(set BLACKGLASS_EGRESS_CONFINED=1 on a confined runner); refusing to run unconfined"),
990
+ "stop": "policy"})
991
+ return 3
992
+
993
+ origins = (spec.get("target") or {}).get("origins") or []
994
+ hosts = (spec.get("scope") or {}).get("allowedHosts") or []
995
+ # The run's primary origin, used to build each finding's absolute endpoint URL.
996
+ origin = origins[0] if origins else (hosts[0] if hosts else "")
997
+ profile = (spec.get("policy") or {}).get("profile", "safe")
998
+ isolation = (spec.get("egress") or {}).get("isolation", "best_effort")
999
+ em.emit("run.started", {
1000
+ "runId": run_id, "origin": origin,
1001
+ "hosts": hosts, "packs": spec.get("packs") or [], "profile": profile,
1002
+ "isolation": isolation, "resumed": False, "ok": True,
1003
+ })
1004
+ em.emit("phase.changed", {"phase": "investigating"})
1005
+
1006
+ # Run kind: 'retest' re-verifies known findings (a constrained brief + fewer rounds)
1007
+ # instead of the open-ended hunt; 'remediate' generates a patch for one finding (no
1008
+ # harness run at all -- an isolated model call over the checkout); 'assess' (default)
1009
+ # is the normal assessment.
1010
+ kind = str(spec.get("kind") or "assess")
1011
+ if kind == "remediate":
1012
+ return _run_remediate(spec, em)
1013
+ retest_findings = (spec.get("retest") or {}).get("findings") or [] if kind == "retest" else []
1014
+ if kind == "retest" and not retest_findings:
1015
+ em.emit("run.completed", {"stop": "halt", "findings": 0, "ok": True,
1016
+ "message": "no findings with a reproduction recipe to retest"})
1017
+ return 0
1018
+ goal = build_retest_brief(retest_findings) if kind == "retest" else build_brief(spec)
1019
+ rounds = (os.environ.get("BASIC_HARNESS_RETEST_ROUNDS", "1") if kind == "retest"
1020
+ else os.environ.get("BASIC_HARNESS_ROUNDS", "3"))
1021
+
1022
+ # Scan depth profile (PR = fast/cheap gate, Full = deep audit): picks model
1023
+ # placement, whether the council runs, breadth, and the cost backstop.
1024
+ scan_name, prof = scan_profile_for(spec)
1025
+
1026
+ # A PR scan scopes to the pull request's diff when a base ref is available, so the
1027
+ # fast gate reviews the change + its blast radius rather than the whole repo.
1028
+ if scan_name == "pr" and kind == "assess":
1029
+ _repo = (spec.get("target") or {}).get("repository") or {}
1030
+ _base, _checkout = _repo.get("baseCommit"), _repo.get("checkoutPath")
1031
+ if _base and _checkout:
1032
+ _files = _pr_changed_files(str(_checkout), str(_base))
1033
+ if _files:
1034
+ goal = goal + _pr_scope_text(_files)
1035
+
1036
+ # Wall-clock budget is the PRIMARY stop condition (call/cost caps are off by default
1037
+ # below). Prefer the RunSpec's policy.maxDurationMinutes; otherwise fall back to the
1038
+ # profile's default (PR ~10 min, Full ~45 min). The harness enforces this itself via
1039
+ # -max-time and stops GRACEFULLY at the deadline, reporting what it found; the external
1040
+ # proc timeout further down is only a backstop, set a little longer so it wins.
1041
+ policy_minutes = (spec.get("policy") or {}).get("maxDurationMinutes")
1042
+ max_minutes = (float(policy_minutes) if isinstance(policy_minutes, (int, float)) and policy_minutes > 0
1043
+ else float(os.environ.get("BASIC_HARNESS_MAX_TIME_MIN", str(prof["default_minutes"]))))
1044
+
1045
+ # Posture pre-pass: on an assessment with a reachable origin, profile the public
1046
+ # surface before the hunt. Its signals steer the agents (BLACKGLASS_PROFILE, consumed
1047
+ # by prod_env) and its coverage + posture observations go into the report. Disabled by
1048
+ # BASIC_HARNESS_PRESCAN=0; skipped for source-only (no origin) and retest/remediate.
1049
+ prescan: dict | None = None
1050
+ if (kind == "assess" and bool((spec.get("target") or {}).get("origins"))
1051
+ and os.environ.get("BASIC_HARNESS_PRESCAN", "1") != "0"):
1052
+ em.emit("phase.changed", {"phase": "profiling"})
1053
+ tier = str((spec.get("policy") or {}).get("actionTier") or "passive")
1054
+ prescan = run_prescan(a.spec, session, tier)
1055
+ if prescan:
1056
+ os.environ["BLACKGLASS_PROFILE"] = "1"
1057
+ os.environ["BLACKGLASS_PROFILE_PATH"] = str(session / "profile.json")
1058
+
1059
+ # Test accounts: sign each username-and-password account in once, before the hunt, and
1060
+ # hand the sessions to every agent's environment out-of-band (the environment, never
1061
+ # disk), so all agents act as the account from their first request and share one
1062
+ # session. A wrong password is tried once, not once per agent.
1063
+ if (spec.get("target") or {}).get("origins"):
1064
+ shared_sessions = sign_in_test_accounts(spec, em)
1065
+ if shared_sessions:
1066
+ os.environ["BLACKGLASS_SESSIONS_JSON"] = json.dumps(shared_sessions)
1067
+
1068
+ db = session / "agents.db"
1069
+ cmd = [
1070
+ HARNESS,
1071
+ "-db", str(db),
1072
+ "-goal", goal,
1073
+ "-model", spec.get("model") or DEFAULT_MODEL,
1074
+ # Exact argv (JSON) for the harness to exec directly -- no shell, so a PROD_ENV or
1075
+ # spec path containing spaces survives intact. sys.executable (not literal python3)
1076
+ # keeps the env on the same interpreter this controller runs under, which matters
1077
+ # once a bundled runtime ships.
1078
+ "-env", json.dumps([sys.executable, PROD_ENV, "--spec", a.spec]),
1079
+ "-orchestrator", os.environ.get("BASIC_HARNESS_ORCHESTRATOR", prof["orchestrator"]),
1080
+ "-rounds", rounds,
1081
+ # Breadth from the scan profile: Full goes wide (the scaled arm that won the Juice
1082
+ # Shop sweep, 2026-10-28); PR stays lighter for a fast gate. Env overrides for dev.
1083
+ "-teams", os.environ.get("BASIC_HARNESS_TEAMS", prof["teams"]),
1084
+ "-cells", os.environ.get("BASIC_HARNESS_CELLS", prof["cells"]),
1085
+ "-team-calls", os.environ.get("BASIC_HARNESS_TEAM_CALLS", prof["team_calls"]),
1086
+ "-workers", os.environ.get("BASIC_HARNESS_WORKERS", prof["workers"]),
1087
+ # Bounded by WALL CLOCK, not calls: -max-time (set above) is the primary stop and
1088
+ # the harness ends gracefully at the deadline. No call cap (0 = unlimited); the
1089
+ # profile's cost backstop ($3 PR / $15 Full) guards a runaway without being the
1090
+ # binding limit. An operator can still override any of these via env.
1091
+ "-max-time", f"{max_minutes:g}m",
1092
+ "-max-calls", os.environ.get("BASIC_HARNESS_MAX_CALLS", "0"),
1093
+ "-max-cost", os.environ.get("BASIC_HARNESS_MAX_COST", prof["max_cost"]),
1094
+ "-compact-tokens", "100000", "-ui", "", "-hold=false",
1095
+ ]
1096
+ # Route model calls through the murk model gateway when the control plane set
1097
+ # OPENROUTER_BASE_URL (then OPENROUTER_API_KEY is a signed gateway token, not the
1098
+ # OpenRouter key); direct to OpenRouter otherwise.
1099
+ base_url = os.environ.get("OPENROUTER_BASE_URL", "").strip()
1100
+ if base_url:
1101
+ cmd += ["-base-url", base_url]
1102
+ # Prefer an explicit key file when one exists; otherwise the harness reads
1103
+ # OPENROUTER_API_KEY straight from the environment (the hosted launch-env path,
1104
+ # runnerLaunchEnv). Passing a -key-file that does not exist makes the harness
1105
+ # fatal on a missing file, so only pass it when it is really there.
1106
+ if os.path.exists(KEY_FILE):
1107
+ cmd += ["-key-file", KEY_FILE]
1108
+ # Each team gets a validator agent that independently re-tests a candidate finding
1109
+ # before the coordinator may confirm it (anti-false-positive). On by default; an
1110
+ # operator can disable it for an A/B via BASIC_HARNESS_VALIDATE=0.
1111
+ if os.environ.get("BASIC_HARNESS_VALIDATE", "1") != "0":
1112
+ cmd.append("-validate")
1113
+ repo = (spec.get("target") or {}).get("repository") or {}
1114
+ if repo.get("checkoutPath"):
1115
+ cmd += ["-source", str(repo["checkoutPath"])]
1116
+ roles: dict[str, dict] = {}
1117
+ if prof["coord_model"]: # premium model at the synthesis locus (Full only)
1118
+ roles["coordinator"] = {"model": prof["coord_model"]}
1119
+ if prof["orch_model"]: # glm-5.3 council chair + members (Full only; not k3)
1120
+ roles["orchestrator"] = {"model": prof["orch_model"]}
1121
+ if roles:
1122
+ roles_file = session / "roles.json"
1123
+ roles_file.write_text(json.dumps(roles))
1124
+ cmd += ["-roles", str(roles_file)]
1125
+ (session / "harness-cmd.txt").write_text(" ".join(cmd))
1126
+
1127
+ cancelled = {"flag": False}
1128
+ proc: subprocess.Popen | None = None
1129
+
1130
+ def on_term(_signum, _frame):
1131
+ cancelled["flag"] = True
1132
+ if proc and proc.poll() is None:
1133
+ proc.terminate()
1134
+
1135
+ signal.signal(signal.SIGTERM, on_term)
1136
+
1137
+ # The harness enforces the time box itself (-max-time = max_minutes, computed above)
1138
+ # and stops gracefully. This external timeout is only a BACKSTOP for a harness that
1139
+ # overruns its own deadline: set a little longer (grace) so the clean internal stop
1140
+ # wins; on a hard expiry here we still salvage whatever findings exist.
1141
+ timeout_s = max_minutes * 60 + 120
1142
+
1143
+ emitted_findings: set[int] = set()
1144
+ follower = Follower(str(db), em, emitted_findings, emit_findings=(kind != "retest"), origin=origin,
1145
+ checkout=((spec.get("target") or {}).get("repository") or {}).get("checkoutPath"))
1146
+ timed_out = False
1147
+ with open(session / "harness.log", "w") as log:
1148
+ proc = subprocess.Popen(cmd, cwd=str(ROOT), stdout=log, stderr=subprocess.STDOUT)
1149
+ follower.start() # tails the state DB and streams activity live
1150
+ try:
1151
+ code = proc.wait(timeout=timeout_s)
1152
+ except subprocess.TimeoutExpired:
1153
+ timed_out = True
1154
+ proc.terminate()
1155
+ try:
1156
+ code = proc.wait(timeout=30)
1157
+ except subprocess.TimeoutExpired:
1158
+ proc.kill()
1159
+ code = proc.wait()
1160
+
1161
+ follower.stop()
1162
+ follower.join(timeout=10)
1163
+
1164
+ if cancelled["flag"]:
1165
+ em.emit("run.cancelled", {"category": "cancelled", "message": "Run cancelled by operator"})
1166
+ return 4
1167
+
1168
+ def _log_tail() -> str:
1169
+ try:
1170
+ return (session / "harness.log").read_text(errors="replace").strip()[-600:]
1171
+ except OSError:
1172
+ return ""
1173
+
1174
+ # Final reconcile: emit any confirmed finding the follower did not already stream.
1175
+ # mode=rw (not a plain connect) so we never create a stray db, and if the harness
1176
+ # produced no usable state DB (crashed / refused to start) surface ITS log rather
1177
+ # than a confusing "no such table" SQLite error.
1178
+ recs: list = []
1179
+ run_cost: float | None = None
1180
+ try:
1181
+ con = sqlite3.connect(f"file:{db}?mode=rw", uri=True, timeout=5)
1182
+ con.execute("PRAGMA query_only=1")
1183
+ try:
1184
+ rows = con.execute(
1185
+ "SELECT id, title, body, coalesce(meta,'') FROM nodes WHERE kind='finding' AND status='confirmed' ORDER BY id"
1186
+ ).fetchall()
1187
+ # Build each finding's full payload while the DB is open: the model's
1188
+ # assessment + the concrete, verbatim proof (cited request/response and code
1189
+ # refs with snippets from the audited checkout). Reused for emission + report.
1190
+ _checkout = ((spec.get("target") or {}).get("repository") or {}).get("checkoutPath")
1191
+ recs = [
1192
+ (fid, title, finding_payload(title, body, meta=parse_meta(meta), origin=origin,
1193
+ con=con, checkout=_checkout))
1194
+ for fid, title, body, meta in rows
1195
+ ]
1196
+ # The harness records the run's total spend in its terminal run.end event;
1197
+ # surface it so the control plane can meter + bill the scan (usage ledger).
1198
+ crow = con.execute(
1199
+ "SELECT CAST(data AS TEXT) FROM events WHERE kind='run.end' ORDER BY seq DESC LIMIT 1"
1200
+ ).fetchone()
1201
+ if crow and crow[0]:
1202
+ try:
1203
+ run_cost = float(json.loads(crow[0]).get("cost"))
1204
+ except (ValueError, TypeError):
1205
+ run_cost = None
1206
+ finally:
1207
+ con.close()
1208
+ except sqlite3.Error:
1209
+ em.emit("run.failed", {"category": "runtime",
1210
+ "message": f"harness produced no findings DB (exit {code}): {_log_tail() or 'no output'}",
1211
+ "stop": "error"})
1212
+ return 1
1213
+ if kind == "retest":
1214
+ # A re-confirmed finding (status 'confirmed' = still reproduces) is matched back
1215
+ # to the original it re-verifies and re-emitted under the ORIGINAL fingerprint,
1216
+ # verification='reproduced'. Originals with no match here were NOT reproduced; the
1217
+ # control plane reconciles them to 'fixed' from the absence of an occurrence.
1218
+ matched: set[str] = set()
1219
+ for _fid, title, payload in recs:
1220
+ orig = _match_original(title, retest_findings)
1221
+ fp = orig.get("fingerprint") if orig else None
1222
+ if not fp or fp in matched:
1223
+ continue
1224
+ matched.add(fp)
1225
+ # Re-emit this run's fresh payload (incl. the re-reproduced proof) under the
1226
+ # ORIGINAL finding's fingerprint/title so the control plane reconciles identity.
1227
+ p = dict(payload)
1228
+ p["fingerprint"] = fp
1229
+ p["verification"] = "reproduced"
1230
+ if orig.get("title"):
1231
+ p["title"] = str(orig["title"])[:300]
1232
+ em.emit("finding.updated", p)
1233
+ n_findings = len(matched)
1234
+ else:
1235
+ for fid, _title, payload in recs:
1236
+ if fid not in emitted_findings:
1237
+ emitted_findings.add(fid)
1238
+ em.emit("finding.created", payload)
1239
+ n_findings = len(emitted_findings)
1240
+
1241
+ # On an assess run, a non-zero harness exit with zero findings is a failure; on a
1242
+ # retest, zero re-confirmed findings is the SUCCESS case (everything was fixed).
1243
+ if kind != "retest" and code != 0 and n_findings == 0 and not timed_out:
1244
+ em.emit("run.failed", {"category": "runtime", "message": f"harness exited {code} with no findings",
1245
+ "stop": "error"})
1246
+ return 1
1247
+
1248
+ # The customer report: deterministic sections (scope, severity rollup, each finding in
1249
+ # full, the pre-scan's coverage + posture) plus an LLM-authored executive summary.
1250
+ # Written to the session dir and served at /api/runs/:id/report. Produced for a
1251
+ # completed/partial assessment (not a retest, and not a hard crash handled above).
1252
+ if kind == "assess":
1253
+ em.emit("phase.changed", {"phase": "reporting"})
1254
+ report_model = (os.environ.get("BASIC_HARNESS_REPORT_MODEL")
1255
+ or prof.get("orch_model") or DEFAULT_MODEL)
1256
+ write_report(session, run_id, spec, origin, scan_name, report_model, recs, prescan, em.start_ts)
1257
+
1258
+ completed = {
1259
+ "stop": "timeout" if timed_out else "halt",
1260
+ "findings": n_findings, "ok": True,
1261
+ "scanProfile": scan_name,
1262
+ "message": "time box reached; findings are partial" if timed_out else "",
1263
+ }
1264
+ if run_cost is not None: # metered for billing by the control plane
1265
+ completed["cost"] = run_cost
1266
+ em.emit("run.completed", completed)
1267
+ return 0
1268
+
1269
+
1270
+ if __name__ == "__main__":
1271
+ raise SystemExit(main())