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
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
"""Fail-closed, deterministic admission proof for regression scars.
|
|
2
|
+
|
|
3
|
+
Empty sanity means that an empty trace is not failure evidence. Safety rules
|
|
4
|
+
may safely hold on empty; non-vacuity additionally requires witnessed violations
|
|
5
|
+
on every successful bad-agent run, alongside clean fixed and control runs.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import math
|
|
11
|
+
import re
|
|
12
|
+
from collections.abc import Callable, Sequence
|
|
13
|
+
from datetime import UTC, datetime
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
from typing import Any
|
|
16
|
+
|
|
17
|
+
from grudge.admission.verdict import AdmissionVerdict
|
|
18
|
+
from grudge.predicates.compiler import Checker, PredicateResult, compile_predicate
|
|
19
|
+
from grudge.replay.sandbox import run_agent
|
|
20
|
+
from grudge.replay.scripted_llm import ScriptedLLM
|
|
21
|
+
from grudge.schema.scar import AdmissionRecord, Scar
|
|
22
|
+
from grudge.schema.trace import ToolResult, Trace
|
|
23
|
+
|
|
24
|
+
DEFAULT_SCARS_DIR = Path(__file__).resolve().parents[2] / "scars"
|
|
25
|
+
_EMPTY_SANITY = (
|
|
26
|
+
"Empty sanity: empty is not failure evidence; safety rules may hold on empty, "
|
|
27
|
+
"and non-vacuity requires witnessed bad-run violations."
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _trace_snapshot(trace: Trace, *, allow_empty: bool = False) -> Trace:
|
|
32
|
+
"""Revalidate mutable models, including nested steps, before trusting them."""
|
|
33
|
+
|
|
34
|
+
if not isinstance(trace, Trace):
|
|
35
|
+
raise TypeError("expected a Trace")
|
|
36
|
+
snapshot = Trace.model_validate(trace.model_dump(warnings="error"), strict=True)
|
|
37
|
+
if snapshot.outcome != "ok":
|
|
38
|
+
details = [
|
|
39
|
+
str(step.meta["failure"])
|
|
40
|
+
for step in snapshot.steps
|
|
41
|
+
if "failure" in step.meta
|
|
42
|
+
]
|
|
43
|
+
suffix = ": " + "; ".join(details) if details else ""
|
|
44
|
+
raise ValueError("failed or truncated trace" + suffix)
|
|
45
|
+
if not allow_empty and not snapshot.steps:
|
|
46
|
+
raise ValueError("trace must be nonempty")
|
|
47
|
+
for step in snapshot.steps:
|
|
48
|
+
if isinstance(step, ToolResult) and step.error is not None:
|
|
49
|
+
raise ValueError(f"tool replay failed: {step.error}")
|
|
50
|
+
if step.meta.get("failure") or step.meta.get("truncated"):
|
|
51
|
+
raise ValueError("trace contains a failure or truncation marker")
|
|
52
|
+
return snapshot
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _check(checker: Checker, trace: Trace) -> PredicateResult:
|
|
56
|
+
"""Reject invalid checker output and evidence absent from the checked trace."""
|
|
57
|
+
|
|
58
|
+
result = checker.check(trace)
|
|
59
|
+
if not isinstance(result, PredicateResult):
|
|
60
|
+
raise TypeError("checker did not return a PredicateResult")
|
|
61
|
+
validated = PredicateResult.model_validate(
|
|
62
|
+
result.model_dump(warnings="error"), strict=True
|
|
63
|
+
)
|
|
64
|
+
if any(step not in trace.steps for step in validated.evidence):
|
|
65
|
+
raise ValueError("checker evidence is not present in the trace")
|
|
66
|
+
return validated
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def admit(
|
|
70
|
+
scar_candidate: Scar,
|
|
71
|
+
bad_agent: Callable[..., Any],
|
|
72
|
+
good_agent: Callable[..., Any],
|
|
73
|
+
*,
|
|
74
|
+
runs: int,
|
|
75
|
+
controls: Sequence[Trace],
|
|
76
|
+
output_dir: Path | str | None = None,
|
|
77
|
+
timeout: float = 2.0,
|
|
78
|
+
max_steps: int = 128,
|
|
79
|
+
) -> AdmissionVerdict:
|
|
80
|
+
"""Prove all admission gates and save a detached, proof-bearing Scar.
|
|
81
|
+
|
|
82
|
+
A new policy is constructed outside guarded replay for *each* agent run.
|
|
83
|
+
The fixed agent may set its supplied policy to defended mode without
|
|
84
|
+
contaminating another run. Rejections never persist a candidate.
|
|
85
|
+
"""
|
|
86
|
+
|
|
87
|
+
if isinstance(runs, bool) or not isinstance(runs, int) or runs <= 0:
|
|
88
|
+
raise ValueError("runs must be a positive integer")
|
|
89
|
+
if (
|
|
90
|
+
not math.isfinite(timeout)
|
|
91
|
+
or timeout <= 0
|
|
92
|
+
or isinstance(max_steps, bool)
|
|
93
|
+
or not isinstance(max_steps, int)
|
|
94
|
+
or max_steps <= 0
|
|
95
|
+
):
|
|
96
|
+
raise ValueError("timeout and max_steps must be positive finite limits")
|
|
97
|
+
|
|
98
|
+
verdict = AdmissionVerdict(admitted=False)
|
|
99
|
+
try:
|
|
100
|
+
# Round-tripping also checks models altered through model_copy/construct
|
|
101
|
+
# or in-place mutations, which assignment validation alone cannot catch.
|
|
102
|
+
candidate = Scar.model_validate(
|
|
103
|
+
scar_candidate.model_dump(warnings="error"), strict=True
|
|
104
|
+
).model_copy(deep=True)
|
|
105
|
+
if re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9._-]{0,127}", candidate.id) is None:
|
|
106
|
+
raise ValueError("scar id must be a safe filename component")
|
|
107
|
+
checker = compile_predicate(candidate.predicate)
|
|
108
|
+
except Exception as exc:
|
|
109
|
+
verdict.reasons.append(
|
|
110
|
+
f"Invalid candidate or predicate: {type(exc).__name__}: {exc}"
|
|
111
|
+
)
|
|
112
|
+
return verdict
|
|
113
|
+
|
|
114
|
+
empty = Trace(
|
|
115
|
+
run_id="admission-empty",
|
|
116
|
+
agent_version="admission",
|
|
117
|
+
task=candidate.cassette.task,
|
|
118
|
+
steps=[],
|
|
119
|
+
outcome="ok",
|
|
120
|
+
)
|
|
121
|
+
try:
|
|
122
|
+
verdict.empty_result = _check(checker, empty)
|
|
123
|
+
if verdict.empty_result.violated:
|
|
124
|
+
verdict.reasons.append(
|
|
125
|
+
"Empty sanity failed: predicate violates on empty trace."
|
|
126
|
+
)
|
|
127
|
+
except Exception as exc:
|
|
128
|
+
verdict.reasons.append(f"Empty checker exception: {type(exc).__name__}: {exc}")
|
|
129
|
+
|
|
130
|
+
if not controls:
|
|
131
|
+
verdict.reasons.append("At least one nonempty clean control trace is required.")
|
|
132
|
+
for index, control in enumerate(controls, start=1):
|
|
133
|
+
try:
|
|
134
|
+
trace = _trace_snapshot(control)
|
|
135
|
+
result = _check(checker, trace)
|
|
136
|
+
verdict.control_results.append(result)
|
|
137
|
+
if result.violated:
|
|
138
|
+
verdict.reasons.append(
|
|
139
|
+
f"Control {index} violated: {result.explanation}"
|
|
140
|
+
)
|
|
141
|
+
except Exception as exc:
|
|
142
|
+
verdict.reasons.append(
|
|
143
|
+
f"Control {index} failed: {type(exc).__name__}: {exc}"
|
|
144
|
+
)
|
|
145
|
+
|
|
146
|
+
for index in range(1, runs + 1):
|
|
147
|
+
for label, agent, results in (
|
|
148
|
+
("Bad", bad_agent, verdict.bad_results),
|
|
149
|
+
("Good", good_agent, verdict.good_results),
|
|
150
|
+
):
|
|
151
|
+
try:
|
|
152
|
+
policy = ScriptedLLM()
|
|
153
|
+
replay = run_agent(
|
|
154
|
+
agent,
|
|
155
|
+
candidate.cassette.model_copy(deep=True),
|
|
156
|
+
policy,
|
|
157
|
+
timeout=timeout,
|
|
158
|
+
max_steps=max_steps,
|
|
159
|
+
)
|
|
160
|
+
trace = _trace_snapshot(replay)
|
|
161
|
+
result = _check(checker, trace)
|
|
162
|
+
results.append(result)
|
|
163
|
+
if label == "Bad":
|
|
164
|
+
if not result.violated or not result.evidence:
|
|
165
|
+
verdict.reasons.append(
|
|
166
|
+
f"Bad run {index} needs a violation "
|
|
167
|
+
"with nonempty evidence: " + result.explanation
|
|
168
|
+
)
|
|
169
|
+
else:
|
|
170
|
+
verdict.evidence.extend(result.evidence)
|
|
171
|
+
elif result.violated:
|
|
172
|
+
verdict.reasons.append(
|
|
173
|
+
f"Good run {index} violated: {result.explanation}"
|
|
174
|
+
)
|
|
175
|
+
except Exception as exc:
|
|
176
|
+
verdict.reasons.append(
|
|
177
|
+
f"{label} run {index} replay/checker failed: "
|
|
178
|
+
f"{type(exc).__name__}: {exc}"
|
|
179
|
+
)
|
|
180
|
+
|
|
181
|
+
if verdict.reasons:
|
|
182
|
+
return verdict
|
|
183
|
+
|
|
184
|
+
verdict.reasons.extend(
|
|
185
|
+
[
|
|
186
|
+
f"All {runs} bad runs violated with nonempty evidence.",
|
|
187
|
+
f"All {runs} good runs and {len(controls)} controls "
|
|
188
|
+
"were successful and clean.",
|
|
189
|
+
_EMPTY_SANITY,
|
|
190
|
+
]
|
|
191
|
+
)
|
|
192
|
+
candidate.admission = AdmissionRecord.model_validate(
|
|
193
|
+
{
|
|
194
|
+
"admitted": True,
|
|
195
|
+
"reasons": list(verdict.reasons),
|
|
196
|
+
"bad_runs": len(verdict.bad_results),
|
|
197
|
+
"good_runs": len(verdict.good_results),
|
|
198
|
+
"checked_at": datetime.now(UTC),
|
|
199
|
+
"control_runs": len(verdict.control_results),
|
|
200
|
+
"empty_sanity": _EMPTY_SANITY,
|
|
201
|
+
"evidence": [step.model_dump(mode="json") for step in verdict.evidence],
|
|
202
|
+
"bad_results": [
|
|
203
|
+
result.model_dump(mode="json") for result in verdict.bad_results
|
|
204
|
+
],
|
|
205
|
+
"good_results": [
|
|
206
|
+
result.model_dump(mode="json") for result in verdict.good_results
|
|
207
|
+
],
|
|
208
|
+
"control_results": [
|
|
209
|
+
result.model_dump(mode="json") for result in verdict.control_results
|
|
210
|
+
],
|
|
211
|
+
"empty_result": (
|
|
212
|
+
verdict.empty_result.model_dump(mode="json")
|
|
213
|
+
if verdict.empty_result is not None
|
|
214
|
+
else None
|
|
215
|
+
),
|
|
216
|
+
"timeout": timeout,
|
|
217
|
+
"max_steps": max_steps,
|
|
218
|
+
}
|
|
219
|
+
)
|
|
220
|
+
try:
|
|
221
|
+
directory = (
|
|
222
|
+
DEFAULT_SCARS_DIR if output_dir is None else Path(output_dir)
|
|
223
|
+
).resolve()
|
|
224
|
+
target = directory / f"{candidate.id}.scar.json"
|
|
225
|
+
payload = candidate.model_dump_json(indent=2) + "\n"
|
|
226
|
+
directory.mkdir(parents=True, exist_ok=True)
|
|
227
|
+
# Exclusive creation prevents symlink escapes and overwriting an existing
|
|
228
|
+
# proof, including a target swapped between path checking and opening.
|
|
229
|
+
with target.open("x", encoding="utf-8") as handle:
|
|
230
|
+
handle.write(payload)
|
|
231
|
+
except Exception as exc:
|
|
232
|
+
verdict.reasons.append(f"Persistence failed: {type(exc).__name__}: {exc}")
|
|
233
|
+
return verdict
|
|
234
|
+
verdict.admitted = True
|
|
235
|
+
verdict.saved_path = target
|
|
236
|
+
return verdict
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
__all__ = ["admit"]
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
"""Structured proof and diagnostics returned by the admission court."""
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
from pydantic import BaseModel, ConfigDict, Field
|
|
6
|
+
|
|
7
|
+
from grudge.predicates.compiler import PredicateResult
|
|
8
|
+
from grudge.schema.trace import Step
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class AdmissionVerdict(BaseModel):
|
|
12
|
+
"""Admission succeeds only after every proof gate and persistence succeed."""
|
|
13
|
+
|
|
14
|
+
model_config = ConfigDict(extra="forbid", validate_assignment=True)
|
|
15
|
+
|
|
16
|
+
admitted: bool
|
|
17
|
+
reasons: list[str] = Field(default_factory=list)
|
|
18
|
+
evidence: list[Step] = Field(default_factory=list)
|
|
19
|
+
bad_results: list[PredicateResult] = Field(default_factory=list)
|
|
20
|
+
good_results: list[PredicateResult] = Field(default_factory=list)
|
|
21
|
+
control_results: list[PredicateResult] = Field(default_factory=list)
|
|
22
|
+
empty_result: PredicateResult | None = None
|
|
23
|
+
saved_path: Path | None = None
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
__all__ = ["AdmissionVerdict"]
|
grudge/cli/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Command-line package for the Phase 0 Grudge surface."""
|
grudge/cli/_common.py
ADDED
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
"""Shared helpers for the Phase 9 scriptable command surface."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import importlib
|
|
6
|
+
import json
|
|
7
|
+
import sys
|
|
8
|
+
import traceback
|
|
9
|
+
from collections.abc import Callable, Mapping
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from typing import Any, NoReturn, TypeVar, cast
|
|
12
|
+
|
|
13
|
+
import typer
|
|
14
|
+
|
|
15
|
+
from grudge.errors import GrudgeError
|
|
16
|
+
|
|
17
|
+
T = TypeVar("T")
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def finish(payload: Mapping[str, Any], *, json_output: bool) -> None:
|
|
21
|
+
"""Print a command result as JSON or a compact human-readable summary."""
|
|
22
|
+
|
|
23
|
+
if json_output:
|
|
24
|
+
typer.echo(json.dumps(dict(payload), ensure_ascii=False, sort_keys=True))
|
|
25
|
+
return
|
|
26
|
+
for key, value in payload.items():
|
|
27
|
+
if isinstance(value, list):
|
|
28
|
+
for item in value:
|
|
29
|
+
typer.echo(str(item))
|
|
30
|
+
else:
|
|
31
|
+
typer.echo(f"{key}: {value}")
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def fail(exc: Exception, *, json_output: bool, debug: bool) -> NoReturn:
|
|
35
|
+
"""Render an expected command failure and terminate with status 2."""
|
|
36
|
+
|
|
37
|
+
if debug:
|
|
38
|
+
traceback.print_exc(file=sys.stderr)
|
|
39
|
+
message = str(exc) or type(exc).__name__
|
|
40
|
+
if json_output:
|
|
41
|
+
typer.echo(
|
|
42
|
+
json.dumps(
|
|
43
|
+
{"error": message, "error_type": type(exc).__name__},
|
|
44
|
+
ensure_ascii=False,
|
|
45
|
+
sort_keys=True,
|
|
46
|
+
)
|
|
47
|
+
)
|
|
48
|
+
else:
|
|
49
|
+
typer.echo(f"Error: {message}", err=True)
|
|
50
|
+
raise typer.Exit(code=2)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def command(action: Callable[[], T], *, json_output: bool, debug: bool) -> T:
|
|
54
|
+
"""Run a command body with the common Grudge error contract."""
|
|
55
|
+
|
|
56
|
+
try:
|
|
57
|
+
return action()
|
|
58
|
+
except typer.Exit:
|
|
59
|
+
raise
|
|
60
|
+
except Exception as exc:
|
|
61
|
+
fail(exc, json_output=json_output, debug=debug)
|
|
62
|
+
raise AssertionError("unreachable")
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def load_callable(module_name: str) -> Callable[..., Any]:
|
|
66
|
+
"""Import an agent module and select its conventional run entry point."""
|
|
67
|
+
|
|
68
|
+
try:
|
|
69
|
+
module = importlib.import_module(module_name)
|
|
70
|
+
except Exception as exc:
|
|
71
|
+
raise GrudgeError(
|
|
72
|
+
f"could not import agent module {module_name!r}: {exc}"
|
|
73
|
+
) from exc
|
|
74
|
+
for name in ("run_agent", "run"):
|
|
75
|
+
candidate = getattr(module, name, None)
|
|
76
|
+
if callable(candidate):
|
|
77
|
+
return cast(Callable[..., Any], candidate)
|
|
78
|
+
raise GrudgeError(
|
|
79
|
+
f"agent module {module_name!r} must expose a callable run_agent or run"
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def require_file(path: Path, *, label: str) -> Path:
|
|
84
|
+
"""Validate a required input file without leaking a raw OS exception."""
|
|
85
|
+
|
|
86
|
+
if not path.is_file():
|
|
87
|
+
raise GrudgeError(f"{label} file does not exist: {path}")
|
|
88
|
+
return path
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def read_text(path: Path, *, label: str) -> str:
|
|
92
|
+
"""Read a required UTF-8 file as a Grudge-friendly operation."""
|
|
93
|
+
|
|
94
|
+
require_file(path, label=label)
|
|
95
|
+
try:
|
|
96
|
+
return path.read_text(encoding="utf-8")
|
|
97
|
+
except (OSError, UnicodeError) as exc:
|
|
98
|
+
raise GrudgeError(f"could not read {label} file {path}: {exc}") from exc
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def write_text(path: Path, text: str, *, label: str) -> None:
|
|
102
|
+
"""Write a command artifact, creating its parent directory when needed."""
|
|
103
|
+
|
|
104
|
+
try:
|
|
105
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
106
|
+
path.write_text(text, encoding="utf-8")
|
|
107
|
+
except OSError as exc:
|
|
108
|
+
raise GrudgeError(f"could not write {label} file {path}: {exc}") from exc
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def model_json(model: Any) -> dict[str, Any]:
|
|
112
|
+
"""Return a Pydantic model as JSON-compatible data."""
|
|
113
|
+
|
|
114
|
+
return cast(dict[str, Any], model.model_dump(mode="json"))
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
__all__ = [
|
|
118
|
+
"command",
|
|
119
|
+
"fail",
|
|
120
|
+
"finish",
|
|
121
|
+
"load_callable",
|
|
122
|
+
"model_json",
|
|
123
|
+
"read_text",
|
|
124
|
+
"require_file",
|
|
125
|
+
"write_text",
|
|
126
|
+
]
|
grudge/cli/admit.py
ADDED
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
"""Build and prove a Scar from a cassette using the admission court."""
|
|
2
|
+
|
|
3
|
+
# Typer's declarative Option defaults are intentional here.
|
|
4
|
+
# ruff: noqa: B008
|
|
5
|
+
|
|
6
|
+
import hashlib
|
|
7
|
+
from datetime import UTC, datetime
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
import typer
|
|
12
|
+
|
|
13
|
+
from grudge.admission import admit
|
|
14
|
+
from grudge.cli._common import command, finish, load_callable, read_text
|
|
15
|
+
from grudge.errors import GrudgeError
|
|
16
|
+
from grudge.predicates.dsl import parse
|
|
17
|
+
from grudge.predicates.fallback_rules import demo_rule
|
|
18
|
+
from grudge.schema.cassette import Cassette
|
|
19
|
+
from grudge.schema.scar import Scar
|
|
20
|
+
from grudge.schema.trace import Trace
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def admit_cassette(
|
|
24
|
+
cassette: Path,
|
|
25
|
+
bad: str = typer.Option(..., "--bad", help="Failing agent module."),
|
|
26
|
+
good: str = typer.Option(..., "--good", help="Fixed agent module."),
|
|
27
|
+
out: Path = typer.Option(..., "--out", help="Directory for admitted Scars."),
|
|
28
|
+
scar_id: str | None = typer.Option(None, "--id", help="Scar filename/id."),
|
|
29
|
+
predicate: str = typer.Option(
|
|
30
|
+
"demo",
|
|
31
|
+
"--predicate",
|
|
32
|
+
help="Predicate DSL, or 'demo' for the hand-verified rule.",
|
|
33
|
+
),
|
|
34
|
+
controls: Path = typer.Option(
|
|
35
|
+
Path("examples/leaky_agent/controls"),
|
|
36
|
+
"--controls",
|
|
37
|
+
help="Directory containing benign control JSONL traces.",
|
|
38
|
+
),
|
|
39
|
+
runs: int = typer.Option(3, "--runs", min=1, help="Proof runs per agent."),
|
|
40
|
+
json_output: bool = typer.Option(False, "--json", help="Emit JSON output."),
|
|
41
|
+
debug: bool = typer.Option(False, "--debug", help="Show a traceback on errors."),
|
|
42
|
+
) -> None:
|
|
43
|
+
"""Prove a cassette red on the bad agent and green on the good agent."""
|
|
44
|
+
|
|
45
|
+
def run() -> dict[str, Any]:
|
|
46
|
+
payload = read_text(cassette, label="cassette")
|
|
47
|
+
try:
|
|
48
|
+
candidate_cassette = Cassette.model_validate_json(payload)
|
|
49
|
+
except Exception as exc:
|
|
50
|
+
raise GrudgeError(f"invalid cassette file {cassette}: {exc}") from exc
|
|
51
|
+
rule = demo_rule() if predicate.strip().lower() == "demo" else parse(predicate)
|
|
52
|
+
identifier = scar_id or candidate_cassette.content_hash[:12]
|
|
53
|
+
fingerprint = hashlib.sha256(
|
|
54
|
+
(candidate_cassette.content_hash + rule.model_dump_json()).encode("utf-8")
|
|
55
|
+
).hexdigest()
|
|
56
|
+
candidate = Scar(
|
|
57
|
+
id=identifier,
|
|
58
|
+
cassette=candidate_cassette,
|
|
59
|
+
predicate=rule,
|
|
60
|
+
seed_predicate=rule.model_copy(deep=True),
|
|
61
|
+
source_trace_hash=candidate_cassette.content_hash,
|
|
62
|
+
agent_version_failed=bad,
|
|
63
|
+
agent_version_fixed=good,
|
|
64
|
+
created_at=datetime.now(UTC),
|
|
65
|
+
fingerprint=fingerprint,
|
|
66
|
+
)
|
|
67
|
+
control_paths = sorted(controls.glob("*.jsonl"))
|
|
68
|
+
if not control_paths:
|
|
69
|
+
raise GrudgeError(f"no control JSONL files found in {controls}")
|
|
70
|
+
control_traces = [
|
|
71
|
+
Trace.from_jsonl(read_text(path, label="control")) for path in control_paths
|
|
72
|
+
]
|
|
73
|
+
verdict = admit(
|
|
74
|
+
candidate,
|
|
75
|
+
load_callable(bad),
|
|
76
|
+
load_callable(good),
|
|
77
|
+
runs=runs,
|
|
78
|
+
controls=control_traces,
|
|
79
|
+
output_dir=out,
|
|
80
|
+
)
|
|
81
|
+
result = {
|
|
82
|
+
"command": "admit",
|
|
83
|
+
"admitted": verdict.admitted,
|
|
84
|
+
"scar_id": candidate.id,
|
|
85
|
+
"saved_path": str(verdict.saved_path) if verdict.saved_path else None,
|
|
86
|
+
"reasons": verdict.reasons,
|
|
87
|
+
}
|
|
88
|
+
if not verdict.admitted:
|
|
89
|
+
finish(result, json_output=json_output)
|
|
90
|
+
raise typer.Exit(code=1)
|
|
91
|
+
return result
|
|
92
|
+
|
|
93
|
+
finish(command(run, json_output=json_output, debug=debug), json_output=json_output)
|
grudge/cli/capture.py
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
"""Capture an agent module's typed trace as canonical JSONL."""
|
|
2
|
+
|
|
3
|
+
# Typer's declarative Option defaults are intentional here.
|
|
4
|
+
# ruff: noqa: B008
|
|
5
|
+
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
import typer
|
|
10
|
+
|
|
11
|
+
from grudge.cli._common import command, finish, load_callable, write_text
|
|
12
|
+
from grudge.errors import GrudgeError
|
|
13
|
+
from grudge.schema.trace import Trace
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def capture_agent(
|
|
17
|
+
agent_module: str,
|
|
18
|
+
out: Path = typer.Option(..., "--out", help="Destination JSONL trace."),
|
|
19
|
+
json_output: bool = typer.Option(False, "--json", help="Emit JSON output."),
|
|
20
|
+
debug: bool = typer.Option(False, "--debug", help="Show a traceback on errors."),
|
|
21
|
+
) -> None:
|
|
22
|
+
"""Capture an agent run into a trace."""
|
|
23
|
+
|
|
24
|
+
def run() -> dict[str, Any]:
|
|
25
|
+
trace = load_callable(agent_module)()
|
|
26
|
+
if not isinstance(trace, Trace):
|
|
27
|
+
raise GrudgeError(
|
|
28
|
+
f"agent {agent_module!r} returned {type(trace).__name__}, not a Trace"
|
|
29
|
+
)
|
|
30
|
+
write_text(out, trace.to_jsonl(), label="trace")
|
|
31
|
+
return {
|
|
32
|
+
"command": "capture",
|
|
33
|
+
"trace": str(out),
|
|
34
|
+
"run_id": trace.run_id,
|
|
35
|
+
"steps": len(trace.steps),
|
|
36
|
+
"outcome": trace.outcome,
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
finish(command(run, json_output=json_output, debug=debug), json_output=json_output)
|
grudge/cli/compile.py
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
"""CLI command for compiling admitted Scars for the local guard runtime."""
|
|
2
|
+
|
|
3
|
+
# Typer requires option factories in function defaults; this is the project's
|
|
4
|
+
# established command-module pattern.
|
|
5
|
+
# ruff: noqa: B008
|
|
6
|
+
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
import typer
|
|
11
|
+
|
|
12
|
+
from grudge.config import SCARS_DIR
|
|
13
|
+
from grudge.guard.compile import compile_scars, load_scars, write_compiled
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def compile_command(
|
|
17
|
+
scars: Path = typer.Option(SCARS_DIR, "--scars", help="Scar directory."),
|
|
18
|
+
out: Path = typer.Option(
|
|
19
|
+
Path(".grudge/compiled.json"), "--out", help="Compiled rule output."
|
|
20
|
+
),
|
|
21
|
+
json_output: bool = typer.Option(False, "--json", help="Emit JSON output."),
|
|
22
|
+
) -> None:
|
|
23
|
+
"""Compile admitted Scar files into the flat hook contract."""
|
|
24
|
+
|
|
25
|
+
payload = compile_scars(load_scars(scars), created_from=str(scars))
|
|
26
|
+
target = write_compiled(payload, out)
|
|
27
|
+
result: dict[str, Any] = {
|
|
28
|
+
"command": "compile",
|
|
29
|
+
"scars": str(scars),
|
|
30
|
+
"out": str(target),
|
|
31
|
+
"rules": len(payload["rules"]),
|
|
32
|
+
"version": payload["version"],
|
|
33
|
+
}
|
|
34
|
+
if json_output:
|
|
35
|
+
typer.echo(__import__("json").dumps(result, sort_keys=True))
|
|
36
|
+
else:
|
|
37
|
+
typer.echo(f"compiled {result['rules']} rule(s) to {target}")
|