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
@@ -0,0 +1,19 @@
1
+ """Replay stage package for hermetic cassette-backed execution."""
2
+
3
+ from grudge.replay.cassette_player import CassettePlayer, ReplayMiss, play_cassette
4
+ from grudge.replay.sandbox import (
5
+ CassetteToolAdapter,
6
+ CassetteWorld,
7
+ run_agent,
8
+ world_from_trace,
9
+ )
10
+
11
+ __all__ = [
12
+ "CassettePlayer",
13
+ "CassetteToolAdapter",
14
+ "CassetteWorld",
15
+ "ReplayMiss",
16
+ "play_cassette",
17
+ "run_agent",
18
+ "world_from_trace",
19
+ ]
@@ -0,0 +1,159 @@
1
+ """Phase 4 cassette-backed tool layer for deterministic replay."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import logging
6
+ from collections.abc import Callable, Mapping
7
+ from dataclasses import dataclass
8
+ from functools import partial
9
+ from typing import Any
10
+
11
+ from grudge.errors import ReplayMismatch
12
+ from grudge.schema.cassette import Cassette, CassetteEntry
13
+
14
+ logger = logging.getLogger(__name__)
15
+
16
+
17
+ @dataclass(frozen=True, slots=True)
18
+ class ReplayMiss:
19
+ """A tool invocation for which the cassette had no available response."""
20
+
21
+ tool: str
22
+ args: dict[str, Any]
23
+
24
+
25
+ class CassettePlayer:
26
+ """Serve each matching cassette response at most once.
27
+
28
+ Entries are selected by tool name, then by matcher precedence (``exact``,
29
+ ``subset``, ``any``), with cassette order as the deterministic tie-breaker.
30
+ The player is callable and also exposes ``call``/``invoke`` plus bound
31
+ tool functions through :meth:`tool` and :attr:`tools`.
32
+ """
33
+
34
+ def __init__(self, cassette: Cassette, *, strict: bool = True) -> None:
35
+ self.cassette = cassette
36
+ self.strict = strict
37
+ self._consumed_indices: list[int] = []
38
+ self._misses: list[ReplayMiss] = []
39
+
40
+ @property
41
+ def consumed_indices(self) -> tuple[int, ...]:
42
+ """Return cassette indexes in the order their entries were consumed."""
43
+
44
+ return tuple(self._consumed_indices)
45
+
46
+ @property
47
+ def consumed_entries(self) -> tuple[CassetteEntry, ...]:
48
+ """Return consumed entries in invocation order."""
49
+
50
+ return tuple(self.cassette.world[index] for index in self._consumed_indices)
51
+
52
+ @property
53
+ def consumed(self) -> tuple[CassetteEntry, ...]:
54
+ """Compatibility alias for :attr:`consumed_entries`."""
55
+
56
+ return self.consumed_entries
57
+
58
+ @property
59
+ def remaining_entries(self) -> tuple[CassetteEntry, ...]:
60
+ """Return entries that have not yet been consumed."""
61
+
62
+ consumed = set(self._consumed_indices)
63
+ return tuple(
64
+ entry
65
+ for index, entry in enumerate(self.cassette.world)
66
+ if index not in consumed
67
+ )
68
+
69
+ @property
70
+ def misses(self) -> tuple[ReplayMiss, ...]:
71
+ """Return recorded misses in invocation order."""
72
+
73
+ return tuple(self._misses)
74
+
75
+ @property
76
+ def miss_log(self) -> tuple[ReplayMiss, ...]:
77
+ """Compatibility alias for :attr:`misses`."""
78
+
79
+ return self.misses
80
+
81
+ def call(
82
+ self,
83
+ tool: str,
84
+ args: Mapping[str, Any] | None = None,
85
+ **keyword_args: Any,
86
+ ) -> Any:
87
+ """Invoke a cassette-backed tool.
88
+
89
+ Arguments may be supplied as one mapping or as keyword arguments. A
90
+ matching entry is consumed before its response is returned.
91
+ """
92
+
93
+ if args is not None and keyword_args:
94
+ raise TypeError("provide tool arguments as a mapping or keywords, not both")
95
+ actual_args = dict(args) if args is not None else dict(keyword_args)
96
+
97
+ for kind in ("exact", "subset", "any"):
98
+ for index, entry in enumerate(self.cassette.world):
99
+ if index in self._consumed_indices:
100
+ continue
101
+ if entry.tool != tool or entry.match.kind != kind:
102
+ continue
103
+ if entry.match.matches(actual_args):
104
+ self._consumed_indices.append(index)
105
+ return entry.response
106
+
107
+ miss = ReplayMiss(tool=tool, args=actual_args)
108
+ self._misses.append(miss)
109
+ message = f"no cassette response for tool {tool!r} with args {actual_args!r}"
110
+ if self.strict:
111
+ raise ReplayMismatch(message)
112
+ logger.warning("cassette replay miss: %s", message)
113
+ return {}
114
+
115
+ def invoke(
116
+ self,
117
+ tool: str,
118
+ args: Mapping[str, Any] | None = None,
119
+ **keyword_args: Any,
120
+ ) -> Any:
121
+ """Alias for :meth:`call` suitable for tool-dispatch loops."""
122
+
123
+ return self.call(tool, args, **keyword_args)
124
+
125
+ def __call__(
126
+ self,
127
+ tool: str,
128
+ args: Mapping[str, Any] | None = None,
129
+ **keyword_args: Any,
130
+ ) -> Any:
131
+ """Alias allowing a player to be used directly as a tool dispatcher."""
132
+
133
+ return self.call(tool, args, **keyword_args)
134
+
135
+ def tool(self, tool: str) -> Callable[..., Any]:
136
+ """Return a callable bound to one cassette tool name."""
137
+
138
+ return partial(self.call, tool)
139
+
140
+ def get_tool(self, tool: str) -> Callable[..., Any]:
141
+ """Compatibility alias for :meth:`tool`."""
142
+
143
+ return self.tool(tool)
144
+
145
+ @property
146
+ def tools(self) -> dict[str, Callable[..., Any]]:
147
+ """Return bound callables for every tool named in the cassette."""
148
+
149
+ names = dict.fromkeys(entry.tool for entry in self.cassette.world)
150
+ return {name: self.tool(name) for name in names}
151
+
152
+
153
+ def play_cassette(cassette: Cassette, *, strict: bool = True) -> CassettePlayer:
154
+ """Build a cassette player through the legacy factory name."""
155
+
156
+ return CassettePlayer(cassette, strict=strict)
157
+
158
+
159
+ __all__ = ["CassettePlayer", "ReplayMiss", "play_cassette"]
@@ -0,0 +1,340 @@
1
+ """Hermetic replay of an agent against an in-memory cassette world.
2
+
3
+ The sandbox keeps the policy live while replacing every tool boundary with a
4
+ frozen cassette response. It never constructs the example world, and its
5
+ failure path always produces a valid :class:`~grudge.schema.trace.Trace`.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import builtins
11
+ import inspect
12
+ import io
13
+ import os
14
+ import socket
15
+ import threading
16
+ from collections.abc import Callable, Iterator, Mapping
17
+ from contextlib import contextmanager
18
+ from datetime import UTC, datetime
19
+ from pathlib import Path
20
+ from typing import Any, NoReturn
21
+
22
+ from grudge.errors import ReplayMismatch
23
+ from grudge.schema.cassette import ArgMatcher, Cassette, CassetteEntry
24
+ from grudge.schema.trace import StateDiff, Step, ToolCall, ToolResult, Trace
25
+
26
+ _EPOCH = datetime(2026, 1, 1, tzinfo=UTC)
27
+
28
+
29
+ class CassetteWorld:
30
+ """Expose cassette entries as methods compatible with ``_invoke_tool``.
31
+
32
+ Entries are consumed at most once. Among unconsumed entries, exact
33
+ matchers win over subset matchers, which win over wildcard matchers; ties
34
+ retain cassette order. In lenient mode a miss is recorded and returns an
35
+ empty mapping instead of raising.
36
+ """
37
+
38
+ def __init__(self, cassette: Cassette, *, strict: bool = True) -> None:
39
+ self.cassette = cassette
40
+ self.strict = strict
41
+ self._consumed: set[int] = set()
42
+ self._misses: list[tuple[str, dict[str, Any]]] = []
43
+
44
+ @property
45
+ def consumed(self) -> tuple[CassetteEntry, ...]:
46
+ """Return consumed entries in cassette order."""
47
+
48
+ return tuple(
49
+ entry
50
+ for index, entry in enumerate(self.cassette.world)
51
+ if index in self._consumed
52
+ )
53
+
54
+ @property
55
+ def consumed_indexes(self) -> frozenset[int]:
56
+ """Return the indexes of entries served by this world."""
57
+
58
+ return frozenset(self._consumed)
59
+
60
+ @property
61
+ def misses(self) -> tuple[tuple[str, dict[str, Any]], ...]:
62
+ """Return unmatched calls observed in lenient mode."""
63
+
64
+ return tuple((tool, dict(args)) for tool, args in self._misses)
65
+
66
+ def call(self, tool: str, args: Mapping[str, Any] | None = None) -> Any:
67
+ """Serve one frozen response for ``tool`` and ``args``."""
68
+
69
+ actual = dict(args or {})
70
+ candidates = [
71
+ (index, entry)
72
+ for index, entry in enumerate(self.cassette.world)
73
+ if index not in self._consumed
74
+ and entry.tool == tool
75
+ and entry.match.matches(actual)
76
+ ]
77
+ priorities = {"exact": 0, "subset": 1, "any": 2}
78
+ if candidates:
79
+ index, entry = min(
80
+ candidates,
81
+ key=lambda item: (
82
+ priorities[item[1].match.kind],
83
+ item[1].order_hint if item[1].order_hint is not None else item[0],
84
+ item[0],
85
+ ),
86
+ )
87
+ self._consumed.add(index)
88
+ return entry.response
89
+
90
+ self._misses.append((tool, actual))
91
+ if self.strict:
92
+ raise ReplayMismatch(
93
+ f"no cassette response for {tool} with arguments {actual!r}"
94
+ )
95
+ return {}
96
+
97
+ def list_emails(self) -> Any:
98
+ """Serve the ``list_emails`` tool."""
99
+
100
+ return self.call("list_emails")
101
+
102
+ def read_email(self, id: str) -> Any:
103
+ """Serve the ``read_email`` tool."""
104
+
105
+ return self.call("read_email", {"id": id})
106
+
107
+ def list_files(self) -> Any:
108
+ """Serve the ``list_files`` tool."""
109
+
110
+ return self.call("list_files")
111
+
112
+ def read_file(self, path: str) -> Any:
113
+ """Serve the ``read_file`` tool."""
114
+
115
+ return self.call("read_file", {"path": path})
116
+
117
+ def send_http(self, url: str, body: str) -> Any:
118
+ """Serve the ``send_http`` tool without opening a connection."""
119
+
120
+ return self.call("send_http", {"url": url, "body": body})
121
+
122
+ def __getattr__(self, name: str) -> Callable[..., Any]:
123
+ """Allow compatible agents to request cassette-defined tool names."""
124
+
125
+ if name.startswith("_"):
126
+ raise AttributeError(name)
127
+
128
+ def invoke(**kwargs: Any) -> Any:
129
+ return self.call(name, kwargs)
130
+
131
+ return invoke
132
+
133
+
134
+ # A descriptive alias for callers that think of this object as a tool layer.
135
+ CassetteToolAdapter = CassetteWorld
136
+
137
+
138
+ @contextmanager
139
+ def _hermetic_guards() -> Iterator[None]:
140
+ """Reject filesystem and socket access while an agent is running."""
141
+
142
+ def blocked_filesystem(*args: Any, **kwargs: Any) -> NoReturn:
143
+ del args, kwargs
144
+ raise PermissionError("replay sandbox forbids filesystem access")
145
+
146
+ def blocked_network(*args: Any, **kwargs: Any) -> NoReturn:
147
+ del args, kwargs
148
+ raise PermissionError("replay sandbox forbids network access")
149
+
150
+ owner_ident = threading.get_ident()
151
+
152
+ def guard(original: Any, blocked: Callable[..., NoReturn]) -> Callable[..., Any]:
153
+ def guarded(*args: Any, **kwargs: Any) -> Any:
154
+ if threading.get_ident() == owner_ident:
155
+ return blocked(*args, **kwargs)
156
+ return original(*args, **kwargs)
157
+
158
+ return guarded
159
+
160
+ patches: list[tuple[object, str, Any]] = [
161
+ (builtins, "open", blocked_filesystem),
162
+ (io, "open", blocked_filesystem),
163
+ (os, "open", blocked_filesystem),
164
+ (os, "fdopen", blocked_filesystem),
165
+ (Path, "open", blocked_filesystem),
166
+ (Path, "read_text", blocked_filesystem),
167
+ (Path, "read_bytes", blocked_filesystem),
168
+ (Path, "write_text", blocked_filesystem),
169
+ (Path, "write_bytes", blocked_filesystem),
170
+ (socket, "socket", blocked_network),
171
+ (socket, "create_connection", blocked_network),
172
+ (socket, "getaddrinfo", blocked_network),
173
+ (socket, "gethostbyname", blocked_network),
174
+ (socket, "gethostbyname_ex", blocked_network),
175
+ ]
176
+ originals: list[tuple[object, str, Any]] = []
177
+ try:
178
+ for owner, name, replacement in patches:
179
+ if hasattr(owner, name):
180
+ originals.append((owner, name, getattr(owner, name)))
181
+ setattr(owner, name, guard(getattr(owner, name), replacement))
182
+ yield
183
+ finally:
184
+ for owner, name, original in reversed(originals):
185
+ setattr(owner, name, original)
186
+
187
+
188
+ def run_agent(
189
+ agent_fn: Callable[..., Any],
190
+ cassette: Cassette,
191
+ llm: Any,
192
+ *,
193
+ timeout: float,
194
+ max_steps: int,
195
+ ) -> Trace:
196
+ """Run ``agent_fn`` against ``cassette`` and always return a ``Trace``.
197
+
198
+ The agent runs in a daemon thread so a non-cooperative policy cannot block
199
+ the caller beyond ``timeout``. Agent functions accepting ``run_id`` and
200
+ ``max_actions`` receive deterministic replay values; narrower compatible
201
+ callables receive only the parameters present in their signature.
202
+ """
203
+
204
+ if timeout <= 0 or max_steps < 0:
205
+ return _failure_trace(cassette, agent_fn, "invalid replay limit", max_steps)
206
+
207
+ world = CassetteWorld(cassette)
208
+ result: list[Any] = []
209
+ failure: list[BaseException] = []
210
+
211
+ def target() -> None:
212
+ try:
213
+ with _hermetic_guards():
214
+ result.append(_call_agent(agent_fn, world, llm, max_steps))
215
+ except BaseException as exc: # The public contract converts all failures.
216
+ failure.append(exc)
217
+
218
+ worker = threading.Thread(target=target, name="grudge-replay", daemon=True)
219
+ worker.start()
220
+ worker.join(timeout)
221
+ if worker.is_alive():
222
+ return _failure_trace(
223
+ cassette, agent_fn, f"sandbox timeout after {timeout:g}s", max_steps
224
+ )
225
+ if failure:
226
+ return _failure_trace(
227
+ cassette, agent_fn, _failure_reason(failure[0]), max_steps
228
+ )
229
+ if not result or not isinstance(result[0], Trace):
230
+ return _failure_trace(
231
+ cassette, agent_fn, "agent did not return a Trace", max_steps
232
+ )
233
+
234
+ trace = result[0]
235
+ if len(trace.steps) > max_steps:
236
+ return trace.model_copy(
237
+ update={"steps": trace.steps[:max_steps], "outcome": "failed"}
238
+ )
239
+ return trace
240
+
241
+
242
+ def world_from_trace(trace: Trace) -> Cassette:
243
+ """Build a full, unshrunk cassette from every tool call in ``trace``."""
244
+
245
+ results: dict[str, list[ToolResult]] = {}
246
+ for step in trace.steps:
247
+ if isinstance(step, ToolResult):
248
+ results.setdefault(step.call_id, []).append(step)
249
+
250
+ entries: list[CassetteEntry] = []
251
+ for step in trace.steps:
252
+ if not isinstance(step, ToolCall):
253
+ continue
254
+ result_list = results.get(step.call_id, [])
255
+ result = result_list.pop(0) if result_list else None
256
+ response = result.content if result is not None else {"error": "missing result"}
257
+ entries.append(
258
+ CassetteEntry(
259
+ tool=step.tool,
260
+ match=ArgMatcher.exact(dict(step.args)),
261
+ response=response,
262
+ order_hint=step.idx,
263
+ )
264
+ )
265
+ return Cassette(task=trace.task, world=entries)
266
+
267
+
268
+ def _call_agent(
269
+ agent_fn: Callable[..., Any],
270
+ world: CassetteWorld,
271
+ llm: Any,
272
+ max_steps: int,
273
+ ) -> Any:
274
+ """Call an agent with only the compatible optional keyword arguments."""
275
+
276
+ parameters: Mapping[str, inspect.Parameter]
277
+ try:
278
+ parameters = inspect.signature(agent_fn).parameters
279
+ except (TypeError, ValueError):
280
+ parameters = {}
281
+ kwargs: dict[str, Any] = {}
282
+ if "llm" in parameters or any(
283
+ parameter.kind is inspect.Parameter.VAR_KEYWORD
284
+ for parameter in parameters.values()
285
+ ):
286
+ kwargs["llm"] = llm
287
+ if "run_id" in parameters:
288
+ kwargs["run_id"] = "replay-run"
289
+ if "max_actions" in parameters:
290
+ kwargs["max_actions"] = max_steps
291
+ return agent_fn(world, **kwargs)
292
+
293
+
294
+ def _failure_trace(
295
+ cassette: Cassette,
296
+ agent_fn: Callable[..., Any],
297
+ reason: str,
298
+ max_steps: int | None = None,
299
+ ) -> Trace:
300
+ """Create a deterministic schema-valid trace for a sandbox failure."""
301
+
302
+ run_id = "replay-run"
303
+ agent_name = getattr(
304
+ agent_fn, "__qualname__", getattr(agent_fn, "__name__", "agent")
305
+ )
306
+ module = getattr(agent_fn, "__module__", "__main__")
307
+ if max_steps == 0:
308
+ steps: list[Step] = []
309
+ else:
310
+ steps = [
311
+ StateDiff(
312
+ idx=0,
313
+ ts=_EPOCH,
314
+ run_id=run_id,
315
+ diff={},
316
+ meta={"failure": reason},
317
+ )
318
+ ]
319
+ return Trace(
320
+ run_id=run_id,
321
+ agent_version=f"{module}.{agent_name}",
322
+ task=cassette.task,
323
+ steps=steps,
324
+ outcome="failed",
325
+ )
326
+
327
+
328
+ def _failure_reason(error: BaseException) -> str:
329
+ """Format an exception without including unstable traceback locations."""
330
+
331
+ text = str(error)
332
+ return f"{type(error).__name__}: {text}" if text else type(error).__name__
333
+
334
+
335
+ __all__ = [
336
+ "CassetteToolAdapter",
337
+ "CassetteWorld",
338
+ "run_agent",
339
+ "world_from_trace",
340
+ ]