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,159 @@
1
+ """Typed boundary for recording plain SDK tool-calling loops.
2
+
3
+ The adapter intentionally accepts the small mapping-shaped events commonly
4
+ produced by SDK wrappers. It keeps SDK-specific response classes out of the
5
+ recorder while preserving the loop's original event order.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from collections.abc import Callable, Iterable, Mapping
11
+ from typing import Any, TypeAlias, cast
12
+
13
+ from grudge.recorder.recorder import Recorder
14
+ from grudge.schema.trace import Step
15
+
16
+ RawEvent: TypeAlias = Mapping[str, Any]
17
+ RawLoop: TypeAlias = Iterable[RawEvent] | Callable[[], Iterable[RawEvent]]
18
+
19
+
20
+ def record_raw_sdk(recorder: Recorder, events: RawLoop) -> list[Step]:
21
+ """Record raw SDK events or the result of a zero-argument event loop.
22
+
23
+ Events use ``kind`` (or the equivalent ``type``) with one of the Phase 1
24
+ values: ``model_call``, ``tool_call``, ``tool_result``, ``state_diff``, or
25
+ ``final_answer``. Event-specific fields are passed through to the typed
26
+ recorder methods, and the returned list contains the newly recorded steps.
27
+ """
28
+
29
+ source = events() if callable(events) else events
30
+ recorded: list[Step] = []
31
+ for event in source:
32
+ kind = event.get("kind", event.get("type"))
33
+ if not isinstance(kind, str):
34
+ raise ValueError("raw SDK event must include a string kind")
35
+ if kind == "model_call":
36
+ step = cast(
37
+ Step,
38
+ recorder.record_model_call(
39
+ model=_optional_str(event, "model"),
40
+ messages=_optional_messages(event.get("messages")),
41
+ prompt=_optional_str(event, "prompt"),
42
+ response=event.get("response"),
43
+ parent_idx=_optional_int(event, "parent_idx"),
44
+ meta=_optional_mapping(event.get("meta")),
45
+ ),
46
+ )
47
+ elif kind == "tool_call":
48
+ args = event.get("args", event.get("arguments", {}))
49
+ step = cast(
50
+ Step,
51
+ recorder.record_tool_call(
52
+ _required_str(event, "tool", alternative="name"),
53
+ _required_mapping(args, "tool call args"),
54
+ _optional_str(event, "call_id"),
55
+ parent_idx=_optional_int(event, "parent_idx"),
56
+ meta=_optional_mapping(event.get("meta")),
57
+ ),
58
+ )
59
+ elif kind == "tool_result":
60
+ step = cast(
61
+ Step,
62
+ recorder.record_tool_result(
63
+ _required_str(event, "call_id", alternative="tool_call_id"),
64
+ event.get("content"),
65
+ _optional_str(event, "error"),
66
+ parent_idx=_optional_int(event, "parent_idx"),
67
+ meta=_optional_mapping(event.get("meta")),
68
+ ),
69
+ )
70
+ elif kind == "state_diff":
71
+ step = cast(
72
+ Step,
73
+ recorder.record_state_diff(
74
+ _required_mapping(event.get("diff", {}), "state diff"),
75
+ parent_idx=_optional_int(event, "parent_idx"),
76
+ meta=_optional_mapping(event.get("meta")),
77
+ ),
78
+ )
79
+ elif kind == "final_answer":
80
+ step = cast(
81
+ Step,
82
+ recorder.record_final_answer(
83
+ _optional_str(event, "answer"),
84
+ parent_idx=_optional_int(event, "parent_idx"),
85
+ meta=_optional_mapping(event.get("meta")),
86
+ ),
87
+ )
88
+ else:
89
+ raise ValueError(f"unsupported raw SDK event kind: {kind}")
90
+ recorded.append(step)
91
+ return recorded
92
+
93
+
94
+ def _required_str(
95
+ event: RawEvent,
96
+ key: str,
97
+ *,
98
+ alternative: str | None = None,
99
+ ) -> str:
100
+ """Read a required string field, with an SDK naming alias."""
101
+
102
+ value = event.get(key)
103
+ if value is None and alternative is not None:
104
+ value = event.get(alternative)
105
+ if not isinstance(value, str) or not value:
106
+ label = f"{key}/{alternative}" if alternative else key
107
+ raise ValueError(f"raw SDK event requires non-empty string {label}")
108
+ return value
109
+
110
+
111
+ def _optional_str(event: RawEvent, key: str) -> str | None:
112
+ """Read an optional string field."""
113
+
114
+ value = event.get(key)
115
+ if value is not None and not isinstance(value, str):
116
+ raise ValueError(f"raw SDK event field {key} must be a string")
117
+ return value
118
+
119
+
120
+ def _optional_int(event: RawEvent, key: str) -> int | None:
121
+ """Read an optional integer field."""
122
+
123
+ value = event.get(key)
124
+ invalid = value is not None and (
125
+ isinstance(value, bool) or not isinstance(value, int)
126
+ )
127
+ if invalid:
128
+ raise ValueError(f"raw SDK event field {key} must be an integer")
129
+ return value
130
+
131
+
132
+ def _optional_mapping(value: Any) -> Mapping[str, Any] | None:
133
+ """Read an optional mapping field."""
134
+
135
+ if value is not None and not isinstance(value, Mapping):
136
+ raise ValueError("raw SDK event metadata must be a mapping")
137
+ return value
138
+
139
+
140
+ def _required_mapping(value: Any, label: str) -> Mapping[str, Any]:
141
+ """Read a required mapping field."""
142
+
143
+ if not isinstance(value, Mapping):
144
+ raise ValueError(f"{label} must be a mapping")
145
+ return value
146
+
147
+
148
+ def _optional_messages(value: Any) -> list[dict[str, Any]] | None:
149
+ """Validate the Phase 1 message-list shape without SDK dependencies."""
150
+
151
+ if value is None:
152
+ return None
153
+ valid = isinstance(value, list) and all(isinstance(item, dict) for item in value)
154
+ if not valid:
155
+ raise ValueError("raw SDK event messages must be a list of mappings")
156
+ return cast(list[dict[str, Any]], value)
157
+
158
+
159
+ __all__ = ["RawEvent", "RawLoop", "record_raw_sdk"]
@@ -0,0 +1,374 @@
1
+ """Capture an agent run as an ordered, canonical Phase 1 trace.
2
+
3
+ The recorder keeps steps in memory until the run is complete. This lets the
4
+ JSONL header contain the final outcome while the output itself is still
5
+ written append-only: once a record is written it is never sought, rewritten,
6
+ or truncated.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import inspect
12
+ import json
13
+ from collections.abc import Callable, Mapping
14
+ from datetime import UTC, datetime
15
+ from functools import wraps
16
+ from io import TextIOBase
17
+ from pathlib import Path
18
+ from types import TracebackType
19
+ from typing import Any, Literal, TypeVar, cast
20
+
21
+ from grudge.schema.trace import (
22
+ FinalAnswer,
23
+ ModelCall,
24
+ StateDiff,
25
+ Step,
26
+ ToolCall,
27
+ ToolResult,
28
+ Trace,
29
+ )
30
+
31
+ Output = str | Path | TextIOBase
32
+ ReturnT = TypeVar("ReturnT")
33
+
34
+
35
+ class JsonlWriter:
36
+ """Write JSON objects to a text sink without rewriting earlier records."""
37
+
38
+ def __init__(self, output: Output) -> None:
39
+ self._owned = isinstance(output, str | Path)
40
+ if self._owned:
41
+ stream = Path(cast(str | Path, output)).open("a", encoding="utf-8")
42
+ self._stream: TextIOBase = stream
43
+ else:
44
+ self._stream = cast(TextIOBase, output)
45
+ self._closed = False
46
+
47
+ def append(self, record: Mapping[str, Any]) -> None:
48
+ """Append one canonical JSON object and flush it to the sink."""
49
+
50
+ if self._closed:
51
+ raise RuntimeError("cannot append to a closed JSONL writer")
52
+ line = json.dumps(
53
+ dict(record),
54
+ ensure_ascii=False,
55
+ sort_keys=True,
56
+ separators=(",", ":"),
57
+ default=str,
58
+ )
59
+ self._stream.write(line + "\n")
60
+ self._stream.flush()
61
+
62
+ def close(self) -> None:
63
+ """Close an owned file, leaving caller-owned streams open."""
64
+
65
+ if self._closed:
66
+ return
67
+ self._stream.flush()
68
+ if self._owned:
69
+ self._stream.close()
70
+ self._closed = True
71
+
72
+
73
+ class Recorder:
74
+ """Record one agent run and persist it as canonical JSONL.
75
+
76
+ ``Recorder`` is normally used as a context manager. Steps receive a
77
+ strictly increasing index at the moment they are recorded, and an
78
+ exception escaping the context produces a failed trace before being
79
+ re-raised to the caller.
80
+ """
81
+
82
+ def __init__(
83
+ self,
84
+ output: Output,
85
+ *,
86
+ run_id: str,
87
+ agent_version: str = "unknown",
88
+ task: str = "",
89
+ ) -> None:
90
+ self.output = output
91
+ self.run_id = run_id
92
+ self.agent_version = agent_version
93
+ self.task = task
94
+ self._steps: list[Step] = []
95
+ self._next_idx = 0
96
+ self._trace: Trace | None = None
97
+ self._writer: JsonlWriter | None = None
98
+ self.exception: BaseException | None = None
99
+
100
+ def __enter__(self) -> Recorder:
101
+ """Start a recording context."""
102
+
103
+ if self._trace is not None:
104
+ raise RuntimeError("a recorder can only be entered once")
105
+ return self
106
+
107
+ def __exit__(
108
+ self,
109
+ exc_type: type[BaseException] | None,
110
+ exc_value: BaseException | None,
111
+ traceback: TracebackType | None,
112
+ ) -> Literal[False]:
113
+ """Persist the trace and re-raise any exception from the context."""
114
+
115
+ if exc_value is not None:
116
+ self.exception = exc_value
117
+ self.close(outcome="failed" if exc_value is not None else "ok")
118
+ return False
119
+
120
+ @property
121
+ def steps(self) -> tuple[Step, ...]:
122
+ """Return the recorded steps in their original order."""
123
+
124
+ return tuple(self._steps)
125
+
126
+ @property
127
+ def trace(self) -> Trace:
128
+ """Return the finalized trace, or raise while recording is active."""
129
+
130
+ if self._trace is None:
131
+ raise RuntimeError("trace is available after Recorder.close()")
132
+ return self._trace
133
+
134
+ def record_step(self, step: Step) -> Step:
135
+ """Append a pre-built Phase 1 step at the next monotonic index."""
136
+
137
+ if self._trace is not None:
138
+ raise RuntimeError("cannot record after the recorder is closed")
139
+ if step.run_id != self.run_id:
140
+ raise ValueError("step run_id must match recorder run_id")
141
+ if step.idx != self._next_idx:
142
+ raise ValueError(f"step idx must be {self._next_idx}, received {step.idx}")
143
+ self._steps.append(step)
144
+ self._next_idx += 1
145
+ return step
146
+
147
+ def record_model_call(
148
+ self,
149
+ *,
150
+ model: str | None = None,
151
+ messages: list[dict[str, Any]] | None = None,
152
+ prompt: str | None = None,
153
+ response: Any = None,
154
+ parent_idx: int | None = None,
155
+ meta: Mapping[str, Any] | None = None,
156
+ ) -> ModelCall:
157
+ """Record a model request or response."""
158
+
159
+ return cast(
160
+ ModelCall,
161
+ self.record_step(
162
+ ModelCall(
163
+ idx=self._next_idx,
164
+ ts=_now(),
165
+ run_id=self.run_id,
166
+ parent_idx=parent_idx,
167
+ meta=dict(meta or {}),
168
+ model=model,
169
+ messages=messages,
170
+ prompt=prompt,
171
+ response=response,
172
+ )
173
+ ),
174
+ )
175
+
176
+ def record_tool_call(
177
+ self,
178
+ tool: str,
179
+ args: Mapping[str, Any] | None = None,
180
+ call_id: str | None = None,
181
+ *,
182
+ parent_idx: int | None = None,
183
+ meta: Mapping[str, Any] | None = None,
184
+ ) -> ToolCall:
185
+ """Record a tool invocation, generating a call id when omitted."""
186
+
187
+ return cast(
188
+ ToolCall,
189
+ self.record_step(
190
+ ToolCall(
191
+ idx=self._next_idx,
192
+ ts=_now(),
193
+ run_id=self.run_id,
194
+ parent_idx=parent_idx,
195
+ meta=dict(meta or {}),
196
+ tool=tool,
197
+ args=dict(args or {}),
198
+ call_id=call_id or f"call-{self._next_idx}",
199
+ )
200
+ ),
201
+ )
202
+
203
+ def record_tool_result(
204
+ self,
205
+ call_id: str,
206
+ content: Any,
207
+ error: str | None = None,
208
+ *,
209
+ parent_idx: int | None = None,
210
+ meta: Mapping[str, Any] | None = None,
211
+ ) -> ToolResult:
212
+ """Record a tool result, including a tool-level error when present."""
213
+
214
+ return cast(
215
+ ToolResult,
216
+ self.record_step(
217
+ ToolResult(
218
+ idx=self._next_idx,
219
+ ts=_now(),
220
+ run_id=self.run_id,
221
+ parent_idx=parent_idx,
222
+ meta=dict(meta or {}),
223
+ call_id=call_id,
224
+ content=content,
225
+ error=error,
226
+ )
227
+ ),
228
+ )
229
+
230
+ def record_state_diff(
231
+ self,
232
+ diff: Mapping[str, Any] | None = None,
233
+ *,
234
+ parent_idx: int | None = None,
235
+ meta: Mapping[str, Any] | None = None,
236
+ ) -> StateDiff:
237
+ """Record a structured state change."""
238
+
239
+ return cast(
240
+ StateDiff,
241
+ self.record_step(
242
+ StateDiff(
243
+ idx=self._next_idx,
244
+ ts=_now(),
245
+ run_id=self.run_id,
246
+ parent_idx=parent_idx,
247
+ meta=dict(meta or {}),
248
+ diff=dict(diff or {}),
249
+ )
250
+ ),
251
+ )
252
+
253
+ def record_final_answer(
254
+ self,
255
+ answer: str | None,
256
+ *,
257
+ parent_idx: int | None = None,
258
+ meta: Mapping[str, Any] | None = None,
259
+ ) -> FinalAnswer:
260
+ """Record the agent's terminal answer."""
261
+
262
+ return cast(
263
+ FinalAnswer,
264
+ self.record_step(
265
+ FinalAnswer(
266
+ idx=self._next_idx,
267
+ ts=_now(),
268
+ run_id=self.run_id,
269
+ parent_idx=parent_idx,
270
+ meta=dict(meta or {}),
271
+ answer=answer,
272
+ )
273
+ ),
274
+ )
275
+
276
+ # Short aliases make the recording API convenient in plain SDK loops.
277
+ model_call = record_model_call
278
+ tool_call = record_tool_call
279
+ tool_result = record_tool_result
280
+ state_diff = record_state_diff
281
+ final_answer = record_final_answer
282
+
283
+ def close(self, *, outcome: Literal["ok", "failed"] = "ok") -> Trace:
284
+ """Finalize and append the trace header and steps to the output."""
285
+
286
+ if self._trace is not None:
287
+ return self._trace
288
+ trace = Trace(
289
+ run_id=self.run_id,
290
+ agent_version=self.agent_version,
291
+ task=self.task,
292
+ steps=list(self._steps),
293
+ outcome=outcome,
294
+ )
295
+ writer = JsonlWriter(self.output)
296
+ try:
297
+ for line in trace.to_jsonl().splitlines():
298
+ writer.append(json.loads(line))
299
+ finally:
300
+ writer.close()
301
+ self._writer = writer
302
+ self._trace = trace
303
+ return trace
304
+
305
+
306
+ def _now() -> datetime:
307
+ """Return an aware UTC timestamp for a trace step."""
308
+
309
+ return datetime.now(UTC)
310
+
311
+
312
+ def record_run(
313
+ output: Output,
314
+ *,
315
+ run_id: str,
316
+ agent_version: str = "unknown",
317
+ task: str = "",
318
+ ) -> Recorder:
319
+ """Create a recorder; use it as ``with record_run(...) as recorder``."""
320
+
321
+ return Recorder(
322
+ output,
323
+ run_id=run_id,
324
+ agent_version=agent_version,
325
+ task=task,
326
+ )
327
+
328
+
329
+ def record(
330
+ output: Output,
331
+ *,
332
+ run_id: str,
333
+ agent_version: str = "unknown",
334
+ task: str = "",
335
+ ) -> Callable[[Callable[..., ReturnT]], Callable[..., ReturnT]]:
336
+ """Decorate a recorder-aware function with a recording context.
337
+
338
+ The wrapped function receives ``recorder=...`` when its signature accepts
339
+ that keyword. Functions that do not accept it still get outcome capture.
340
+ """
341
+
342
+ def decorate(function: Callable[..., ReturnT]) -> Callable[..., ReturnT]:
343
+ def wrapped(*args: Any, **kwargs: Any) -> ReturnT:
344
+ with Recorder(
345
+ output,
346
+ run_id=run_id,
347
+ agent_version=agent_version,
348
+ task=task,
349
+ ) as recorder:
350
+ accepts_recorder = _accepts_keyword(function, "recorder")
351
+ if accepts_recorder:
352
+ kwargs = {**kwargs, "recorder": recorder}
353
+ return function(*args, **kwargs)
354
+
355
+ return cast(Callable[..., ReturnT], wraps(function)(wrapped))
356
+
357
+ return decorate
358
+
359
+
360
+ def _accepts_keyword(function: Callable[..., Any], name: str) -> bool:
361
+ """Check whether a callable can receive a named recorder argument."""
362
+
363
+ try:
364
+ signature = inspect.signature(function)
365
+ except (TypeError, ValueError):
366
+ return False
367
+ parameter = signature.parameters.get(name)
368
+ return parameter is not None or any(
369
+ item.kind is inspect.Parameter.VAR_KEYWORD
370
+ for item in signature.parameters.values()
371
+ )
372
+
373
+
374
+ __all__ = ["JsonlWriter", "Recorder", "record", "record_run"]
@@ -0,0 +1,105 @@
1
+ """Small, dependency-free redaction helpers for recorded trace values."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ from collections.abc import Mapping, Sequence
7
+ from dataclasses import dataclass
8
+ from typing import TypeVar, cast
9
+
10
+
11
+ @dataclass(frozen=True, slots=True)
12
+ class RedactionRule:
13
+ """A regular expression and replacement used by :func:`redact_text`."""
14
+
15
+ pattern: str | re.Pattern[str]
16
+ replacement: str = "[REDACTED]"
17
+ flags: int = 0
18
+
19
+ def compiled(self) -> re.Pattern[str]:
20
+ """Return the rule's compiled regular expression."""
21
+ if isinstance(self.pattern, str):
22
+ return re.compile(self.pattern, self.flags)
23
+ if self.flags:
24
+ raise ValueError("flags cannot be used with a compiled pattern")
25
+ return self.pattern
26
+
27
+
28
+ DEFAULT_REDACTION_RULES: tuple[RedactionRule, ...] = (
29
+ RedactionRule(r"\bFAKE_KEY_[A-Za-z0-9_-]+\b"),
30
+ RedactionRule(r"\b(?:sk-ant-|sk-)[A-Za-z0-9_-]{12,}\b"),
31
+ RedactionRule(r"\b(?:AKIA|ASIA)[A-Z0-9]{16}\b"),
32
+ RedactionRule(r"\b(?:ghp|gho|ghs|ghr)_[A-Za-z0-9]{20,}\b"),
33
+ RedactionRule(r"\bgithub_pat_[A-Za-z0-9_]{20,}\b"),
34
+ RedactionRule(r"\bxox[baprs]-[A-Za-z0-9-]{10,}\b"),
35
+ RedactionRule(
36
+ r"(?i)\b(?:api[_-]?key|access[_-]?token|secret|token|password)"
37
+ r"(\s*[:=]\s*)['\"]?[A-Za-z0-9_./+=-]{8,}['\"]?",
38
+ replacement=r"\1[REDACTED]",
39
+ ),
40
+ )
41
+
42
+ # A short alias keeps the public rule list convenient to discover.
43
+ DEFAULT_RULES = DEFAULT_REDACTION_RULES
44
+
45
+ _T = TypeVar("_T")
46
+ _RuleLike = RedactionRule | str | re.Pattern[str]
47
+
48
+
49
+ def _as_rule(rule: _RuleLike) -> RedactionRule:
50
+ """Normalize a shorthand regex into a standard redaction rule."""
51
+ if isinstance(rule, RedactionRule):
52
+ return rule
53
+ return RedactionRule(rule)
54
+
55
+
56
+ def redact_text(
57
+ text: str,
58
+ *,
59
+ rules: Sequence[_RuleLike] | None = None,
60
+ enabled: bool = True,
61
+ ) -> str:
62
+ """Redact key-like substrings in ``text`` using rules in list order.
63
+
64
+ Passing ``enabled=False`` is an intentional no-op for tests that need to
65
+ inspect the original trace. Passing an empty rule sequence also disables
66
+ the default rules while leaving redaction enabled.
67
+ """
68
+ if not enabled:
69
+ return text
70
+
71
+ selected_rules = DEFAULT_REDACTION_RULES if rules is None else rules
72
+ redacted = text
73
+ for rule in selected_rules:
74
+ normalized = _as_rule(rule)
75
+ redacted = normalized.compiled().sub(normalized.replacement, redacted)
76
+ return redacted
77
+
78
+
79
+ def redact_trace(
80
+ trace: _T,
81
+ *,
82
+ rules: Sequence[_RuleLike] | None = None,
83
+ enabled: bool = True,
84
+ ) -> _T:
85
+ """Return ``trace`` with string values redacted recursively.
86
+
87
+ Mappings, lists, and tuples are copied so the supplied trace is not
88
+ mutated. Other values pass through unchanged. With ``enabled=False``
89
+ the original object is returned untouched.
90
+ """
91
+ if not enabled:
92
+ return trace
93
+
94
+ def redact_value(value: object) -> object:
95
+ if isinstance(value, str):
96
+ return redact_text(value, rules=rules)
97
+ if isinstance(value, Mapping):
98
+ return {key: redact_value(item) for key, item in value.items()}
99
+ if isinstance(value, list):
100
+ return [redact_value(item) for item in value]
101
+ if isinstance(value, tuple):
102
+ return tuple(redact_value(item) for item in value)
103
+ return value
104
+
105
+ return cast(_T, redact_value(trace))
@@ -0,0 +1 @@
1
+ """Scar registry package; persistent storage remains a roadmap stub."""
grudge/registry/db.py ADDED
@@ -0,0 +1,10 @@
1
+ """Registry database boundary; 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 open_registry() -> NoReturn:
9
+ """Open the registry when the database stretch stage is implemented."""
10
+ roadmap("registry.db")
@@ -0,0 +1,10 @@
1
+ """Scar fingerprint boundary; 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 fingerprint() -> NoReturn:
9
+ """Compute a fingerprint when registry work is implemented."""
10
+ roadmap("registry.fingerprint")
@@ -0,0 +1,10 @@
1
+ """Flakiness tracking boundary; 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 score_flakiness() -> NoReturn:
9
+ """Score flakiness when registry work is implemented."""
10
+ roadmap("registry.flakiness")
@@ -0,0 +1,10 @@
1
+ """Scar retirement boundary; 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 retire() -> NoReturn:
9
+ """Retire a scar when registry work is implemented."""
10
+ roadmap("registry.retirement")