grudge-cli 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (89) hide show
  1. examples/__init__.py +1 -0
  2. examples/leaky_agent/__init__.py +1 -0
  3. examples/leaky_agent/agent.py +250 -0
  4. examples/leaky_agent/agent_fixed.py +183 -0
  5. examples/leaky_agent/tools.py +153 -0
  6. grudge/__init__.py +5 -0
  7. grudge/__main__.py +6 -0
  8. grudge/_stubs.py +8 -0
  9. grudge/admission/__init__.py +6 -0
  10. grudge/admission/court.py +239 -0
  11. grudge/admission/verdict.py +26 -0
  12. grudge/cli/__init__.py +1 -0
  13. grudge/cli/_common.py +126 -0
  14. grudge/cli/admit.py +93 -0
  15. grudge/cli/capture.py +39 -0
  16. grudge/cli/compile.py +37 -0
  17. grudge/cli/demo.py +136 -0
  18. grudge/cli/main.py +54 -0
  19. grudge/cli/run.py +60 -0
  20. grudge/cli/scars.py +91 -0
  21. grudge/cli/shrink.py +108 -0
  22. grudge/config.py +35 -0
  23. grudge/demo/__init__.py +1 -0
  24. grudge/demo/events.py +228 -0
  25. grudge/demo/orchestrator.py +326 -0
  26. grudge/demo/server.py +222 -0
  27. grudge/errors.py +25 -0
  28. grudge/fuzzer/__init__.py +1 -0
  29. grudge/fuzzer/fingerprint_guard.py +10 -0
  30. grudge/fuzzer/mutators.py +10 -0
  31. grudge/gate/__init__.py +5 -0
  32. grudge/gate/report.py +125 -0
  33. grudge/gate/runner.py +323 -0
  34. grudge/gate/selector.py +10 -0
  35. grudge/gate/wilson.py +36 -0
  36. grudge/guard/__init__.py +5 -0
  37. grudge/guard/compile.py +251 -0
  38. grudge/guard/import_session.py +106 -0
  39. grudge/integrations/__init__.py +1 -0
  40. grudge/integrations/github_comment.py +10 -0
  41. grudge/integrations/pytest_plugin.py +10 -0
  42. grudge/llm/__init__.py +6 -0
  43. grudge/llm/cache.py +92 -0
  44. grudge/llm/client.py +82 -0
  45. grudge/locator/__init__.py +1 -0
  46. grudge/locator/fork.py +10 -0
  47. grudge/locator/point_of_no_return.py +10 -0
  48. grudge/predicates/__init__.py +25 -0
  49. grudge/predicates/call_shape.py +116 -0
  50. grudge/predicates/compiler.py +116 -0
  51. grudge/predicates/dsl.py +295 -0
  52. grudge/predicates/fallback_rules.py +10 -0
  53. grudge/predicates/ordering.py +33 -0
  54. grudge/predicates/outcome.py +14 -0
  55. grudge/predicates/prompts/synthesize.md +15 -0
  56. grudge/predicates/synthesizer.py +127 -0
  57. grudge/predicates/taint_flow.py +84 -0
  58. grudge/recorder/__init__.py +5 -0
  59. grudge/recorder/adapters/__init__.py +1 -0
  60. grudge/recorder/adapters/langgraph.py +8 -0
  61. grudge/recorder/adapters/mcp_proxy.py +8 -0
  62. grudge/recorder/adapters/raw_sdk.py +159 -0
  63. grudge/recorder/recorder.py +374 -0
  64. grudge/recorder/redact.py +105 -0
  65. grudge/registry/__init__.py +1 -0
  66. grudge/registry/db.py +10 -0
  67. grudge/registry/fingerprint.py +10 -0
  68. grudge/registry/flakiness.py +10 -0
  69. grudge/registry/retirement.py +10 -0
  70. grudge/replay/__init__.py +19 -0
  71. grudge/replay/cassette_player.py +159 -0
  72. grudge/replay/sandbox.py +340 -0
  73. grudge/replay/scripted_llm.py +382 -0
  74. grudge/schema/__init__.py +45 -0
  75. grudge/schema/cassette.py +206 -0
  76. grudge/schema/predicate.py +263 -0
  77. grudge/schema/scar.py +113 -0
  78. grudge/schema/trace.py +243 -0
  79. grudge/shrinker/__init__.py +12 -0
  80. grudge/shrinker/cassette_builder.py +328 -0
  81. grudge/shrinker/ddmin.py +243 -0
  82. grudge/shrinker/oracle.py +230 -0
  83. grudge/shrinker/reducers.py +331 -0
  84. grudge_cli-0.1.0.dist-info/LICENSE +21 -0
  85. grudge_cli-0.1.0.dist-info/METADATA +313 -0
  86. grudge_cli-0.1.0.dist-info/RECORD +89 -0
  87. grudge_cli-0.1.0.dist-info/WHEEL +5 -0
  88. grudge_cli-0.1.0.dist-info/entry_points.txt +2 -0
  89. grudge_cli-0.1.0.dist-info/top_level.txt +2 -0
grudge/gate/runner.py ADDED
@@ -0,0 +1,323 @@
1
+ """Deterministic, fail-closed execution of an admitted Scar gate."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import copy
6
+ import math
7
+ from collections.abc import Callable
8
+ from dataclasses import dataclass, field
9
+ from typing import Any
10
+
11
+ from grudge.config import DEFAULTS
12
+ from grudge.predicates.compiler import Checker, PredicateResult, compile_predicate
13
+ from grudge.replay.sandbox import run_agent as replay_agent
14
+ from grudge.replay.scripted_llm import ScriptedLLM
15
+ from grudge.schema.scar import Scar
16
+ from grudge.schema.trace import Step, ToolResult, Trace
17
+
18
+ DEFAULT_RUNS = DEFAULTS.n_runs
19
+ DEFAULT_THRESHOLD = DEFAULTS.gate_threshold
20
+ DEFAULT_Z = DEFAULTS.wilson_z
21
+ DEFAULT_TIMEOUT = 2.0
22
+ DEFAULT_MAX_STEPS = 128
23
+
24
+
25
+ def wilson_lower_bound(successes: int, runs: int, z: float = DEFAULT_Z) -> float:
26
+ """Return the Wilson lower confidence bound for a binomial proportion.
27
+
28
+ The zero-run case is deliberately safe and returns zero. The runner never
29
+ creates a zero-run result, but keeping this helper total makes reporting and
30
+ callers that calculate a bound independently fail closed as well.
31
+ """
32
+
33
+ if runs <= 0:
34
+ return 0.0
35
+ if not math.isfinite(z) or z <= 0:
36
+ raise ValueError("z must be a positive finite number")
37
+ successes = max(0, min(successes, runs))
38
+ n = float(runs)
39
+ p = successes / n
40
+ z_squared = z * z
41
+ denominator = 1.0 + z_squared / n
42
+ centre = p + z_squared / (2.0 * n)
43
+ spread = z * math.sqrt(p * (1.0 - p) / n + z_squared / (4.0 * n * n))
44
+ return max(0.0, min(1.0, (centre - spread) / denominator))
45
+
46
+
47
+ @dataclass(slots=True)
48
+ class RunResult:
49
+ """Evidence and outcome for one replay/checker attempt."""
50
+
51
+ number: int
52
+ passed: bool
53
+ replay_succeeded: bool
54
+ trace: Trace | None = None
55
+ predicate_result: PredicateResult | None = None
56
+ evidence: list[Step] = field(default_factory=list)
57
+ reasons: list[str] = field(default_factory=list)
58
+
59
+ @property
60
+ def observation(self) -> str:
61
+ """Return the stable, human-readable outcome for this run."""
62
+
63
+ if self.reasons:
64
+ return "; ".join(self.reasons)
65
+ return "replay succeeded and predicate was not violated"
66
+
67
+
68
+ @dataclass(slots=True)
69
+ class GateResult:
70
+ """Complete statistical gate result, including per-run evidence."""
71
+
72
+ scar_id: str
73
+ successes: int
74
+ runs: int
75
+ pass_rate: float
76
+ lower_bound: float
77
+ threshold: float
78
+ passed: bool
79
+ run_results: list[RunResult] = field(default_factory=list)
80
+ observations: list[str] = field(default_factory=list)
81
+ reasons: list[str] = field(default_factory=list)
82
+ offline: bool = True
83
+ seed: int | None = None
84
+ z: float = DEFAULT_Z
85
+
86
+ @property
87
+ def pass_count(self) -> int:
88
+ """Compatibility name for the number of passing gate runs."""
89
+
90
+ return self.successes
91
+
92
+ @property
93
+ def results(self) -> list[RunResult]:
94
+ """Compatibility alias for the per-run evidence list."""
95
+
96
+ return self.run_results
97
+
98
+ @property
99
+ def exit_code(self) -> int:
100
+ """Return the CI status for an already-evaluated result."""
101
+
102
+ return 0 if self.passed else 1
103
+
104
+
105
+ def _validate_config(
106
+ runs: int,
107
+ threshold: float,
108
+ z: float,
109
+ timeout: float,
110
+ max_steps: int,
111
+ offline: bool,
112
+ seed: int | None,
113
+ ) -> None:
114
+ if isinstance(runs, bool) or not isinstance(runs, int) or runs <= 0:
115
+ raise ValueError("runs must be a positive integer")
116
+ if isinstance(threshold, bool) or not isinstance(threshold, int | float):
117
+ raise ValueError("threshold must be a finite number in [0, 1]")
118
+ if not math.isfinite(float(threshold)) or not 0 <= threshold <= 1:
119
+ raise ValueError("threshold must be a finite number in [0, 1]")
120
+ if (
121
+ isinstance(z, bool)
122
+ or not isinstance(z, int | float)
123
+ or not math.isfinite(z)
124
+ or z <= 0
125
+ ):
126
+ raise ValueError("z must be a positive finite number")
127
+ if (
128
+ not isinstance(timeout, int | float)
129
+ or not math.isfinite(timeout)
130
+ or timeout <= 0
131
+ ):
132
+ raise ValueError("timeout must be a positive finite number")
133
+ if isinstance(max_steps, bool) or not isinstance(max_steps, int) or max_steps <= 0:
134
+ raise ValueError("max_steps must be a positive integer")
135
+ if not isinstance(offline, bool):
136
+ raise ValueError("offline must be a boolean")
137
+ if seed is not None and (isinstance(seed, bool) or not isinstance(seed, int)):
138
+ raise ValueError("seed must be an integer or None")
139
+
140
+
141
+ def _admitted_snapshot(scar: Scar) -> Scar:
142
+ if not isinstance(scar, Scar):
143
+ raise ValueError("scar must be a Scar")
144
+ try:
145
+ snapshot = Scar.model_validate(scar.model_dump(warnings="error"), strict=True)
146
+ except Exception as exc:
147
+ raise ValueError(f"invalid Scar: {type(exc).__name__}: {exc}") from exc
148
+ if snapshot.admission is None or not snapshot.admission.admitted:
149
+ raise ValueError("Scar must have a true admission record before it can run")
150
+ return snapshot.model_copy(deep=True)
151
+
152
+
153
+ def _trace_snapshot(trace: Trace) -> Trace:
154
+ """Revalidate replay output and reject every known failure marker."""
155
+
156
+ if not isinstance(trace, Trace):
157
+ raise TypeError("agent replay did not return a Trace")
158
+ snapshot = Trace.model_validate(trace.model_dump(warnings="error"), strict=True)
159
+ if snapshot.outcome != "ok":
160
+ failures = [
161
+ str(step.meta["failure"])
162
+ for step in snapshot.steps
163
+ if "failure" in step.meta
164
+ ]
165
+ detail = ": " + "; ".join(failures) if failures else ""
166
+ raise RuntimeError(f"replay failed{detail}")
167
+ if not snapshot.steps:
168
+ raise RuntimeError("replay produced an empty trace")
169
+ for step in snapshot.steps:
170
+ if isinstance(step, ToolResult) and step.error is not None:
171
+ raise RuntimeError(f"tool replay failed: {step.error}")
172
+ if step.meta.get("failure") or step.meta.get("truncated"):
173
+ raise RuntimeError("trace contains a failure or truncation marker")
174
+ return snapshot
175
+
176
+
177
+ def _check(checker: Checker, trace: Trace) -> PredicateResult:
178
+ result = checker.check(trace)
179
+ if not isinstance(result, PredicateResult):
180
+ raise TypeError("checker did not return a PredicateResult")
181
+ checked = PredicateResult.model_validate(
182
+ result.model_dump(warnings="error"), strict=True
183
+ )
184
+ if any(step not in trace.steps for step in checked.evidence):
185
+ raise ValueError("checker evidence is not present in the replay trace")
186
+ return checked
187
+
188
+
189
+ def _normalise_arguments(
190
+ scar: Scar | Callable[..., Any],
191
+ agent: Callable[..., Any] | Scar | None,
192
+ ) -> tuple[Scar, Callable[..., Any]]:
193
+ """Accept both ``run_gate(scar, agent)`` and ``run_gate(agent, scar)``."""
194
+
195
+ if isinstance(scar, Scar) and callable(agent):
196
+ return scar, agent
197
+ if callable(scar) and isinstance(agent, Scar):
198
+ return agent, scar
199
+ raise ValueError("run_gate requires an admitted Scar and a callable agent")
200
+
201
+
202
+ def run_gate(
203
+ scar: Scar | Callable[..., Any],
204
+ agent: Callable[..., Any] | Scar | None = None,
205
+ *,
206
+ runs: int = DEFAULT_RUNS,
207
+ threshold: float = DEFAULT_THRESHOLD,
208
+ z: float = DEFAULT_Z,
209
+ timeout: float = DEFAULT_TIMEOUT,
210
+ max_steps: int = DEFAULT_MAX_STEPS,
211
+ offline: bool = True,
212
+ seed: int | None = None,
213
+ ) -> GateResult:
214
+ """Run an admitted Scar repeatedly and apply its Wilson lower bound.
215
+
216
+ Every attempt gets a deep cassette copy and a newly constructed
217
+ :class:`ScriptedLLM`. ``offline`` and ``seed`` are explicit policy metadata;
218
+ the built-in policy is deterministic in either case and never performs I/O.
219
+ """
220
+
221
+ _validate_config(runs, threshold, z, timeout, max_steps, offline, seed)
222
+ candidate, agent_fn = _normalise_arguments(scar, agent)
223
+ candidate = _admitted_snapshot(candidate)
224
+ try:
225
+ checker = compile_predicate(candidate.predicate)
226
+ except Exception as exc:
227
+ raise ValueError(
228
+ f"predicate compilation failed: {type(exc).__name__}: {exc}"
229
+ ) from exc
230
+
231
+ run_results: list[RunResult] = []
232
+ for number in range(1, runs + 1):
233
+ result = RunResult(
234
+ number=number,
235
+ passed=False,
236
+ replay_succeeded=False,
237
+ )
238
+ try:
239
+ policy = (
240
+ ScriptedLLM(config={"seed": seed})
241
+ if seed is not None
242
+ else ScriptedLLM()
243
+ )
244
+ trace = replay_agent(
245
+ agent_fn,
246
+ copy.deepcopy(candidate.cassette),
247
+ policy,
248
+ timeout=float(timeout),
249
+ max_steps=max_steps,
250
+ )
251
+ result.trace = _trace_snapshot(trace)
252
+ result.replay_succeeded = True
253
+ result.predicate_result = _check(checker, result.trace)
254
+ result.evidence = list(result.predicate_result.evidence)
255
+ if result.predicate_result.violated:
256
+ result.reasons.append(
257
+ "predicate violation: " + result.predicate_result.explanation
258
+ )
259
+ else:
260
+ result.passed = True
261
+ except Exception as exc:
262
+ result.reasons.append(f"{type(exc).__name__}: {exc}")
263
+ run_results.append(result)
264
+
265
+ successes = sum(run_result.passed for run_result in run_results)
266
+ pass_rate = successes / runs if runs else 0.0
267
+ lower_bound = wilson_lower_bound(successes, runs, float(z))
268
+ passed = lower_bound >= float(threshold)
269
+ mode = (
270
+ "Offline deterministic mode: fresh ScriptedLLM and deep-copied "
271
+ "cassette per run."
272
+ if offline
273
+ else "Deterministic policy mode: fresh ScriptedLLM and deep-copied "
274
+ "cassette per run."
275
+ )
276
+ reasons = [
277
+ f"Run {run.number}: {run.observation}" for run in run_results if not run.passed
278
+ ]
279
+ observations = [mode]
280
+ observations.append(
281
+ f"{successes}/{runs} gate runs passed; Wilson lower bound "
282
+ f"{lower_bound:.4f} versus threshold {float(threshold):.4f}."
283
+ )
284
+ if not passed and not reasons:
285
+ reasons.append("Wilson lower bound is below the configured threshold")
286
+ return GateResult(
287
+ scar_id=candidate.id,
288
+ successes=successes,
289
+ runs=runs,
290
+ pass_rate=pass_rate,
291
+ lower_bound=lower_bound,
292
+ threshold=float(threshold),
293
+ passed=passed,
294
+ run_results=run_results,
295
+ observations=observations,
296
+ reasons=reasons,
297
+ offline=offline,
298
+ seed=seed,
299
+ z=float(z),
300
+ )
301
+
302
+
303
+ def gate(
304
+ scar: Scar | Callable[..., Any],
305
+ agent: Callable[..., Any] | Scar | None = None,
306
+ **kwargs: Any,
307
+ ) -> int:
308
+ """Return a small CI-friendly status without implementing a CLI."""
309
+
310
+ try:
311
+ result = run_gate(scar, agent, **kwargs)
312
+ except (ValueError, TypeError):
313
+ return 2
314
+ return result.exit_code
315
+
316
+
317
+ __all__ = [
318
+ "GateResult",
319
+ "RunResult",
320
+ "gate",
321
+ "run_gate",
322
+ "wilson_lower_bound",
323
+ ]
@@ -0,0 +1,10 @@
1
+ """Changed-scar selector; this Phase 13 stretch item is a stub."""
2
+
3
+ from typing import NoReturn
4
+
5
+ from grudge._stubs import roadmap
6
+
7
+
8
+ def select_scars() -> NoReturn:
9
+ """Select affected scars when the gate stretch stage is implemented."""
10
+ roadmap("gate.selector")
grudge/gate/wilson.py ADDED
@@ -0,0 +1,36 @@
1
+ """Wilson lower-bound statistics for the Phase 8 regression gate."""
2
+
3
+ import math
4
+
5
+
6
+ def wilson_lower_bound(successes: int, n: int, z: float = 1.96) -> float:
7
+ """Return the lower confidence bound for a binomial pass rate.
8
+
9
+ The calculation uses the Wilson score interval without continuity
10
+ correction. Counts and the confidence multiplier are validated so gate
11
+ decisions cannot silently use malformed statistics.
12
+ """
13
+ if isinstance(successes, bool) or not isinstance(successes, int):
14
+ raise ValueError("successes must be an integer")
15
+ if isinstance(n, bool) or not isinstance(n, int):
16
+ raise ValueError("n must be an integer")
17
+ if successes < 0:
18
+ raise ValueError("successes must be nonnegative")
19
+ if n < 0:
20
+ raise ValueError("n must be nonnegative")
21
+ if successes > n:
22
+ raise ValueError("successes must not exceed n")
23
+ if isinstance(z, bool) or not isinstance(z, int | float):
24
+ raise ValueError("z must be finite and positive")
25
+ if not math.isfinite(z) or z <= 0:
26
+ raise ValueError("z must be finite and positive")
27
+ if n == 0:
28
+ return 0.0
29
+
30
+ p = successes / n
31
+ z_squared = z**2
32
+ denominator = 1 + z_squared / n
33
+ numerator = (
34
+ p + z_squared / (2 * n) - z * math.sqrt((p * (1 - p) + z_squared / (4 * n)) / n)
35
+ )
36
+ return numerator / denominator
@@ -0,0 +1,5 @@
1
+ """Guard-side compilation and session import helpers."""
2
+
3
+ from grudge.guard.compile import compile_scars, evaluate_rule
4
+
5
+ __all__ = ["compile_scars", "evaluate_rule"]
@@ -0,0 +1,251 @@
1
+ """Compile admitted Python Scars into the flat contract consumed by Node hooks.
2
+
3
+ The compiler is deterministic: it reads only admitted Scar JSON, converts the
4
+ three runtime predicate kinds into a small flat representation, sorts rules by
5
+ stable id, and writes canonical JSON. Runtime evaluation here is intentionally
6
+ dependency-free so the shared conformance fixtures can exercise both languages.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import json
12
+ import os
13
+ import tempfile
14
+ from collections.abc import Iterable, Mapping
15
+ from pathlib import Path
16
+ from typing import Any
17
+
18
+ from grudge.schema.predicate import (
19
+ CallShape,
20
+ FilteredTaintFlow,
21
+ Ordering,
22
+ Predicate,
23
+ PredicateSet,
24
+ TaintFlow,
25
+ )
26
+ from grudge.schema.scar import Scar
27
+
28
+ SCHEMA_VERSION = 1
29
+
30
+
31
+ def _matches(value: Any, expected: Any) -> bool:
32
+ """Match JSON values using the shared exact/contains/path operators."""
33
+
34
+ if isinstance(expected, dict) and len(expected) == 1:
35
+ operator, operand = next(iter(expected.items()))
36
+ if operator == "$eq":
37
+ return bool(value == operand)
38
+ if operator == "$contains":
39
+ return isinstance(value, str) and str(operand) in value
40
+ if operator == "$under":
41
+ return isinstance(value, str) and _under(value, str(operand))
42
+ if operator == "$not_under":
43
+ return isinstance(value, str) and not _under(value, str(operand))
44
+ if isinstance(expected, dict):
45
+ return isinstance(value, dict) and all(
46
+ key in value and _matches(value[key], item)
47
+ for key, item in expected.items()
48
+ )
49
+ return bool(value == expected)
50
+
51
+
52
+ def _under(path: str, root: str) -> bool:
53
+ """Compare slash and Windows paths without allowing a prefix collision."""
54
+
55
+ left = path.replace("\\", "/").rstrip("/")
56
+ right = root.replace("\\", "/").rstrip("/")
57
+ return left == right or left.startswith(right + "/")
58
+
59
+
60
+ def _values(value: Any) -> Iterable[Any]:
61
+ if isinstance(value, Mapping):
62
+ yield value
63
+ for child in value.values():
64
+ yield from _values(child)
65
+ elif isinstance(value, list):
66
+ for child in value:
67
+ yield from _values(child)
68
+ else:
69
+ yield value
70
+
71
+
72
+ def _event_value(event: Mapping[str, Any], field: str | None) -> Any:
73
+ if field is None:
74
+ return event.get("result")
75
+ args = event.get("args")
76
+ if isinstance(args, Mapping) and field in args:
77
+ return args[field]
78
+ return event.get(field)
79
+
80
+
81
+ def _rule_violations(
82
+ rule: Mapping[str, Any], events: list[Mapping[str, Any]]
83
+ ) -> list[int]:
84
+ """Return zero-based event indexes that prove a rule violation."""
85
+
86
+ kind = rule["kind"]
87
+ if kind == "call_shape":
88
+ tool = rule["tool"]
89
+ match = rule.get("match", {})
90
+ return [
91
+ index
92
+ for index, event in enumerate(events)
93
+ if event.get("tool") == tool and _matches(event.get("args", {}), match)
94
+ ]
95
+ if kind == "ordering":
96
+ ordering = rule["ordering"]
97
+ seen_before = False
98
+ violations: list[int] = []
99
+ for index, event in enumerate(events):
100
+ if event.get("tool") == ordering["before"]:
101
+ seen_before = True
102
+ elif event.get("tool") == ordering["after"] and not seen_before:
103
+ violations.append(index)
104
+ return violations
105
+ if kind == "taint_flow":
106
+ taint = rule["taint"]
107
+ sources: list[Any] = []
108
+ violations = []
109
+ for index, event in enumerate(events):
110
+ if event.get("tool") == taint["source"] and _matches(
111
+ event.get("args", {}), taint.get("sourceMatch", {})
112
+ ):
113
+ sources.extend(_values(_event_value(event, taint.get("sourceField"))))
114
+ if event.get("tool") == taint["sink"]:
115
+ sink_value = _event_value(event, taint.get("sinkField"))
116
+ if any(
117
+ source != ""
118
+ and source is not None
119
+ and source in list(_values(sink_value))
120
+ for source in sources
121
+ ) or any(
122
+ isinstance(source, str)
123
+ and isinstance(sink_value, str)
124
+ and source in sink_value
125
+ for source in sources
126
+ ):
127
+ violations.append(index)
128
+ return violations
129
+ raise ValueError(f"unsupported compiled rule kind: {kind}")
130
+
131
+
132
+ def evaluate_rule(
133
+ rule: Mapping[str, Any], events: list[Mapping[str, Any]]
134
+ ) -> dict[str, Any]:
135
+ """Evaluate one compiled rule using the shared fixture result shape."""
136
+
137
+ at_events = _rule_violations(rule, events)
138
+ return {"violated": bool(at_events), "atEvents": at_events}
139
+
140
+
141
+ def _flatten(predicate: Predicate) -> tuple[str, dict[str, Any]]:
142
+ if isinstance(predicate, CallShape):
143
+ return "call_shape", {"tool": predicate.tool, "match": predicate.args}
144
+ if isinstance(predicate, Ordering):
145
+ return "ordering", {
146
+ "tool": predicate.after,
147
+ "ordering": {"before": predicate.before, "after": predicate.after},
148
+ "match": {},
149
+ }
150
+ if isinstance(predicate, FilteredTaintFlow):
151
+ flow = predicate.flow
152
+ return "taint_flow", {
153
+ "tool": flow.sink,
154
+ "match": {},
155
+ "taint": {
156
+ "source": flow.source,
157
+ "sink": flow.sink,
158
+ "sourceField": flow.source_field,
159
+ "sinkField": flow.sink_field,
160
+ "sourceMatch": predicate.source_args,
161
+ },
162
+ }
163
+ if isinstance(predicate, TaintFlow):
164
+ return "taint_flow", {
165
+ "tool": predicate.sink,
166
+ "match": {},
167
+ "taint": {
168
+ "source": predicate.source,
169
+ "sink": predicate.sink,
170
+ "sourceField": predicate.source_field,
171
+ "sinkField": predicate.sink_field,
172
+ "sourceMatch": {},
173
+ },
174
+ }
175
+ if (
176
+ isinstance(predicate, PredicateSet)
177
+ and predicate.kind == "not"
178
+ and len(predicate.predicates) == 1
179
+ and isinstance(predicate.predicates[0], CallShape)
180
+ ):
181
+ child = predicate.predicates[0]
182
+ return "call_shape", {"tool": child.tool, "match": child.args}
183
+ raise ValueError(
184
+ f"predicate is not supported by runtime guard: {type(predicate).__name__}"
185
+ )
186
+
187
+
188
+ def compile_scar(scar: Scar, *, created_from: str) -> dict[str, Any]:
189
+ """Convert one admitted Scar to a schema-shaped runtime rule."""
190
+
191
+ if scar.admission is None or not scar.admission.admitted:
192
+ raise ValueError(f"Scar {scar.id!r} is not admitted")
193
+ kind, fields = _flatten(scar.predicate)
194
+ return {
195
+ "id": scar.id,
196
+ "mode": "block",
197
+ "kind": kind,
198
+ "tool": fields["tool"],
199
+ "match": fields.get("match", {}),
200
+ "ordering": fields.get("ordering", {"before": "", "after": ""}),
201
+ "taint": fields.get("taint", {"source": "", "sink": ""}),
202
+ "english": scar.predicate.describe(),
203
+ "createdFrom": created_from,
204
+ }
205
+
206
+
207
+ def compile_scars(
208
+ scars: Iterable[Scar], *, created_from: str = "scar"
209
+ ) -> dict[str, Any]:
210
+ """Build a stable compiled payload from admitted Scars."""
211
+
212
+ rules = [compile_scar(scar, created_from=created_from) for scar in scars]
213
+ return {
214
+ "version": SCHEMA_VERSION,
215
+ "rules": sorted(rules, key=lambda item: item["id"]),
216
+ }
217
+
218
+
219
+ def write_compiled(payload: Mapping[str, Any], destination: str | Path) -> Path:
220
+ """Atomically write canonical compiled JSON, creating its parent."""
221
+
222
+ target = Path(destination)
223
+ target.parent.mkdir(parents=True, exist_ok=True)
224
+ text = json.dumps(payload, ensure_ascii=False, sort_keys=True, indent=2) + "\n"
225
+ fd, temporary = tempfile.mkstemp(prefix=f".{target.name}.", dir=target.parent)
226
+ try:
227
+ with os.fdopen(fd, "w", encoding="utf-8") as handle:
228
+ handle.write(text)
229
+ handle.flush()
230
+ os.fsync(handle.fileno())
231
+ os.replace(temporary, target)
232
+ finally:
233
+ if os.path.exists(temporary):
234
+ os.unlink(temporary)
235
+ return target
236
+
237
+
238
+ def load_scars(directory: str | Path) -> list[Scar]:
239
+ """Load all Scar files in stable filename order."""
240
+
241
+ root = Path(directory)
242
+ return [Scar.read(path) for path in sorted(root.glob("*.scar.json"))]
243
+
244
+
245
+ __all__ = [
246
+ "compile_scar",
247
+ "compile_scars",
248
+ "evaluate_rule",
249
+ "load_scars",
250
+ "write_compiled",
251
+ ]