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,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))
|