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.
- examples/__init__.py +1 -0
- examples/leaky_agent/__init__.py +1 -0
- examples/leaky_agent/agent.py +250 -0
- examples/leaky_agent/agent_fixed.py +183 -0
- examples/leaky_agent/tools.py +153 -0
- grudge/__init__.py +5 -0
- grudge/__main__.py +6 -0
- grudge/_stubs.py +8 -0
- grudge/admission/__init__.py +6 -0
- grudge/admission/court.py +239 -0
- grudge/admission/verdict.py +26 -0
- grudge/cli/__init__.py +1 -0
- grudge/cli/_common.py +126 -0
- grudge/cli/admit.py +93 -0
- grudge/cli/capture.py +39 -0
- grudge/cli/compile.py +37 -0
- grudge/cli/demo.py +136 -0
- grudge/cli/main.py +54 -0
- grudge/cli/run.py +60 -0
- grudge/cli/scars.py +91 -0
- grudge/cli/shrink.py +108 -0
- grudge/config.py +35 -0
- grudge/demo/__init__.py +1 -0
- grudge/demo/events.py +228 -0
- grudge/demo/orchestrator.py +326 -0
- grudge/demo/server.py +222 -0
- grudge/errors.py +25 -0
- grudge/fuzzer/__init__.py +1 -0
- grudge/fuzzer/fingerprint_guard.py +10 -0
- grudge/fuzzer/mutators.py +10 -0
- grudge/gate/__init__.py +5 -0
- grudge/gate/report.py +125 -0
- grudge/gate/runner.py +323 -0
- grudge/gate/selector.py +10 -0
- grudge/gate/wilson.py +36 -0
- grudge/guard/__init__.py +5 -0
- grudge/guard/compile.py +251 -0
- grudge/guard/import_session.py +106 -0
- grudge/integrations/__init__.py +1 -0
- grudge/integrations/github_comment.py +10 -0
- grudge/integrations/pytest_plugin.py +10 -0
- grudge/llm/__init__.py +6 -0
- grudge/llm/cache.py +92 -0
- grudge/llm/client.py +82 -0
- grudge/locator/__init__.py +1 -0
- grudge/locator/fork.py +10 -0
- grudge/locator/point_of_no_return.py +10 -0
- grudge/predicates/__init__.py +25 -0
- grudge/predicates/call_shape.py +116 -0
- grudge/predicates/compiler.py +116 -0
- grudge/predicates/dsl.py +295 -0
- grudge/predicates/fallback_rules.py +10 -0
- grudge/predicates/ordering.py +33 -0
- grudge/predicates/outcome.py +14 -0
- grudge/predicates/prompts/synthesize.md +15 -0
- grudge/predicates/synthesizer.py +127 -0
- grudge/predicates/taint_flow.py +84 -0
- grudge/recorder/__init__.py +5 -0
- grudge/recorder/adapters/__init__.py +1 -0
- grudge/recorder/adapters/langgraph.py +8 -0
- grudge/recorder/adapters/mcp_proxy.py +8 -0
- grudge/recorder/adapters/raw_sdk.py +159 -0
- grudge/recorder/recorder.py +374 -0
- grudge/recorder/redact.py +105 -0
- grudge/registry/__init__.py +1 -0
- grudge/registry/db.py +10 -0
- grudge/registry/fingerprint.py +10 -0
- grudge/registry/flakiness.py +10 -0
- grudge/registry/retirement.py +10 -0
- grudge/replay/__init__.py +19 -0
- grudge/replay/cassette_player.py +159 -0
- grudge/replay/sandbox.py +340 -0
- grudge/replay/scripted_llm.py +382 -0
- grudge/schema/__init__.py +45 -0
- grudge/schema/cassette.py +206 -0
- grudge/schema/predicate.py +263 -0
- grudge/schema/scar.py +113 -0
- grudge/schema/trace.py +243 -0
- grudge/shrinker/__init__.py +12 -0
- grudge/shrinker/cassette_builder.py +328 -0
- grudge/shrinker/ddmin.py +243 -0
- grudge/shrinker/oracle.py +230 -0
- grudge/shrinker/reducers.py +331 -0
- grudge_cli-0.1.0.dist-info/LICENSE +21 -0
- grudge_cli-0.1.0.dist-info/METADATA +313 -0
- grudge_cli-0.1.0.dist-info/RECORD +89 -0
- grudge_cli-0.1.0.dist-info/WHEEL +5 -0
- grudge_cli-0.1.0.dist-info/entry_points.txt +2 -0
- 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
|
+
]
|
grudge/gate/selector.py
ADDED
|
@@ -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
|
grudge/guard/__init__.py
ADDED
grudge/guard/compile.py
ADDED
|
@@ -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
|
+
]
|