diff-sentry 0.4.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.
@@ -0,0 +1,83 @@
1
+ """diff-sentry — classify a GitHub change for malicious intent, as a traced RLM harness.
2
+
3
+ A downstream *consumer* of rlm-harness (editable path dep) reproducing Datadog's BewAIre defense shape:
4
+ ingest a GitHub PR/issue/push → analyze the diff as UNTRUSTED data in a sandboxed REPL → produce a
5
+ judgement-only structured verdict (benign/suspicious/malicious) → union the deterministic indicator
6
+ evidence on read → emit a SIEM signal host-side → export reward-free trajectories.
7
+
8
+ Public surface::
9
+
10
+ from diff_sentry import DetectConfig, run, detect_from_event # drive a run
11
+ from diff_sentry import ChangeVerdict, AssembledVerdict, DetectionResponse # shapes
12
+ from diff_sentry import scan_indicators, assemble_verdict, build_response, emit_signal
13
+ from diff_sentry import normalize_event, event_from_payload, export_dataset
14
+
15
+ `config`, `schema`, `normalize`, `indicators`, `assemble`, `response`, `emit`, `ingest`, `rl_export`
16
+ import NO dspy at module top (unit-testable in isolation). `ClassifyChange` / `setup` / `run` /
17
+ `detect_from_event` pull in dspy lazily (via RLMTask).
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ from .assemble import assemble_verdict, verdict_from_events
23
+ from .config import DetectConfig
24
+ from .emit import EmitResult, emit_signal, signal_payload
25
+ from .indicators import hits_from_events, scan_indicators
26
+ from .ingest import event_from_payload, issue_event, pr_event
27
+ from .normalize import event_metadata, normalize_event
28
+ from .response import build_failed_response, build_response
29
+ from .rl_export import export_dataset, run_labels, run_metrics
30
+ from .schema import (
31
+ AssembledVerdict,
32
+ ChangeVerdict,
33
+ DetectionResponse,
34
+ IndicatorHit,
35
+ ProcessInfo,
36
+ RefusalInfo,
37
+ )
38
+
39
+ __all__ = [
40
+ "DetectConfig",
41
+ "ChangeVerdict",
42
+ "AssembledVerdict",
43
+ "IndicatorHit",
44
+ "DetectionResponse",
45
+ "RefusalInfo",
46
+ "ProcessInfo",
47
+ "scan_indicators",
48
+ "hits_from_events",
49
+ "normalize_event",
50
+ "event_metadata",
51
+ "event_from_payload",
52
+ "pr_event",
53
+ "issue_event",
54
+ "assemble_verdict",
55
+ "verdict_from_events",
56
+ "build_response",
57
+ "build_failed_response",
58
+ "emit_signal",
59
+ "signal_payload",
60
+ "EmitResult",
61
+ "export_dataset",
62
+ "run_labels",
63
+ "run_metrics",
64
+ # dspy-bearing (lazy):
65
+ "ClassifyChange",
66
+ "setup",
67
+ "run",
68
+ "detect_from_event",
69
+ ]
70
+
71
+ __version__ = "0.4.0"
72
+
73
+
74
+ def __getattr__(name: str): # PEP 562 — defer the dspy import to first use
75
+ if name in ("ClassifyChange", "setup"):
76
+ from . import detect
77
+
78
+ return getattr(detect, name)
79
+ if name in ("run", "detect_from_event"):
80
+ from . import cli
81
+
82
+ return getattr(cli, name)
83
+ raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
@@ -0,0 +1,8 @@
1
+ """`python -m diff_sentry …` → the CLI."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from .cli import main
6
+
7
+ if __name__ == "__main__":
8
+ raise SystemExit(main())
@@ -0,0 +1,87 @@
1
+ """Assemble the canonical verdict from the trace — the read-time step that makes the EVIDENCE a fact.
2
+
3
+ `assemble_verdict(verdict, events)` attaches the deterministic fields the SYSTEM owns to the planner's
4
+ JUDGEMENT (`ChangeVerdict`, no hits): the UNION of ALL indicator hits from the trace (baseline ∪ every
5
+ `scan_indicators` tool call — MF3), the derived max severity, whether to SIGNAL, and any indicator ids
6
+ the planner cited that match no recorded hit (a fabrication tell).
7
+
8
+ The `signal` decision is DETERMINISTIC and partly independent of the planner: it fires if the planner's
9
+ verdict is in `emit_on` OR a high/critical deterministic indicator fired. So a false-benign self-report
10
+ cannot suppress hard evidence from reaching the SIEM — the planner can skew the verdict, never hide the
11
+ indicators. (This is deterministic reduction over facts, NOT a model judgement smuggled into the read
12
+ path — the verdict tier stays the planner's; only the evidence-driven floor is ours.)
13
+
14
+ This runs everywhere the result is consumed — the live path (cli), re-render, and rl_export — so labels
15
+ read facts too. Old traces heal: pydantic ignores legacy keys; hits re-source from the trace.
16
+
17
+ Pure stdlib + pydantic; no dspy.
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ from rlm_harness.trace import EVENT_RESULT, EVENT_RUN_START
23
+
24
+ from .indicators import hits_from_events
25
+ from .schema import (
26
+ SIGNAL_SEVERITY_FLOOR,
27
+ AssembledVerdict,
28
+ ChangeVerdict,
29
+ max_severity,
30
+ severity_rank,
31
+ )
32
+
33
+ _DEFAULT_EMIT_ON = ("suspicious", "malicious")
34
+
35
+
36
+ def _emit_on_from_meta(events: list[dict]) -> tuple[str, ...]:
37
+ """The `emit_on` the run actually used, recorded in run_start meta (so an OFFLINE re-render/export
38
+ re-derives the SAME `signal` the live run emitted). Falls back to the default for an old trace."""
39
+ for e in events:
40
+ if e.get("type") == EVENT_RUN_START:
41
+ eo = ((e.get("payload") or {}).get("meta") or {}).get("emit_on")
42
+ if isinstance(eo, (list, tuple)) and eo:
43
+ return tuple(eo)
44
+ return _DEFAULT_EMIT_ON
45
+
46
+
47
+ def assemble_verdict(
48
+ verdict: ChangeVerdict, events: list[dict], *, emit_on: tuple[str, ...] = _DEFAULT_EMIT_ON
49
+ ) -> AssembledVerdict:
50
+ """Attach the deterministic evidence + derived signal to the planner's judgement."""
51
+ hits = hits_from_events(events)
52
+ known_ids = {h.id for h in hits}
53
+ cited_unknown = [cid for cid in (verdict.indicator_ids or []) if cid not in known_ids]
54
+ top_sev = max_severity([h.severity for h in hits])
55
+
56
+ verdict_signals = (verdict.verdict or "").strip().lower() in {v.lower() for v in emit_on}
57
+ evidence_signals = severity_rank(top_sev) >= severity_rank(SIGNAL_SEVERITY_FLOOR)
58
+ signal = bool(verdict_signals or evidence_signals)
59
+
60
+ return AssembledVerdict(
61
+ verdict=verdict.verdict,
62
+ confidence=verdict.confidence,
63
+ summary=verdict.summary,
64
+ rationale=verdict.rationale,
65
+ techniques=list(verdict.techniques or []),
66
+ suspect_files=list(verdict.suspect_files or []),
67
+ recommended_action=verdict.recommended_action,
68
+ indicators=hits,
69
+ max_indicator_severity=top_sev,
70
+ signal=signal,
71
+ cited_unknown_ids=cited_unknown,
72
+ )
73
+
74
+
75
+ def verdict_from_events(events: list[dict], *, emit_on: tuple[str, ...] | None = None
76
+ ) -> AssembledVerdict | None:
77
+ """Reconstruct the assembled verdict from a saved trace's result event, or None if the run produced
78
+ no result (never finalized). `emit_on` defaults to the value recorded in the run's meta, so an
79
+ offline re-render/export re-derives the SAME `signal` the live run emitted (finding 5)."""
80
+ results = [e for e in events if e["type"] == EVENT_RESULT]
81
+ if not results:
82
+ return None
83
+ out = results[-1]["payload"].get("output")
84
+ if not isinstance(out, dict):
85
+ return None
86
+ resolved = emit_on if emit_on is not None else _emit_on_from_meta(events)
87
+ return assemble_verdict(ChangeVerdict.from_payload(out), events, emit_on=resolved)
diff_sentry/cli.py ADDED
@@ -0,0 +1,376 @@
1
+ """THE entry point: a GitHub change in → a structured malicious-intent verdict out, signal emitted.
2
+
3
+ python -m diff_sentry pr owner/repo 7 # classify a PR (needs gh + DS_* creds + Deno)
4
+ python -m diff_sentry issue owner/repo 12 # classify an issue
5
+ python -m diff_sentry classify event.json # classify a payload you already hold
6
+ python -m diff_sentry render traces/pr-7.jsonl pr-7 # re-render the response from a trace (offline)
7
+ python -m diff_sentry export "traces/*.jsonl" ds.json # reward-free dataset export (offline)
8
+ git diff main...HEAD | python -m diff_sentry scan - # indicators only, exit 1 at/above the floor
9
+
10
+ `run()` is the programmatic entry. It records `traces/{run_id}.jsonl`, writes `responses/{run_id}.json`,
11
+ and — host-side, AFTER the run — emits a SIEM signal when the assembled verdict warrants it. A host-side
12
+ BASELINE indicator scan runs at ingest and rides in the run_start meta, so the deterministic evidence is
13
+ in the trace before the planner takes a turn (MF3). `scan`/`render`/`export` work offline;
14
+ `pr`/`issue`/`classify` need model creds (DS_* env) + a Deno sandbox (`classify` only *ingests* offline).
15
+ `scan` exposes that deterministic baseline on its own — the half we can run in our own CI on an untrusted
16
+ fork PR, where no creds exist and none may be handed over (see `_cmd_scan`).
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ import argparse
22
+ import asyncio
23
+ import glob
24
+ import json
25
+ import os
26
+ import sys
27
+ from collections.abc import Callable
28
+ from dataclasses import dataclass
29
+
30
+ from . import __version__
31
+ from .config import DetectConfig
32
+ from .schema import SEVERITIES, SIGNAL_SEVERITY_FLOOR, AssembledVerdict
33
+
34
+
35
+ async def detect_from_event(
36
+ event: dict,
37
+ config: DetectConfig,
38
+ *,
39
+ trace_path: str | None = None,
40
+ run_id: str = "change",
41
+ on_event: Callable[[dict], None] | None = None,
42
+ extra_tools=(),
43
+ ):
44
+ """Run the classifier on one change event and return the planner's JUDGEMENT (`ChangeVerdict`).
45
+ Call `assemble.assemble_verdict(verdict, events)` to attach the deterministic indicators + signal."""
46
+ import rlm_harness
47
+
48
+ from .detect import ClassifyChange, setup
49
+ from .normalize import event_metadata, normalize_event
50
+ from .rubric import default_rubric, rubric_to_meta
51
+
52
+ setup(config)
53
+ task = ClassifyChange(config=config, extra_tools=extra_tools)
54
+ event_str = normalize_event(event)
55
+ meta_source = event_metadata(event)
56
+ # Host-side BASELINE scan — deterministic evidence in the trace before the planner runs (MF3).
57
+ baseline = _baseline_indicators(event)
58
+
59
+ async def _run():
60
+ return await task.arun(event=event_str)
61
+
62
+ if trace_path:
63
+ with rlm_harness.TraceRecorder(trace_path, run_id=run_id, on_event=on_event, meta={
64
+ # The run's INITIAL STATE (the untrusted event is a REPL variable, not a chat turn) + prompt.
65
+ "event": event_str,
66
+ "instructions": task.instructions,
67
+ # The change under review — echoed into the response envelope + the SIEM signal.
68
+ "source": {k: meta_source.get(k) for k in ("repo", "kind", "number", "author", "title",
69
+ "file_count", "content_sha256")},
70
+ # The deterministic baseline — assemble unions this with every scan_indicators tool call.
71
+ "baseline_indicators": baseline,
72
+ # The emit threshold THIS run used, so an offline re-render/export re-derives the SAME signal.
73
+ "emit_on": list(config.emit_on),
74
+ # The models actually used this run (roles → concrete names) — self-describing trace.
75
+ "planner": config.main_model,
76
+ "analyst": config.sub_model,
77
+ "classifier": config.classifier_model,
78
+ # The budget THIS run ran under, so an offline reader computes hit_iteration_cap correctly.
79
+ "max_iterations": config.max_iterations,
80
+ "max_llm_calls": config.max_llm_calls,
81
+ # The ATLAS rubric decomposition carried as LABELS (structure only — the trainer scores dᵢ). A
82
+ # FIXED skeleton (default_rubric): diff-sentry's task is constant, so only the per-criterion
83
+ # FACTS vary per run (re-sourced from the trace on read; see rubric.criteria_facts).
84
+ "rubric": rubric_to_meta(default_rubric()),
85
+ }):
86
+ return await _run()
87
+ return await _run()
88
+
89
+
90
+ def _baseline_indicators(event: dict) -> list[dict]:
91
+ """The host-side deterministic evidence recorded in run_start meta BEFORE the planner runs: the text
92
+ detectors over the raw content ∪ the provenance detectors over the ingest facts. Both re-source into the
93
+ evidence union on read (MF3), so a false-benign SUBMIT can skew the verdict but never suppress these."""
94
+ from .indicators import scan_indicators, scan_provenance
95
+ from .normalize import raw_content
96
+
97
+ return ([h.model_dump() for h in scan_indicators(raw_content(event))]
98
+ + [h.model_dump() for h in scan_provenance(event.get("provenance") or {})])
99
+
100
+
101
+ def _write(path_parts: tuple[str, str], content: str) -> str:
102
+ d, name = path_parts
103
+ os.makedirs(d, exist_ok=True)
104
+ path = os.path.join(d, name)
105
+ with open(path, "w", encoding="utf-8") as fh:
106
+ fh.write(content)
107
+ return path
108
+
109
+
110
+ def _reset_trace(trace_path: str) -> None:
111
+ """Drop any stale trace for this run_id before the run (TraceRecorder appends; one trace per run)."""
112
+ if os.path.exists(trace_path):
113
+ os.remove(trace_path)
114
+
115
+
116
+ @dataclass
117
+ class RunArtifacts:
118
+ assembled: AssembledVerdict | None
119
+ events: list
120
+ run_id: str
121
+ trace_path: str
122
+ response_path: str
123
+ emit: object = None # an emit.EmitResult when the SIEM emitter ran, else None
124
+ status: str = "classified"
125
+
126
+
127
+ def run(
128
+ event: dict,
129
+ *,
130
+ run_id: str = "change",
131
+ outdir: str = "./output",
132
+ config: DetectConfig | None = None,
133
+ on_event: Callable[[dict], None] | None = None,
134
+ extra_tools=(),
135
+ emit: bool = True,
136
+ poster=None,
137
+ ) -> RunArtifacts:
138
+ """THE programmatic entry: classify one change, write the response, and (host-side, after the run)
139
+ emit a SIEM signal when warranted. Never raises on a run failure — a failed run still writes an
140
+ informative response and returns result-less RunArtifacts."""
141
+ from rlm_harness.trace import load_events
142
+
143
+ from .assemble import assemble_verdict
144
+ from .emit import emit_signal
145
+ from .response import build_failed_response, build_response
146
+
147
+ config = config or DetectConfig.from_env()
148
+ trace_path = os.path.join(outdir, "traces", f"{run_id}.jsonl")
149
+ _reset_trace(trace_path)
150
+
151
+ try:
152
+ verdict = asyncio.run(detect_from_event(event, config, trace_path=trace_path, run_id=run_id,
153
+ on_event=on_event, extra_tools=extra_tools))
154
+ except Exception as exc: # noqa: BLE001 — a failed run is still self-contained + navigable
155
+ events = load_events(trace_path, run_id) if os.path.exists(trace_path) else []
156
+ resp = build_failed_response(run_id, events, f"{type(exc).__name__}: {exc}")
157
+ response_path = _write((os.path.join(outdir, "responses"), f"{run_id}.json"),
158
+ resp.model_dump_json(indent=2) + "\n")
159
+ emit_result = emit_signal(resp, config, poster=poster) if emit else None
160
+ return RunArtifacts(None, events, run_id, trace_path, response_path, emit_result, status="failed")
161
+
162
+ events = load_events(trace_path, run_id)
163
+ assembled = assemble_verdict(verdict, events, emit_on=config.emit_on)
164
+ resp = build_response(assembled, events, run_id)
165
+ response_path = _write((os.path.join(outdir, "responses"), f"{run_id}.json"),
166
+ resp.model_dump_json(indent=2) + "\n")
167
+ emit_result = emit_signal(resp, config, poster=poster) if emit else None
168
+ return RunArtifacts(assembled, events, run_id, trace_path, response_path, emit_result, status=resp.status)
169
+
170
+
171
+ # ---- subcommands -------------------------------------------------------------
172
+
173
+ def _print_run(arts: RunArtifacts) -> None:
174
+ a = arts.assembled
175
+ if a is None:
176
+ print(f" ✗ {arts.run_id} failed → {arts.response_path}")
177
+ return
178
+ sig = "SIGNAL" if a.signal else "no-signal"
179
+ emitted = ""
180
+ if arts.emit is not None:
181
+ e = arts.emit
182
+ emitted = (f" · emitted→{e.status_code}" if e.emitted
183
+ else f" · not-emitted({e.skipped_reason or e.error})")
184
+ print(f" ✔ {arts.run_id} [{a.verdict}/{sig}/sev={a.max_indicator_severity}] "
185
+ f"{len(a.indicators)} indicator(s) → {arts.response_path}{emitted}")
186
+
187
+
188
+ def _cmd_pr(args) -> int:
189
+ from .ingest import pr_event
190
+
191
+ config = DetectConfig.from_env()
192
+ event = pr_event(args.repo, args.number)
193
+ arts = run(event, run_id=f"{args.repo.replace('/', '-')}-pr-{args.number}", outdir=args.out,
194
+ config=config, emit=not args.no_emit)
195
+ _print_run(arts)
196
+ return 0 if arts.assembled is not None else 1
197
+
198
+
199
+ def _cmd_issue(args) -> int:
200
+ from .ingest import issue_event
201
+
202
+ config = DetectConfig.from_env()
203
+ event = issue_event(args.repo, args.number)
204
+ arts = run(event, run_id=f"{args.repo.replace('/', '-')}-issue-{args.number}", outdir=args.out,
205
+ config=config, emit=not args.no_emit)
206
+ _print_run(arts)
207
+ return 0 if arts.assembled is not None else 1
208
+
209
+
210
+ def _cmd_classify(args) -> int:
211
+ from .ingest import event_from_payload
212
+
213
+ config = DetectConfig.from_env()
214
+ with open(args.payload, "r", encoding="utf-8") as fh:
215
+ payload = json.load(fh)
216
+ event = event_from_payload(payload)
217
+ run_id = args.run_id or (payload.get("repo", "change").replace("/", "-") + f"-{payload.get('number', 0)}")
218
+ arts = run(event, run_id=run_id, outdir=args.out, config=config, emit=not args.no_emit)
219
+ _print_run(arts)
220
+ return 0 if arts.assembled is not None else 1
221
+
222
+
223
+ def drop_deleted_lines(text: str) -> str:
224
+ """Strip a unified diff's `-` deletion lines, keeping additions and context.
225
+
226
+ A diff carries the code being REMOVED as well as the code being added, and the detectors read plain
227
+ text — so scanning a raw diff flags a change for a payload it is DELETING. That turns every
228
+ remediation commit red, which is the worst possible failure mode for a security gate: it penalises
229
+ exactly the change you wanted. `---` (the file header) is kept; only true `-` deletion lines go.
230
+
231
+ Non-diff input is returned untouched — the check is per line, so a plain source file has no `-`-
232
+ prefixed lines to lose. `scan --include-deletions` opts back in when you want to audit what a change
233
+ removed (a payload can also be hidden by deleting a check)."""
234
+ if not text:
235
+ return text
236
+ return "\n".join(ln for ln in text.split("\n")
237
+ if not (ln.startswith("-") and not ln.startswith("---")))
238
+
239
+
240
+ def _cmd_scan(args) -> int:
241
+ """The deterministic HALF of the pipeline, standalone: run the indicator suite over text and exit
242
+ non-zero on a hit at/above the floor. No LLM, no network, no creds, no Deno — just `indicators`.
243
+
244
+ This is what makes the detector safe to dogfood in our OWN CI. The full `pr`/`classify` path needs
245
+ `DS_*` creds, and the only way to hand creds to a fork PR's run is `pull_request_target` — the exact
246
+ misconfiguration that opened the AsyncAPI "Miasma" supply-chain compromise. `scan` needs nothing, so
247
+ it runs under the same read-only `contents: read` token an untrusted fork PR already gets."""
248
+ from .indicators import scan_indicators
249
+ from .schema import severity_rank
250
+
251
+ threshold = severity_rank(args.fail_on)
252
+ hits = []
253
+ for path in args.path:
254
+ if path == "-":
255
+ text, label = sys.stdin.read(), "<stdin>"
256
+ else:
257
+ with open(path, "r", encoding="utf-8", errors="replace") as fh:
258
+ text = fh.read()
259
+ label = path
260
+ if not args.include_deletions:
261
+ text = drop_deleted_lines(text)
262
+ hits.extend(scan_indicators(text, location=label))
263
+
264
+ worst = max((severity_rank(h.severity) for h in hits), default=-1)
265
+ failed = worst >= threshold
266
+
267
+ if args.json:
268
+ print(json.dumps({"hits": [h.model_dump() for h in hits], "count": len(hits),
269
+ "max_severity": SEVERITIES[worst] if worst >= 0 else None,
270
+ "fail_on": args.fail_on, "failed": failed}, indent=2))
271
+ return 1 if failed else 0
272
+
273
+ if not hits:
274
+ print("scan: no indicators fired.")
275
+ return 0
276
+ print(f"scan: {len(hits)} indicator(s), max severity={SEVERITIES[worst]} (fail-on={args.fail_on})")
277
+ for h in sorted(hits, key=lambda h: -severity_rank(h.severity)):
278
+ where = f" ({h.location})" if h.location else ""
279
+ print(f" [{h.severity:8}] {h.rule}{where}: {h.title}")
280
+ print(f" evidence: {h.evidence[:160]}")
281
+ print(f"FAIL — a hit at/above `{args.fail_on}`." if failed
282
+ else f"OK — every hit is below `{args.fail_on}`.")
283
+ return 1 if failed else 0
284
+
285
+
286
+ def _cmd_render(args) -> int:
287
+ from rlm_harness.trace import load_events
288
+
289
+ from .assemble import verdict_from_events
290
+ from .response import build_failed_response, build_response
291
+
292
+ events = load_events(args.trace, args.run_id)
293
+ assembled = verdict_from_events(events)
294
+ resp = (build_response(assembled, events, args.run_id) if assembled is not None
295
+ else build_failed_response(args.run_id, events, "trace has no result event"))
296
+ out = resp.model_dump_json(indent=2)
297
+ if args.out:
298
+ with open(args.out, "w", encoding="utf-8") as fh:
299
+ fh.write(out + "\n")
300
+ print(f"wrote {args.out}")
301
+ else:
302
+ print(out)
303
+ return 0
304
+
305
+
306
+ def _cmd_export(args) -> int:
307
+ from .rl_export import export_dataset, load_runs
308
+
309
+ paths = [p for g in args.trace for p in glob.glob(g)]
310
+ runs = load_runs(*paths)
311
+ bundle = export_dataset(runs)
312
+ with open(args.out, "w", encoding="utf-8") as fh:
313
+ json.dump(bundle, fh, ensure_ascii=False, indent=2, default=str)
314
+ signals = sum(1 for lab in bundle["labels"].values() if lab["signal"])
315
+ print(f"runs={len(runs)} ({signals} signalling) | sft_turns={len(bundle['sft_turns'])} | "
316
+ f"classifier={len(bundle['classifier'])} | reward-free → {args.out}")
317
+ return 0
318
+
319
+
320
+ def build_parser() -> argparse.ArgumentParser:
321
+ p = argparse.ArgumentParser(prog="diff_sentry", description=__doc__)
322
+ p.add_argument("--version", action="version", version=f"%(prog)s {__version__}")
323
+ sub = p.add_subparsers(dest="cmd", required=True)
324
+
325
+ pr = sub.add_parser("pr", help="classify a GitHub pull request")
326
+ pr.add_argument("repo", help="owner/name")
327
+ pr.add_argument("number", type=int)
328
+ pr.add_argument("--out", default="./output")
329
+ pr.add_argument("--no-emit", action="store_true", help="do not emit a SIEM signal")
330
+ pr.set_defaults(func=_cmd_pr)
331
+
332
+ iss = sub.add_parser("issue", help="classify a GitHub issue")
333
+ iss.add_argument("repo", help="owner/name")
334
+ iss.add_argument("number", type=int)
335
+ iss.add_argument("--out", default="./output")
336
+ iss.add_argument("--no-emit", action="store_true", help="do not emit a SIEM signal")
337
+ iss.set_defaults(func=_cmd_issue)
338
+
339
+ cl = sub.add_parser("classify", help="classify a change payload you already hold (offline ingest)")
340
+ cl.add_argument("payload", help="path to a JSON payload {repo,kind,number,author,title,body,files}")
341
+ cl.add_argument("--run-id", default=None)
342
+ cl.add_argument("--out", default="./output")
343
+ cl.add_argument("--no-emit", action="store_true", help="do not emit a SIEM signal")
344
+ cl.set_defaults(func=_cmd_classify)
345
+
346
+ sc = sub.add_parser("scan", help="deterministic indicator scan over text/diff (offline, no creds)")
347
+ sc.add_argument("path", nargs="+", help="file(s) to scan; `-` reads stdin")
348
+ sc.add_argument("--fail-on", default=SIGNAL_SEVERITY_FLOOR, choices=SEVERITIES,
349
+ help=f"exit 1 on a hit at/above this severity (default: {SIGNAL_SEVERITY_FLOOR}, "
350
+ "the SIEM signal floor)")
351
+ sc.add_argument("--json", action="store_true", help="machine-readable output")
352
+ sc.add_argument("--include-deletions", action="store_true",
353
+ help="also scan a diff's `-` lines (default: skip them, so removing a payload "
354
+ "does not flag the change that removes it)")
355
+ sc.set_defaults(func=_cmd_scan)
356
+
357
+ r = sub.add_parser("render", help="re-render the response from a trace (offline)")
358
+ r.add_argument("trace")
359
+ r.add_argument("run_id")
360
+ r.add_argument("--out", default=None)
361
+ r.set_defaults(func=_cmd_render)
362
+
363
+ e = sub.add_parser("export", help="export reward-free SFT/RL datasets from traces (offline)")
364
+ e.add_argument("trace", nargs="+", help="trace file glob(s)")
365
+ e.add_argument("out", help="output json path")
366
+ e.set_defaults(func=_cmd_export)
367
+ return p
368
+
369
+
370
+ def main(argv=None) -> int:
371
+ args = build_parser().parse_args(argv)
372
+ return args.func(args)
373
+
374
+
375
+ if __name__ == "__main__":
376
+ raise SystemExit(main())