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,106 @@
1
+ # OWNER: lane-b
2
+ """Import redacted Phase 16 hook JSONL into the Python Trace contract.
3
+
4
+ The Node recorder stores one compact event per tool invocation. The engine's
5
+ trace language models the request and response separately, so each valid log
6
+ line becomes an adjacent ``ToolCall``/``ToolResult`` pair while preserving the
7
+ original event order. Corrupt or non-object lines are ignored deliberately:
8
+ session capture is diagnostic and must not make later analysis unavailable.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import json
14
+ from datetime import UTC, datetime
15
+ from pathlib import Path
16
+ from typing import Any, Literal, cast
17
+ from uuid import uuid4
18
+
19
+ from grudge.schema import Step, ToolCall, ToolResult, Trace
20
+
21
+
22
+ def _timestamp(value: Any) -> datetime:
23
+ if isinstance(value, str):
24
+ try:
25
+ parsed = datetime.fromisoformat(value.replace("Z", "+00:00"))
26
+ return parsed if parsed.tzinfo else parsed.replace(tzinfo=UTC)
27
+ except ValueError:
28
+ pass
29
+ return datetime.fromtimestamp(0, tz=UTC)
30
+
31
+
32
+ def import_session(
33
+ source: str | Path,
34
+ *,
35
+ run_id: str | None = None,
36
+ agent_version: str = "grudge-hook",
37
+ task: str = "Imported Grudge session",
38
+ outcome: str = "ok",
39
+ ) -> Trace:
40
+ """Load a session JSONL file into a validated :class:`~grudge.schema.Trace`."""
41
+
42
+ session_id = run_id or Path(source).stem or str(uuid4())
43
+ steps: list[Step] = []
44
+ with Path(source).open(encoding="utf-8") as handle:
45
+ for event_index, line in enumerate(handle):
46
+ if not line.strip():
47
+ continue
48
+ try:
49
+ event = json.loads(line)
50
+ except json.JSONDecodeError:
51
+ continue
52
+ if not isinstance(event, dict):
53
+ continue
54
+ tool = event.get("tool")
55
+ if not isinstance(tool, str) or not tool:
56
+ continue
57
+ args = event.get("args", {})
58
+ if not isinstance(args, dict):
59
+ args = {}
60
+ call_id = f"{session_id}:{event_index}"
61
+ timestamp = _timestamp(event.get("timestamp"))
62
+ steps.append(
63
+ ToolCall(
64
+ idx=len(steps),
65
+ tool=tool,
66
+ args=args,
67
+ call_id=call_id,
68
+ ts=timestamp,
69
+ run_id=session_id,
70
+ meta={"session_event": event_index},
71
+ )
72
+ )
73
+ result = event.get("result", "")
74
+ if (
75
+ not isinstance(result, str | int | float | bool | list | dict)
76
+ and result is not None
77
+ ):
78
+ result = str(result)
79
+ steps.append(
80
+ ToolResult(
81
+ idx=len(steps),
82
+ call_id=call_id,
83
+ content=result,
84
+ error=(
85
+ event.get("error")
86
+ if isinstance(event.get("error"), str)
87
+ else None
88
+ ),
89
+ ts=timestamp,
90
+ run_id=session_id,
91
+ meta={"session_event": event_index},
92
+ )
93
+ )
94
+ normalized_outcome = cast(
95
+ Literal["ok", "failed"], outcome if outcome in {"ok", "failed"} else "ok"
96
+ )
97
+ return Trace(
98
+ run_id=session_id,
99
+ agent_version=agent_version,
100
+ task=task,
101
+ steps=steps,
102
+ outcome=normalized_outcome,
103
+ )
104
+
105
+
106
+ __all__ = ["import_session"]
@@ -0,0 +1 @@
1
+ """Integration package for pytest and GitHub; both integrations are stubs."""
@@ -0,0 +1,10 @@
1
+ """GitHub comment 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 publish_github_comment() -> NoReturn:
9
+ """Publish a regression comment when the integration is implemented."""
10
+ roadmap("integrations.github_comment")
@@ -0,0 +1,10 @@
1
+ """Pytest integration 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 install_pytest_plugin() -> NoReturn:
9
+ """Install pytest scar collection when the integration is implemented."""
10
+ roadmap("integrations.pytest_plugin")
grudge/llm/__init__.py ADDED
@@ -0,0 +1,6 @@
1
+ """Offline-first, provider-neutral completion and disk-cache boundary."""
2
+
3
+ from grudge.llm.cache import Completion, DiskCache, cached_completion
4
+ from grudge.llm.client import Provider, complete
5
+
6
+ __all__ = ["Completion", "DiskCache", "Provider", "cached_completion", "complete"]
grudge/llm/cache.py ADDED
@@ -0,0 +1,92 @@
1
+ """Deterministic, prompt-addressed completion storage."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import hashlib
6
+ import json
7
+ import os
8
+ import tempfile
9
+ from collections.abc import Callable
10
+ from dataclasses import dataclass
11
+ from pathlib import Path
12
+ from typing import Literal
13
+
14
+ from grudge import config
15
+
16
+
17
+ @dataclass(frozen=True)
18
+ class Completion:
19
+ """Text returned by the completion boundary and its provenance."""
20
+
21
+ text: str
22
+ source: Literal["llm", "cache", "fallback"]
23
+
24
+ @property
25
+ def cache_hit(self) -> bool:
26
+ return self.source == "cache"
27
+
28
+ @property
29
+ def fallback_used(self) -> bool:
30
+ return self.source == "fallback"
31
+
32
+
33
+ class DiskCache:
34
+ """Store UTF-8 completion text under the SHA-256 of the exact prompt."""
35
+
36
+ def __init__(self, directory: Path | str | None = None) -> None:
37
+ self.directory = Path(directory) if directory is not None else config.CACHE_DIR
38
+
39
+ def _path(self, prompt: str) -> Path:
40
+ digest = hashlib.sha256(prompt.encode("utf-8")).hexdigest()
41
+ return self.directory / f"{digest}.json"
42
+
43
+ def get(self, prompt: str) -> str | None:
44
+ try:
45
+ payload = json.loads(self._path(prompt).read_text(encoding="utf-8"))
46
+ except (FileNotFoundError, UnicodeDecodeError, json.JSONDecodeError):
47
+ return None
48
+ if isinstance(payload, dict) and isinstance(payload.get("text"), str):
49
+ return str(payload["text"])
50
+ return None
51
+
52
+ def put(self, prompt: str, text: str) -> None:
53
+ self.directory.mkdir(parents=True, exist_ok=True)
54
+ payload = json.dumps({"text": text}, ensure_ascii=False, sort_keys=True) + "\n"
55
+ # A complete entry becomes visible atomically, including to parallel readers.
56
+ with tempfile.NamedTemporaryFile(
57
+ mode="w", encoding="utf-8", newline="\n", dir=self.directory, delete=False
58
+ ) as stream:
59
+ temporary = Path(stream.name)
60
+ stream.write(payload)
61
+ try:
62
+ os.replace(temporary, self._path(prompt))
63
+ finally:
64
+ temporary.unlink(missing_ok=True)
65
+
66
+
67
+ def cached_completion(
68
+ prompt: str,
69
+ request: Callable[[str], str | Completion] | None = None,
70
+ *,
71
+ cache_dir: Path | str | None = None,
72
+ fallback: str,
73
+ ) -> Completion:
74
+ """Read first; invoke ``request`` only on a miss, or return local fallback.
75
+
76
+ Fallback text is never cached as model output, so a later online invocation
77
+ can still synthesize a rule for the same evidence.
78
+ """
79
+
80
+ cache = DiskCache(cache_dir)
81
+ text = cache.get(prompt)
82
+ if text is not None:
83
+ return Completion(text, "cache")
84
+ if request is None:
85
+ return Completion(fallback, "fallback")
86
+ response = request(prompt)
87
+ completion = (
88
+ response if isinstance(response, Completion) else Completion(response, "llm")
89
+ )
90
+ if not completion.fallback_used:
91
+ cache.put(prompt, completion.text)
92
+ return completion
grudge/llm/client.py ADDED
@@ -0,0 +1,82 @@
1
+ """Offline-first boundary for any OpenAI-compatible completion provider."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ from pathlib import Path
7
+ from typing import Protocol
8
+
9
+ import httpx
10
+
11
+ from grudge import config
12
+ from grudge.llm.cache import Completion, cached_completion
13
+
14
+ DEMO_DSL = "never call send_http where body tainted_by read_file()"
15
+
16
+
17
+ class Provider(Protocol):
18
+ """An injectable adapter, independent of any provider SDK."""
19
+
20
+ def __call__(self, prompt: str, *, api_key: str, model: str) -> str: ...
21
+
22
+
23
+ def is_offline(offline: bool | None = None) -> bool:
24
+ """Resolve explicit mode, then the environment, then offline-safe defaults."""
25
+
26
+ if offline is not None:
27
+ return offline
28
+ value = os.getenv("GRUDGE_OFFLINE")
29
+ if value is None:
30
+ return config.OFFLINE
31
+ return value.strip().lower() not in {"0", "false", "no", "off"}
32
+
33
+
34
+ def _http_completion(prompt: str, *, api_key: str, model: str) -> str:
35
+ url = os.getenv("GRUDGE_LLM_URL")
36
+ if not url:
37
+ raise ValueError("online completion requires GRUDGE_LLM_URL")
38
+ response = httpx.post(
39
+ url,
40
+ headers={"Authorization": f"Bearer {api_key}"},
41
+ json={
42
+ "model": model,
43
+ "messages": [{"role": "user", "content": prompt}],
44
+ "temperature": 0,
45
+ },
46
+ timeout=30.0,
47
+ )
48
+ response.raise_for_status()
49
+ text = response.json()["choices"][0]["message"]["content"]
50
+ if not isinstance(text, str):
51
+ raise ValueError("completion provider returned non-text content")
52
+ return text
53
+
54
+
55
+ def complete(
56
+ prompt: str,
57
+ *,
58
+ offline: bool | None = None,
59
+ cache_dir: Path | str | None = None,
60
+ provider: Provider | None = None,
61
+ fallback: str = DEMO_DSL,
62
+ ) -> Completion:
63
+ """Return cached, generated, or fallback text without eager credential reads.
64
+
65
+ ``GRUDGE_LLM_API_KEY`` and ``GRUDGE_MODEL`` are consulted only for an online
66
+ cache miss. The HTTP adapter accepts the full chat-completions endpoint in
67
+ ``GRUDGE_LLM_URL``; callers may instead inject another provider adapter.
68
+ """
69
+
70
+ def request(text: str) -> str:
71
+ api_key = os.getenv("GRUDGE_LLM_API_KEY")
72
+ if not api_key:
73
+ raise ValueError("online completion requires GRUDGE_LLM_API_KEY")
74
+ model = os.getenv("GRUDGE_MODEL", config.DEFAULT_MODEL)
75
+ return (provider or _http_completion)(text, api_key=api_key, model=model)
76
+
77
+ return cached_completion(
78
+ prompt,
79
+ None if is_offline(offline) else request,
80
+ cache_dir=cache_dir,
81
+ fallback=fallback,
82
+ )
@@ -0,0 +1 @@
1
+ """Fault-locator stage; both locator algorithms are roadmap stubs."""
grudge/locator/fork.py ADDED
@@ -0,0 +1,10 @@
1
+ """Fork a run at a decision point; 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 fork_run() -> NoReturn:
9
+ """Fork an agent run once the locator stretch stage is implemented."""
10
+ roadmap("locator.fork")
@@ -0,0 +1,10 @@
1
+ """Locate the earliest irreversible failure point; this is a stub."""
2
+
3
+ from typing import NoReturn
4
+
5
+ from grudge._stubs import roadmap
6
+
7
+
8
+ def find_point_of_no_return() -> NoReturn:
9
+ """Find the earliest successful alternative in the future locator stage."""
10
+ roadmap("locator.point_of_no_return")
@@ -0,0 +1,25 @@
1
+ """Phase 6 deterministic predicate parsing and checking public interface."""
2
+
3
+ from grudge.predicates.compiler import Checker, PredicateResult, compile_predicate
4
+ from grudge.predicates.dsl import (
5
+ FilteredTaintFlow,
6
+ PredicateAST,
7
+ PredicateGroup,
8
+ PredicateParseError,
9
+ parse,
10
+ parse_predicate,
11
+ pretty_print,
12
+ )
13
+
14
+ __all__ = [
15
+ "Checker",
16
+ "FilteredTaintFlow",
17
+ "PredicateAST",
18
+ "PredicateGroup",
19
+ "PredicateParseError",
20
+ "PredicateResult",
21
+ "compile_predicate",
22
+ "parse",
23
+ "parse_predicate",
24
+ "pretty_print",
25
+ ]
@@ -0,0 +1,116 @@
1
+ """Phase 6 call matching: exact JSON subsets and explicit text/path operators.
2
+
3
+ Paths are compared lexically (including .. and separator normalization), without
4
+ filesystem access or symlink resolution. Missing arguments never match a filter.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import math
10
+ import posixpath
11
+ from dataclasses import dataclass
12
+ from typing import Any
13
+
14
+ from grudge.predicates.compiler import PredicateResult
15
+ from grudge.schema.predicate import CallShape
16
+ from grudge.schema.trace import ToolCall, Trace
17
+
18
+ _OPERATORS = {"$eq", "$contains", "$under", "$not_under"}
19
+
20
+
21
+ def _json_value(value: Any) -> bool:
22
+ if value is None or isinstance(value, str | bool | int):
23
+ return True
24
+ if isinstance(value, float):
25
+ return math.isfinite(value)
26
+ if isinstance(value, list):
27
+ return all(_json_value(item) for item in value)
28
+ if isinstance(value, dict):
29
+ return all(isinstance(k, str) and _json_value(v) for k, v in value.items())
30
+ return False
31
+
32
+
33
+ def validate_arguments(arguments: dict[str, Any]) -> None:
34
+ """Reject non-JSON values and malformed explicit operator expressions."""
35
+ if not isinstance(arguments, dict):
36
+ raise TypeError("call arguments must be a dictionary")
37
+ for field, value in arguments.items():
38
+ if not isinstance(field, str) or not field:
39
+ raise TypeError("argument names must be nonempty strings")
40
+ if not _json_value(value):
41
+ raise TypeError(f"argument {field!r} must contain finite JSON values")
42
+ if isinstance(value, dict) and any(key.startswith("$") for key in value):
43
+ if len(value) != 1 or next(iter(value)) not in _OPERATORS:
44
+ raise TypeError(f"argument {field!r} has an unknown/ambiguous operator")
45
+ operator = next(iter(value))
46
+ if operator != "$eq" and not isinstance(value[operator], str):
47
+ raise TypeError(f"operator {operator} requires a string")
48
+ if operator in {"$under", "$not_under"} and not value[operator]:
49
+ raise TypeError("path root must not be empty")
50
+
51
+
52
+ def _equal(left: Any, right: Any) -> bool:
53
+ if isinstance(left, bool) != isinstance(right, bool):
54
+ return False
55
+ if isinstance(left, dict) and isinstance(right, dict):
56
+ return left.keys() == right.keys() and all(
57
+ _equal(left[k], right[k]) for k in left
58
+ )
59
+ if isinstance(left, list) and isinstance(right, list):
60
+ return len(left) == len(right) and all(
61
+ _equal(a, b) for a, b in zip(left, right, strict=True)
62
+ )
63
+ return bool(left == right)
64
+
65
+
66
+ def _under(path: str, root: str) -> bool:
67
+ path = posixpath.normpath(path.replace("\\", "/"))
68
+ root = posixpath.normpath(root.replace("\\", "/"))
69
+ if root == ".":
70
+ return not (
71
+ path.startswith(("/", "../")) or path == ".." or ":" in path.split("/")[0]
72
+ )
73
+ return path == root or path.startswith(root.rstrip("/") + "/")
74
+
75
+
76
+ def _matches(value: Any, expected: Any) -> bool:
77
+ if isinstance(expected, dict) and len(expected) == 1:
78
+ operator = next(iter(expected))
79
+ operand = expected[operator]
80
+ if operator == "$eq":
81
+ return _equal(value, operand)
82
+ if operator in {"$contains", "$under", "$not_under"}:
83
+ if not isinstance(value, str):
84
+ return False
85
+ if operator == "$contains":
86
+ return operand in value
87
+ inside = _under(value, operand)
88
+ return not inside if operator == "$not_under" else inside
89
+ return _equal(value, expected)
90
+
91
+
92
+ def matches_call(call: ToolCall, predicate: CallShape) -> bool:
93
+ """Match tool name and supplied arguments; unspecified arguments are allowed."""
94
+ return call.tool == predicate.tool and all(
95
+ field in call.args and _matches(call.args[field], expected)
96
+ for field, expected in predicate.args.items()
97
+ )
98
+
99
+
100
+ @dataclass(frozen=True)
101
+ class CallShapeChecker:
102
+ predicate: CallShape
103
+
104
+ def check(self, trace: Trace) -> PredicateResult:
105
+ matches = [
106
+ call for call in trace.tool_calls() if matches_call(call, self.predicate)
107
+ ]
108
+ return PredicateResult(
109
+ violated=not matches,
110
+ evidence=list(matches),
111
+ explanation=(
112
+ f"Found {len(matches)} matching call(s) to {self.predicate.tool}."
113
+ if matches
114
+ else f"Required call to {self.predicate.tool} was not found."
115
+ ),
116
+ )
@@ -0,0 +1,116 @@
1
+ """Phase 6 deterministic AST-to-checker boundary; no LLM or I/O is involved.
2
+
3
+ CallShape requires a matching call, Ordering requires a prior call at each
4
+ target, and TaintFlow prohibits a flow. Logical groups combine rule satisfaction
5
+ (not violation flags). Evidence always references the original trace objects.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from dataclasses import dataclass
11
+ from typing import Protocol
12
+
13
+ from pydantic import BaseModel, ConfigDict, Field
14
+
15
+ from grudge.predicates.dsl import FilteredTaintFlow, PredicateAST, PredicateGroup
16
+ from grudge.schema.predicate import (
17
+ CallShape,
18
+ Ordering,
19
+ Outcome,
20
+ PredicateSet,
21
+ TaintFlow,
22
+ )
23
+ from grudge.schema.trace import Step, Trace
24
+
25
+
26
+ class PredicateResult(BaseModel):
27
+ """A rule verdict with exact supporting steps, never synthesized evidence."""
28
+
29
+ model_config = ConfigDict(extra="forbid")
30
+ violated: bool
31
+ evidence: list[Step] = Field(default_factory=list)
32
+ explanation: str
33
+
34
+
35
+ class Checker(Protocol):
36
+ """A compiled rule that deterministically evaluates a trace."""
37
+
38
+ def check(self, trace: Trace) -> PredicateResult: ...
39
+
40
+
41
+ @dataclass(frozen=True)
42
+ class _GroupChecker:
43
+ kind: str
44
+ children: tuple[Checker, ...]
45
+
46
+ def check(self, trace: Trace) -> PredicateResult:
47
+ results = [child.check(trace) for child in self.children]
48
+ if self.kind == "not":
49
+ violated = not results[0].violated
50
+ selected = results
51
+ elif self.kind == "all_of":
52
+ violated = any(result.violated for result in results)
53
+ selected = [r for r in results if r.violated == violated]
54
+ else:
55
+ violated = all(result.violated for result in results)
56
+ selected = [r for r in results if r.violated == violated]
57
+ indexes = {step.idx for result in selected for step in result.evidence}
58
+ return PredicateResult(
59
+ violated=violated,
60
+ evidence=[step for step in trace.steps if step.idx in indexes],
61
+ explanation=f"{self.kind}: " + " ".join(r.explanation for r in selected),
62
+ )
63
+
64
+
65
+ def compile_predicate(predicate: PredicateAST) -> Checker:
66
+ """Type-check and snapshot an AST; unsupported/ill-typed input is rejected.
67
+
68
+ Outcome remains a roadmap stub and is rejected at compilation, rather than
69
+ pretending to have checked a run. Use parse_predicate explicitly for text.
70
+ """
71
+ from grudge.predicates.call_shape import CallShapeChecker, validate_arguments
72
+ from grudge.predicates.ordering import OrderingChecker
73
+ from grudge.predicates.outcome import check_outcome
74
+ from grudge.predicates.taint_flow import TaintFlowChecker
75
+
76
+ if isinstance(predicate, CallShape):
77
+ if not isinstance(predicate.tool, str) or not predicate.tool:
78
+ raise TypeError("call tool must be a nonempty string")
79
+ validate_arguments(predicate.args)
80
+ return CallShapeChecker(predicate.model_copy(deep=True))
81
+ if isinstance(predicate, Ordering):
82
+ if not all(
83
+ isinstance(v, str) and v for v in (predicate.before, predicate.after)
84
+ ):
85
+ raise TypeError("ordering tools must be nonempty strings")
86
+ return OrderingChecker(predicate.model_copy(deep=True))
87
+ if isinstance(predicate, FilteredTaintFlow):
88
+ validate_arguments(predicate.source_args)
89
+ flow = predicate.flow
90
+ source_args = predicate.source_args
91
+ elif isinstance(predicate, TaintFlow):
92
+ flow = predicate
93
+ source_args = {}
94
+ elif isinstance(predicate, Outcome):
95
+ check_outcome()
96
+ elif isinstance(predicate, PredicateSet | PredicateGroup):
97
+ if predicate.kind not in {"all_of", "any_of", "not"}:
98
+ raise TypeError("unknown predicate group operator")
99
+ if not predicate.predicates or (
100
+ predicate.kind == "not" and len(predicate.predicates) != 1
101
+ ):
102
+ raise TypeError("groups require children; not requires exactly one")
103
+ return _GroupChecker(
104
+ predicate.kind, tuple(compile_predicate(p) for p in predicate.predicates)
105
+ )
106
+ else:
107
+ raise TypeError(f"unsupported predicate type: {type(predicate).__name__}")
108
+ if not all(isinstance(v, str) and v for v in (flow.source, flow.sink)):
109
+ raise TypeError("taint tools must be nonempty strings")
110
+ if any(
111
+ v is not None and (not isinstance(v, str) or not v)
112
+ for v in (flow.source_field, flow.sink_field)
113
+ ):
114
+ raise TypeError("taint fields must be nonempty strings or None")
115
+ source = CallShape(tool=flow.source, args=source_args)
116
+ return TaintFlowChecker(flow.model_copy(deep=True), source.model_copy(deep=True))