interlockd 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.
- interlockd/__init__.py +3 -0
- interlockd/__main__.py +7 -0
- interlockd/aibom.py +136 -0
- interlockd/api.py +307 -0
- interlockd/auth.py +38 -0
- interlockd/calibration.py +260 -0
- interlockd/cli.py +1920 -0
- interlockd/compose.py +84 -0
- interlockd/config.py +80 -0
- interlockd/corpus.py +505 -0
- interlockd/data/packs/_corpus.yaml +7 -0
- interlockd/data/packs/egress.jsonl +38 -0
- interlockd/data/packs/fileops.jsonl +20 -0
- interlockd/data/packs/git.jsonl +51 -0
- interlockd/data/packs/packages.jsonl +24 -0
- interlockd/data/packs/persistence.jsonl +31 -0
- interlockd/data/packs/secrets.jsonl +22 -0
- interlockd/data/packs/shell.jsonl +75 -0
- interlockd/det/__init__.py +53 -0
- interlockd/det/describe.py +66 -0
- interlockd/det/engine.py +153 -0
- interlockd/det/flow.py +106 -0
- interlockd/det/mandate.py +108 -0
- interlockd/det/mcp.py +70 -0
- interlockd/det/memory.py +50 -0
- interlockd/det/packs.py +531 -0
- interlockd/det/shellparse.py +438 -0
- interlockd/egress.py +124 -0
- interlockd/gateway.py +350 -0
- interlockd/ids.py +25 -0
- interlockd/insights.py +316 -0
- interlockd/integrations/__init__.py +2 -0
- interlockd/integrations/client.py +85 -0
- interlockd/integrations/langchain.py +59 -0
- interlockd/integrations/openai_agents.py +72 -0
- interlockd/integrations/strands.py +69 -0
- interlockd/judges/__init__.py +107 -0
- interlockd/judges/guardrails.py +94 -0
- interlockd/judges/local_probe.py +192 -0
- interlockd/judges/projector.py +82 -0
- interlockd/judges/strands_decider.py +160 -0
- interlockd/ledger.py +62 -0
- interlockd/mcp_http.py +216 -0
- interlockd/mcp_proxy.py +315 -0
- interlockd/mcp_server.py +211 -0
- interlockd/models.py +98 -0
- interlockd/normalize/__init__.py +1 -0
- interlockd/normalize/claude_code.py +89 -0
- interlockd/normalize/cursor.py +59 -0
- interlockd/normalize/gemini_cli.py +55 -0
- interlockd/normalize/kiro.py +65 -0
- interlockd/normalize/more.py +98 -0
- interlockd/ogr.py +542 -0
- interlockd/onboard.py +300 -0
- interlockd/otel.py +139 -0
- interlockd/paths.py +63 -0
- interlockd/pipeline.py +370 -0
- interlockd/policy/__init__.py +220 -0
- interlockd/policy_lint.py +76 -0
- interlockd/render.py +142 -0
- interlockd/sandbox.py +87 -0
- interlockd/scan.py +405 -0
- interlockd/schema/verdict.schema.json +68 -0
- interlockd/selftest.py +101 -0
- interlockd/service.py +247 -0
- interlockd/shims/claude-code/interlock-hook.sh +113 -0
- interlockd/shims/kiro/interlock-hook.mjs +101 -0
- interlockd/shims/kiro/interlock-hook.sh +82 -0
- interlockd/shims/opencode/interlock.js +60 -0
- interlockd/store/__init__.py +22 -0
- interlockd/store/drainer.py +74 -0
- interlockd/store/dynamodb.py +144 -0
- interlockd/store/memory.py +28 -0
- interlockd/store/spool.py +159 -0
- interlockd/testkit.py +401 -0
- interlockd/tiers.py +95 -0
- interlockd/transcript/__init__.py +1 -0
- interlockd/transcript/claude_code.py +73 -0
- interlockd/transcript/kiro.py +102 -0
- interlockd-0.1.0.dist-info/METADATA +125 -0
- interlockd-0.1.0.dist-info/RECORD +84 -0
- interlockd-0.1.0.dist-info/WHEEL +4 -0
- interlockd-0.1.0.dist-info/entry_points.txt +2 -0
- interlockd-0.1.0.dist-info/licenses/LICENSE +201 -0
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
"""GitHub Copilot CLI, Windsurf (Cascade), and Antigravity CLI hook payloads -> InterlockEvent.
|
|
2
|
+
|
|
3
|
+
Built from the official docs (read 2026-10-18), not yet captured from the running tools:
|
|
4
|
+
Copilot CLI https://docs.github.com/en/copilot/reference/hooks-configuration
|
|
5
|
+
Windsurf https://docs.devin.ai/desktop/cascade/hooks
|
|
6
|
+
Antigravity https://antigravity.google/docs/hooks/
|
|
7
|
+
The shim passes `event` ("pre" | "post") because Copilot's camelCase payload does not name its event.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import getpass
|
|
13
|
+
import json
|
|
14
|
+
import socket
|
|
15
|
+
from typing import Any
|
|
16
|
+
|
|
17
|
+
from ..models import AdapterInfo, Capabilities, Context, Identity, InterlockEvent, ToolCall, ToolResult
|
|
18
|
+
from .claude_code import _response_text
|
|
19
|
+
from .kiro import workspace_name
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _event(harness: str, kind: str, name: str, args: Any, *, session: str | None, cwd: str | None, version: str,
|
|
23
|
+
agent_id: str, result: str | None = None, mcp: dict | None = None, caps: Capabilities, transcript: str | None = None):
|
|
24
|
+
if isinstance(args, str):
|
|
25
|
+
try:
|
|
26
|
+
args = json.loads(args)
|
|
27
|
+
except ValueError:
|
|
28
|
+
args = {"value": args}
|
|
29
|
+
if not isinstance(args, dict):
|
|
30
|
+
args = {"value": args} if args is not None else {}
|
|
31
|
+
return InterlockEvent(
|
|
32
|
+
kind=kind, adapter=AdapterInfo(name=harness, version=version),
|
|
33
|
+
identity=Identity(agent_id=agent_id or f"{harness}-{socket.gethostname().split('.')[0]}", agent_type=harness,
|
|
34
|
+
agent_workspace=workspace_name(cwd), agent_user=getpass.getuser()),
|
|
35
|
+
session_hint=session, tool_call=ToolCall(name=str(name or ""), arguments=args),
|
|
36
|
+
context=Context(cwd=cwd, mcp_server=mcp, transcript_path=transcript),
|
|
37
|
+
result=ToolResult(content=result) if result is not None else None, capabilities=caps)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def normalize_copilot(payload: dict[str, Any], *, event: str = "pre", agent_id: str = "", adapter_version: str = "0") -> InterlockEvent:
|
|
41
|
+
p = payload
|
|
42
|
+
hook = str(p.get("hook_event_name") or "")
|
|
43
|
+
kind = "post_action" if event == "post" or hook.lower().startswith("posttooluse") else "pre_action"
|
|
44
|
+
name = p.get("toolName", p.get("tool_name"))
|
|
45
|
+
args = p.get("toolArgs", p.get("tool_input"))
|
|
46
|
+
result = None
|
|
47
|
+
if kind == "post_action":
|
|
48
|
+
tr = p.get("toolResult", p.get("tool_response", p.get("tool_output")))
|
|
49
|
+
if isinstance(tr, dict) and "textResultForLlm" in tr: # measured on Copilot CLI 1.0.98
|
|
50
|
+
tr = tr.get("textResultForLlm")
|
|
51
|
+
result, _ = _response_text(tr)
|
|
52
|
+
return _event("copilot-cli", kind, name, args, session=p.get("sessionId", p.get("session_id")), cwd=p.get("cwd"),
|
|
53
|
+
version=adapter_version, agent_id=agent_id, result=result,
|
|
54
|
+
caps=Capabilities(ask=True, rewrite=True, withhold=True)) # postToolUse modifiedResult replaces
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def normalize_windsurf(payload: dict[str, Any], *, event: str = "pre", agent_id: str = "", adapter_version: str = "0") -> InterlockEvent:
|
|
58
|
+
p = payload
|
|
59
|
+
action = str(p.get("agent_action_name") or "")
|
|
60
|
+
info = p.get("tool_info") or {}
|
|
61
|
+
kind = "post_action" if event == "post" or action.startswith("post_") else "pre_action"
|
|
62
|
+
mcp = None
|
|
63
|
+
if "run_command" in action:
|
|
64
|
+
name, args = "Shell", {"command": info.get("command_line", "")}
|
|
65
|
+
elif "mcp_tool_use" in action:
|
|
66
|
+
server = str(info.get("mcp_server_name") or "")
|
|
67
|
+
name, args = f"mcp__{server}__{info.get('mcp_tool_name', '')}", info.get("mcp_tool_arguments") or {}
|
|
68
|
+
mcp = {"name": server, "source": "windsurf"}
|
|
69
|
+
elif "read_code" in action:
|
|
70
|
+
name, args = "Read", {"file_path": info.get("file_path") or info.get("path") or ""}
|
|
71
|
+
elif "write_code" in action:
|
|
72
|
+
name, args = "Write", {"file_path": info.get("file_path") or info.get("path") or "",
|
|
73
|
+
**({"content": info["content"]} if isinstance(info.get("content"), str) else {})}
|
|
74
|
+
else:
|
|
75
|
+
name, args = action or "unknown", info
|
|
76
|
+
result = None
|
|
77
|
+
if kind == "post_action":
|
|
78
|
+
result, _ = _response_text(info.get("output") or info.get("result") or p.get("output"))
|
|
79
|
+
return _event("windsurf", kind, name, args, session=p.get("trajectory_id"), cwd=info.get("cwd"), version=adapter_version,
|
|
80
|
+
agent_id=agent_id, result=result, mcp=mcp, caps=Capabilities(ask=False, rewrite=False, withhold=False))
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def normalize_antigravity(payload: dict[str, Any], *, event: str = "pre", agent_id: str = "", adapter_version: str = "0") -> InterlockEvent:
|
|
84
|
+
p = payload
|
|
85
|
+
call = p.get("toolCall") or {}
|
|
86
|
+
hook = str(p.get("hook_event_name") or p.get("hookEventName") or "")
|
|
87
|
+
kind = "post_action" if event == "post" or hook == "PostToolUse" else "pre_action"
|
|
88
|
+
name, args = call.get("name", ""), dict(call.get("args") or {})
|
|
89
|
+
if name == "run_command": # documented: args CommandLine, Cwd
|
|
90
|
+
args = {"command": args.get("CommandLine", ""), **({"cwd": args["Cwd"]} if args.get("Cwd") else {})}
|
|
91
|
+
name = "Shell"
|
|
92
|
+
roots = p.get("workspacePaths") or []
|
|
93
|
+
result = None
|
|
94
|
+
if kind == "post_action":
|
|
95
|
+
result, _ = _response_text(p.get("toolResult") or p.get("result") or p.get("output"))
|
|
96
|
+
return _event("antigravity", kind, name, args, session=p.get("conversationId"), cwd=args.get("cwd") or (roots[0] if roots else None),
|
|
97
|
+
version=adapter_version, agent_id=agent_id, result=result, transcript=p.get("transcriptPath"),
|
|
98
|
+
caps=Capabilities(ask=True, rewrite=True, withhold=False)) # PostToolUse returns {} (no replacement)
|
interlockd/ogr.py
ADDED
|
@@ -0,0 +1,542 @@
|
|
|
1
|
+
"""OGR conformance (design §5.2): the `POST /v1/evaluate` seat.
|
|
2
|
+
|
|
3
|
+
An OGR `GuardEvent` carries one half of a model call (`step/request` or `step/response`) in a
|
|
4
|
+
provider-native or canonical `payload`. We map it onto one or more `InterlockEvent`s — one per
|
|
5
|
+
proposed tool call for a `step/response`, one per arriving tool result for a `step/request` — run
|
|
6
|
+
each through the SAME `Daemon.decide()` pipeline the native `/interlock/v1/decide` path uses (the
|
|
7
|
+
judge is consulted whenever a backend is configured, exactly as for pre/post-action), and compose
|
|
8
|
+
the per-event dispositions into a single OGR `Verdict`.
|
|
9
|
+
|
|
10
|
+
Disposition → OGR decision (the wire enum is only `allow`/`block`; `require_approval` was removed in
|
|
11
|
+
OGR v1.x, so the "ask" band is Interlock-native and maps to `block` by default — fail stricter —
|
|
12
|
+
configurable to `allow` with the finding retained):
|
|
13
|
+
|
|
14
|
+
allow, pass, warn -> allow (warn keeps its finding + notice; it is advisory)
|
|
15
|
+
ask -> block | allow (ogr.ask_maps_to, default block)
|
|
16
|
+
block, withhold -> block
|
|
17
|
+
|
|
18
|
+
The verdict validates against `schema/verdict.schema.json`. GuardEvent and Verdict shapes follow
|
|
19
|
+
`interlock/phase1-research.md` §4 (verified against the OGR specification) and the mapping in
|
|
20
|
+
`interlock/phase1-design.md` §5.2.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
from __future__ import annotations
|
|
24
|
+
|
|
25
|
+
import json
|
|
26
|
+
import time
|
|
27
|
+
from dataclasses import dataclass, field
|
|
28
|
+
from pathlib import Path
|
|
29
|
+
from typing import Any
|
|
30
|
+
|
|
31
|
+
from .ids import ulid
|
|
32
|
+
from .models import (
|
|
33
|
+
AdapterInfo,
|
|
34
|
+
Capabilities,
|
|
35
|
+
Context,
|
|
36
|
+
Finding,
|
|
37
|
+
Identity,
|
|
38
|
+
InterlockEvent,
|
|
39
|
+
ToolCall,
|
|
40
|
+
ToolResult,
|
|
41
|
+
UserMessage,
|
|
42
|
+
Verdict,
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
SCHEMA_PATH = Path(__file__).resolve().parent / "schema" / "verdict.schema.json"
|
|
46
|
+
OGR_PROVIDER = "interlock"
|
|
47
|
+
PROTOCOLS = ("canonical", "openai.chat", "openai.responses", "anthropic.messages")
|
|
48
|
+
# OGR peers can drop a single call, withhold a result, or take a redaction span, so the daemon may
|
|
49
|
+
# emit its richest dispositions and the OGR verdict layer renders them as decision + continuation.
|
|
50
|
+
OGR_CAPABILITIES = Capabilities(ask=True, rewrite=True, withhold=True)
|
|
51
|
+
|
|
52
|
+
MAX_USER_MESSAGES = 6
|
|
53
|
+
_USER_MSG_CAP = 4000
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
# ----------------------------------------------------------------------------- payload -> trajectory
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
@dataclass
|
|
60
|
+
class Trajectory:
|
|
61
|
+
"""The parts of a GuardEvent payload the pipeline needs, protocol-independent."""
|
|
62
|
+
|
|
63
|
+
user_messages: list[str] = field(default_factory=list)
|
|
64
|
+
prior_calls: list[dict[str, Any]] = field(default_factory=list) # {name, arguments_summary}
|
|
65
|
+
tool_calls: list[dict[str, Any]] = field(default_factory=list) # proposed now: {id, name, arguments}
|
|
66
|
+
tool_results: list[dict[str, Any]] = field(default_factory=list) # arriving now: {id, name, content}
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _text_of(content: Any) -> str:
|
|
70
|
+
"""Flatten a message `content` (string, provider content-block list, or dict) to plain text."""
|
|
71
|
+
if content is None:
|
|
72
|
+
return ""
|
|
73
|
+
if isinstance(content, str):
|
|
74
|
+
return content
|
|
75
|
+
if isinstance(content, list):
|
|
76
|
+
parts: list[str] = []
|
|
77
|
+
for b in content:
|
|
78
|
+
if isinstance(b, str):
|
|
79
|
+
parts.append(b)
|
|
80
|
+
elif isinstance(b, dict):
|
|
81
|
+
if isinstance(b.get("text"), str):
|
|
82
|
+
parts.append(b["text"])
|
|
83
|
+
elif isinstance(b.get("content"), str):
|
|
84
|
+
parts.append(b["content"])
|
|
85
|
+
return "\n".join(p for p in parts if p)
|
|
86
|
+
if isinstance(content, dict):
|
|
87
|
+
if isinstance(content.get("text"), str):
|
|
88
|
+
return content["text"]
|
|
89
|
+
if isinstance(content.get("content"), str):
|
|
90
|
+
return content["content"]
|
|
91
|
+
return str(content)
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def _coerce_args(arguments: Any) -> dict[str, Any]:
|
|
95
|
+
"""Tool arguments must be an object; OpenAI ships them as a JSON string."""
|
|
96
|
+
if isinstance(arguments, dict):
|
|
97
|
+
return arguments
|
|
98
|
+
if isinstance(arguments, str):
|
|
99
|
+
s = arguments.strip()
|
|
100
|
+
if not s:
|
|
101
|
+
return {}
|
|
102
|
+
try:
|
|
103
|
+
parsed = json.loads(s)
|
|
104
|
+
return parsed if isinstance(parsed, dict) else {"value": parsed}
|
|
105
|
+
except json.JSONDecodeError:
|
|
106
|
+
return {"value": arguments}
|
|
107
|
+
if arguments is None:
|
|
108
|
+
return {}
|
|
109
|
+
return {"value": arguments}
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def _args_summary(name: str, arguments: dict[str, Any]) -> str:
|
|
113
|
+
for k in ("command", "cmd", "script", "path", "file_path", "url", "query"):
|
|
114
|
+
v = arguments.get(k)
|
|
115
|
+
if isinstance(v, str) and v:
|
|
116
|
+
return f"{name} {v}"[:200]
|
|
117
|
+
return f"{name} {json.dumps(arguments, ensure_ascii=False, sort_keys=True)}"[:200]
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def _norm_call(cid: Any, name: Any, arguments: Any) -> dict[str, Any]:
|
|
121
|
+
return {"id": cid if isinstance(cid, str) else None, "name": str(name or ""), "arguments": _coerce_args(arguments)}
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def _openai_calls(obj: dict[str, Any]) -> list[dict[str, Any]]:
|
|
125
|
+
out: list[dict[str, Any]] = []
|
|
126
|
+
for tc in (obj.get("tool_calls") or []):
|
|
127
|
+
if not isinstance(tc, dict):
|
|
128
|
+
continue
|
|
129
|
+
fn = tc.get("function") if isinstance(tc.get("function"), dict) else {}
|
|
130
|
+
out.append(_norm_call(tc.get("id"), fn.get("name") or tc.get("name"), fn.get("arguments", tc.get("arguments"))))
|
|
131
|
+
return out
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def _anthropic_calls(content: Any) -> list[dict[str, Any]]:
|
|
135
|
+
out: list[dict[str, Any]] = []
|
|
136
|
+
for b in (content or []):
|
|
137
|
+
if isinstance(b, dict) and b.get("type") == "tool_use":
|
|
138
|
+
out.append(_norm_call(b.get("id"), b.get("name"), b.get("input")))
|
|
139
|
+
return out
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def _parse_canonical(payload: dict[str, Any], traj: Trajectory) -> list[list[dict[str, Any]]]:
|
|
143
|
+
assistant_call_seq: list[list[dict[str, Any]]] = []
|
|
144
|
+
for m in (payload.get("messages") or []):
|
|
145
|
+
if not isinstance(m, dict):
|
|
146
|
+
continue
|
|
147
|
+
role = m.get("role")
|
|
148
|
+
if role == "user":
|
|
149
|
+
traj.user_messages.append(_text_of(m.get("content")))
|
|
150
|
+
elif role == "assistant":
|
|
151
|
+
calls = _openai_calls(m) or _anthropic_calls(m.get("content"))
|
|
152
|
+
if calls:
|
|
153
|
+
assistant_call_seq.append(calls)
|
|
154
|
+
elif role in ("tool", "tool_result"):
|
|
155
|
+
traj.tool_results.append({"id": m.get("tool_call_id") or m.get("id"), "name": m.get("name"),
|
|
156
|
+
"content": _text_of(m.get("content"))})
|
|
157
|
+
explicit = [_norm_call(tc.get("id"), tc.get("name"), tc.get("arguments"))
|
|
158
|
+
for tc in (payload.get("tool_calls") or []) if isinstance(tc, dict)]
|
|
159
|
+
if explicit:
|
|
160
|
+
traj.tool_calls = explicit
|
|
161
|
+
return assistant_call_seq
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def _parse_openai(payload: dict[str, Any], traj: Trajectory) -> list[list[dict[str, Any]]]:
|
|
165
|
+
assistant_call_seq: list[list[dict[str, Any]]] = []
|
|
166
|
+
for m in (payload.get("messages") or []):
|
|
167
|
+
if not isinstance(m, dict):
|
|
168
|
+
continue
|
|
169
|
+
role = m.get("role")
|
|
170
|
+
if role == "user":
|
|
171
|
+
traj.user_messages.append(_text_of(m.get("content")))
|
|
172
|
+
elif role == "assistant":
|
|
173
|
+
calls = _openai_calls(m)
|
|
174
|
+
if calls:
|
|
175
|
+
assistant_call_seq.append(calls)
|
|
176
|
+
elif role == "tool":
|
|
177
|
+
traj.tool_results.append({"id": m.get("tool_call_id"), "name": m.get("name"),
|
|
178
|
+
"content": _text_of(m.get("content"))})
|
|
179
|
+
resp = None
|
|
180
|
+
choices = payload.get("choices")
|
|
181
|
+
if isinstance(choices, list) and choices and isinstance(choices[0], dict):
|
|
182
|
+
resp = choices[0].get("message")
|
|
183
|
+
elif isinstance(payload.get("message"), dict):
|
|
184
|
+
resp = payload["message"]
|
|
185
|
+
explicit = _openai_calls(resp) if isinstance(resp, dict) else _openai_calls(payload)
|
|
186
|
+
if explicit:
|
|
187
|
+
traj.tool_calls = explicit
|
|
188
|
+
return assistant_call_seq
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def _parse_anthropic(payload: dict[str, Any], traj: Trajectory) -> list[list[dict[str, Any]]]:
|
|
192
|
+
assistant_call_seq: list[list[dict[str, Any]]] = []
|
|
193
|
+
for m in (payload.get("messages") or []):
|
|
194
|
+
if not isinstance(m, dict):
|
|
195
|
+
continue
|
|
196
|
+
role, content = m.get("role"), m.get("content")
|
|
197
|
+
if role == "user":
|
|
198
|
+
if isinstance(content, list):
|
|
199
|
+
texts: list[str] = []
|
|
200
|
+
for b in content:
|
|
201
|
+
if isinstance(b, dict) and b.get("type") == "tool_result":
|
|
202
|
+
traj.tool_results.append({"id": b.get("tool_use_id"), "name": None,
|
|
203
|
+
"content": _text_of(b.get("content"))})
|
|
204
|
+
else:
|
|
205
|
+
t = _text_of(b)
|
|
206
|
+
if t:
|
|
207
|
+
texts.append(t)
|
|
208
|
+
if texts:
|
|
209
|
+
traj.user_messages.append("\n".join(texts))
|
|
210
|
+
else:
|
|
211
|
+
traj.user_messages.append(_text_of(content))
|
|
212
|
+
elif role == "assistant":
|
|
213
|
+
calls = _anthropic_calls(content)
|
|
214
|
+
if calls:
|
|
215
|
+
assistant_call_seq.append(calls)
|
|
216
|
+
explicit = _anthropic_calls(payload.get("content"))
|
|
217
|
+
if explicit:
|
|
218
|
+
traj.tool_calls = explicit
|
|
219
|
+
return assistant_call_seq
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
def parse_payload(proto: str, payload: dict[str, Any], kind: str = "step/response") -> Trajectory:
|
|
223
|
+
"""Map a provider/canonical payload onto a protocol-independent Trajectory.
|
|
224
|
+
|
|
225
|
+
`kind` disambiguates where the trailing assistant tool calls belong: on a `step/response` the
|
|
226
|
+
last assistant message IS the response under judgement, so (absent an explicit response position)
|
|
227
|
+
its calls become the proposed `tool_calls`; on a `step/request` every assistant call already ran
|
|
228
|
+
and is prior context, and what is being judged are the tool results that just arrived.
|
|
229
|
+
"""
|
|
230
|
+
traj = Trajectory()
|
|
231
|
+
if proto == "anthropic.messages":
|
|
232
|
+
seq = _parse_anthropic(payload, traj)
|
|
233
|
+
elif proto in ("openai.chat", "openai.responses"):
|
|
234
|
+
seq = _parse_openai(payload, traj)
|
|
235
|
+
else:
|
|
236
|
+
seq = _parse_canonical(payload, traj)
|
|
237
|
+
# When the response position carried no explicit tool calls, the last assistant message in the
|
|
238
|
+
# trajectory IS the response being judged; earlier assistant calls are prior context.
|
|
239
|
+
if kind != "step/request" and not traj.tool_calls and seq:
|
|
240
|
+
traj.tool_calls = seq[-1]
|
|
241
|
+
seq = seq[:-1]
|
|
242
|
+
for calls in seq:
|
|
243
|
+
for c in calls:
|
|
244
|
+
traj.prior_calls.append({"name": c["name"], "arguments_summary": _args_summary(c["name"], c["arguments"])})
|
|
245
|
+
return traj
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
# ----------------------------------------------------------------------------- GuardEvent -> events
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
def _user_messages(texts: list[str]) -> list[UserMessage]:
|
|
252
|
+
kept = [t for t in texts if t and t.strip()][-MAX_USER_MESSAGES:]
|
|
253
|
+
k = len(kept)
|
|
254
|
+
return [UserMessage(n=i - k, text=t[:_USER_MSG_CAP]) for i, t in enumerate(kept)]
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
def guard_event_to_events(ge: dict[str, Any]) -> list[InterlockEvent]:
|
|
258
|
+
"""Map one GuardEvent to the InterlockEvents its tool calls / results require."""
|
|
259
|
+
if not isinstance(ge, dict):
|
|
260
|
+
raise TypeError("GuardEvent must be a JSON object")
|
|
261
|
+
proto = ge.get("llm_protocol") or "canonical"
|
|
262
|
+
if proto not in PROTOCOLS:
|
|
263
|
+
proto = "canonical"
|
|
264
|
+
kind = str(ge.get("kind") or "step/response")
|
|
265
|
+
payload = ge.get("payload")
|
|
266
|
+
if not isinstance(payload, dict):
|
|
267
|
+
payload = {}
|
|
268
|
+
traj = parse_payload(proto, payload, kind)
|
|
269
|
+
|
|
270
|
+
identity = Identity(
|
|
271
|
+
agent_id=str(ge.get("agent_id") or ""),
|
|
272
|
+
agent_type=str(ge.get("agent_type") or ""),
|
|
273
|
+
agent_workspace=str(ge.get("agent_workspace") or ""),
|
|
274
|
+
agent_user=str(ge.get("agent_user") or ""),
|
|
275
|
+
)
|
|
276
|
+
session = ge.get("session_hint") or ge.get("step_id")
|
|
277
|
+
cwd = payload.get("cwd") if isinstance(payload.get("cwd"), str) else None
|
|
278
|
+
ums = _user_messages(traj.user_messages)
|
|
279
|
+
adapter = AdapterInfo(name="ogr", version=str(ge.get("integration") or proto))
|
|
280
|
+
|
|
281
|
+
def ctx() -> Context:
|
|
282
|
+
return Context(cwd=cwd, user_messages=ums or None, prior_tool_calls=list(traj.prior_calls))
|
|
283
|
+
|
|
284
|
+
def pre(call: dict[str, Any]) -> InterlockEvent:
|
|
285
|
+
return InterlockEvent(kind="pre_action", adapter=adapter, identity=identity, session_hint=session,
|
|
286
|
+
tool_call=ToolCall(id=call.get("id"), name=call.get("name") or "", arguments=call.get("arguments") or {}),
|
|
287
|
+
context=ctx(), capabilities=OGR_CAPABILITIES)
|
|
288
|
+
|
|
289
|
+
def post(res: dict[str, Any]) -> InterlockEvent:
|
|
290
|
+
return InterlockEvent(kind="post_action", adapter=adapter, identity=identity, session_hint=session,
|
|
291
|
+
tool_call=ToolCall(id=res.get("id"), name=res.get("name") or "tool_result", arguments={}),
|
|
292
|
+
context=ctx(), result=ToolResult(content=res.get("content") or "", evidence={}),
|
|
293
|
+
capabilities=OGR_CAPABILITIES)
|
|
294
|
+
|
|
295
|
+
want_post = kind == "step/request"
|
|
296
|
+
if want_post and traj.tool_results:
|
|
297
|
+
return [post(r) for r in traj.tool_results]
|
|
298
|
+
if traj.tool_calls:
|
|
299
|
+
return [pre(c) for c in traj.tool_calls]
|
|
300
|
+
if traj.tool_results: # lenient: judge whatever is present even if kind disagreed
|
|
301
|
+
return [post(r) for r in traj.tool_results]
|
|
302
|
+
return []
|
|
303
|
+
|
|
304
|
+
|
|
305
|
+
# ----------------------------------------------------------------------------- verdict composition
|
|
306
|
+
|
|
307
|
+
|
|
308
|
+
def _map_disposition(disposition: str, ask_maps_to: str) -> str:
|
|
309
|
+
if disposition in ("allow", "pass", "warn"):
|
|
310
|
+
return "allow"
|
|
311
|
+
if disposition == "ask":
|
|
312
|
+
return "allow" if ask_maps_to == "allow" else "block"
|
|
313
|
+
if disposition in ("block", "withhold"):
|
|
314
|
+
return "block"
|
|
315
|
+
return "allow"
|
|
316
|
+
|
|
317
|
+
|
|
318
|
+
def _finding_to_ogr(f: Finding) -> dict[str, Any]:
|
|
319
|
+
return {
|
|
320
|
+
"category": f.category,
|
|
321
|
+
"severity": f.severity,
|
|
322
|
+
"path": f.path,
|
|
323
|
+
"start": f.start,
|
|
324
|
+
"end": f.end,
|
|
325
|
+
"score": f.score,
|
|
326
|
+
"fp": False,
|
|
327
|
+
"whitelisted": bool(f.whitelisted),
|
|
328
|
+
"subject": f.subject,
|
|
329
|
+
"detector": f.detector,
|
|
330
|
+
"rule": f.rule,
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
|
|
334
|
+
def compose_ogr_verdict(event_id: str, pairs: list[tuple[InterlockEvent, Verdict]], *,
|
|
335
|
+
ask_maps_to: str = "block", latency_ms: float = 0.0,
|
|
336
|
+
provider: str = OGR_PROVIDER) -> dict[str, Any]:
|
|
337
|
+
"""Combine per-InterlockEvent verdicts into one OGR Verdict (deny-wins)."""
|
|
338
|
+
decision = "allow"
|
|
339
|
+
findings: list[dict[str, Any]] = []
|
|
340
|
+
unjudged: list[str] = []
|
|
341
|
+
drop_calls: list[str] = []
|
|
342
|
+
spans: list[dict[str, Any]] = []
|
|
343
|
+
notices: list[str] = []
|
|
344
|
+
withhold = False
|
|
345
|
+
|
|
346
|
+
for ev, v in pairs:
|
|
347
|
+
for f in v.findings:
|
|
348
|
+
findings.append(_finding_to_ogr(f))
|
|
349
|
+
if (f.category or "").startswith("security.secret_leak") and f.start is not None and f.end is not None:
|
|
350
|
+
spans.append({"path": f.path, "start": f.start, "end": f.end})
|
|
351
|
+
unjudged.extend(v.unjudged)
|
|
352
|
+
if _map_disposition(v.disposition, ask_maps_to) == "block":
|
|
353
|
+
decision = "block"
|
|
354
|
+
if ev.kind == "pre_action" and ev.tool_call.id:
|
|
355
|
+
drop_calls.append(ev.tool_call.id)
|
|
356
|
+
if v.disposition == "withhold":
|
|
357
|
+
withhold = True
|
|
358
|
+
if v.notice:
|
|
359
|
+
notices.append(v.notice)
|
|
360
|
+
elif v.disposition == "warn" and v.notice:
|
|
361
|
+
notices.append(v.notice)
|
|
362
|
+
|
|
363
|
+
notice = " ".join(dict.fromkeys(notices)) or None
|
|
364
|
+
if withhold:
|
|
365
|
+
continuation = {"style": "withhold", "notice": notice}
|
|
366
|
+
elif drop_calls:
|
|
367
|
+
continuation = {"style": "drop_calls", "calls": drop_calls, "notice": notice}
|
|
368
|
+
else:
|
|
369
|
+
continuation = {"style": "answer", "notice": notice}
|
|
370
|
+
|
|
371
|
+
return {
|
|
372
|
+
"event_id": event_id,
|
|
373
|
+
"provider": provider,
|
|
374
|
+
"decision": decision,
|
|
375
|
+
"findings": findings,
|
|
376
|
+
"modifications": {"spans": spans},
|
|
377
|
+
"unjudged": sorted(set(unjudged)),
|
|
378
|
+
"latency_ms": round(latency_ms, 2),
|
|
379
|
+
"continuation": continuation,
|
|
380
|
+
"timing": {},
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
|
|
384
|
+
def evaluate_guard_event(daemon, ge: dict[str, Any], *, ask_maps_to: str = "block") -> dict[str, Any]:
|
|
385
|
+
"""The body of `POST /v1/evaluate`: GuardEvent -> InterlockEvents -> decide() -> OGR Verdict."""
|
|
386
|
+
t0 = time.perf_counter()
|
|
387
|
+
event_id = ulid()
|
|
388
|
+
events = guard_event_to_events(ge)
|
|
389
|
+
pairs = [(ev, daemon.decide(ev)) for ev in events]
|
|
390
|
+
latency = (time.perf_counter() - t0) * 1000
|
|
391
|
+
return compose_ogr_verdict(event_id, pairs, ask_maps_to=ask_maps_to, latency_ms=latency)
|
|
392
|
+
|
|
393
|
+
|
|
394
|
+
# ----------------------------------------------------------------------------- schema + validation
|
|
395
|
+
|
|
396
|
+
_SCHEMA_CACHE: dict[str, Any] | None = None
|
|
397
|
+
|
|
398
|
+
|
|
399
|
+
def load_schema() -> dict[str, Any]:
|
|
400
|
+
global _SCHEMA_CACHE
|
|
401
|
+
if _SCHEMA_CACHE is None:
|
|
402
|
+
_SCHEMA_CACHE = json.loads(SCHEMA_PATH.read_text())
|
|
403
|
+
return _SCHEMA_CACHE
|
|
404
|
+
|
|
405
|
+
|
|
406
|
+
def _is_type(value: Any, t: str) -> bool:
|
|
407
|
+
if t == "object":
|
|
408
|
+
return isinstance(value, dict)
|
|
409
|
+
if t == "array":
|
|
410
|
+
return isinstance(value, list)
|
|
411
|
+
if t == "string":
|
|
412
|
+
return isinstance(value, str)
|
|
413
|
+
if t == "integer":
|
|
414
|
+
return isinstance(value, int) and not isinstance(value, bool)
|
|
415
|
+
if t == "number":
|
|
416
|
+
return isinstance(value, (int, float)) and not isinstance(value, bool)
|
|
417
|
+
if t == "boolean":
|
|
418
|
+
return isinstance(value, bool)
|
|
419
|
+
if t == "null":
|
|
420
|
+
return value is None
|
|
421
|
+
return True
|
|
422
|
+
|
|
423
|
+
|
|
424
|
+
def _schema_errors(instance: Any, schema: dict[str, Any], path: str = "$") -> list[str]:
|
|
425
|
+
"""A small, dependency-free Draft-07 subset validator — enough for the OGR verdict schema."""
|
|
426
|
+
errors: list[str] = []
|
|
427
|
+
t = schema.get("type")
|
|
428
|
+
if t is not None:
|
|
429
|
+
types = t if isinstance(t, list) else [t]
|
|
430
|
+
if not any(_is_type(instance, tt) for tt in types):
|
|
431
|
+
errors.append(f"{path}: expected type {t}, got {type(instance).__name__}")
|
|
432
|
+
return errors
|
|
433
|
+
if "enum" in schema and instance not in schema["enum"]:
|
|
434
|
+
errors.append(f"{path}: {instance!r} not in enum {schema['enum']}")
|
|
435
|
+
if "const" in schema and instance != schema["const"]:
|
|
436
|
+
errors.append(f"{path}: {instance!r} != const {schema['const']!r}")
|
|
437
|
+
if isinstance(instance, (int, float)) and not isinstance(instance, bool):
|
|
438
|
+
if "minimum" in schema and instance < schema["minimum"]:
|
|
439
|
+
errors.append(f"{path}: {instance} < minimum {schema['minimum']}")
|
|
440
|
+
if "maximum" in schema and instance > schema["maximum"]:
|
|
441
|
+
errors.append(f"{path}: {instance} > maximum {schema['maximum']}")
|
|
442
|
+
if isinstance(instance, dict):
|
|
443
|
+
props = schema.get("properties", {})
|
|
444
|
+
for req in schema.get("required", []):
|
|
445
|
+
if req not in instance:
|
|
446
|
+
errors.append(f"{path}: missing required property '{req}'")
|
|
447
|
+
ap = schema.get("additionalProperties", True)
|
|
448
|
+
for k, v in instance.items():
|
|
449
|
+
if k in props:
|
|
450
|
+
errors += _schema_errors(v, props[k], f"{path}.{k}")
|
|
451
|
+
elif ap is False:
|
|
452
|
+
errors.append(f"{path}: additional property '{k}' not allowed")
|
|
453
|
+
elif isinstance(ap, dict):
|
|
454
|
+
errors += _schema_errors(v, ap, f"{path}.{k}")
|
|
455
|
+
if isinstance(instance, list) and "items" in schema:
|
|
456
|
+
for i, it in enumerate(instance):
|
|
457
|
+
errors += _schema_errors(it, schema["items"], f"{path}[{i}]")
|
|
458
|
+
if "oneOf" in schema:
|
|
459
|
+
matched = sum(1 for s in schema["oneOf"] if not _schema_errors(instance, s, path))
|
|
460
|
+
if matched != 1:
|
|
461
|
+
errors.append(f"{path}: matched {matched} of oneOf, expected exactly 1")
|
|
462
|
+
return errors
|
|
463
|
+
|
|
464
|
+
|
|
465
|
+
def validate_verdict(verdict: Any, schema: dict[str, Any] | None = None) -> list[str]:
|
|
466
|
+
"""Return a list of human-readable schema errors ([] means valid)."""
|
|
467
|
+
return _schema_errors(verdict, schema or load_schema())
|
|
468
|
+
|
|
469
|
+
|
|
470
|
+
# ----------------------------------------------------------------------------- seed-v0 reader
|
|
471
|
+
|
|
472
|
+
|
|
473
|
+
def _as_case(raw: Any, default_suite: str, idx: int) -> dict[str, Any] | None:
|
|
474
|
+
if not isinstance(raw, dict):
|
|
475
|
+
return None
|
|
476
|
+
ge = raw.get("guard_event") or raw.get("guardEvent") or raw.get("event") or raw.get("input")
|
|
477
|
+
if ge is None:
|
|
478
|
+
if any(k in raw for k in ("llm_protocol", "payload", "kind", "agent_id", "step_id")):
|
|
479
|
+
ge, meta = raw, {}
|
|
480
|
+
else:
|
|
481
|
+
return None
|
|
482
|
+
else:
|
|
483
|
+
meta = raw
|
|
484
|
+
if not isinstance(ge, dict):
|
|
485
|
+
return None
|
|
486
|
+
cid = meta.get("id") or meta.get("case_id") or f"{default_suite}-{idx}"
|
|
487
|
+
suite = meta.get("suite") or meta.get("category") or default_suite
|
|
488
|
+
expect = meta.get("expect") if meta.get("expect") is not None else (meta.get("label") or meta.get("expected"))
|
|
489
|
+
return {"id": str(cid), "suite": str(suite), "expect": (str(expect) if expect is not None else None), "guard_event": ge}
|
|
490
|
+
|
|
491
|
+
|
|
492
|
+
def _read_one(path: Path) -> list[dict[str, Any]]:
|
|
493
|
+
suite = path.stem
|
|
494
|
+
text = path.read_text()
|
|
495
|
+
cases: list[dict[str, Any]] = []
|
|
496
|
+
if path.suffix == ".jsonl":
|
|
497
|
+
for i, line in enumerate(text.splitlines()):
|
|
498
|
+
line = line.strip()
|
|
499
|
+
if not line:
|
|
500
|
+
continue
|
|
501
|
+
try:
|
|
502
|
+
raw = json.loads(line)
|
|
503
|
+
except json.JSONDecodeError:
|
|
504
|
+
continue
|
|
505
|
+
c = _as_case(raw, suite, i)
|
|
506
|
+
if c:
|
|
507
|
+
cases.append(c)
|
|
508
|
+
return cases
|
|
509
|
+
data = json.loads(text) if text.strip() else []
|
|
510
|
+
if isinstance(data, dict):
|
|
511
|
+
data = data.get("cases") or data.get("items") or data.get("data") or data.get("events") or []
|
|
512
|
+
if isinstance(data, list):
|
|
513
|
+
for i, raw in enumerate(data):
|
|
514
|
+
c = _as_case(raw, suite, i)
|
|
515
|
+
if c:
|
|
516
|
+
cases.append(c)
|
|
517
|
+
return cases
|
|
518
|
+
|
|
519
|
+
|
|
520
|
+
def read_seed(path: Path) -> list[dict[str, Any]]:
|
|
521
|
+
"""Read an OGR seed suite: a single .json/.jsonl file, or a directory of them."""
|
|
522
|
+
path = Path(path)
|
|
523
|
+
cases: list[dict[str, Any]] = []
|
|
524
|
+
if path.is_dir():
|
|
525
|
+
for p in sorted(path.rglob("*")):
|
|
526
|
+
if p.suffix in (".jsonl", ".json") and p.is_file():
|
|
527
|
+
cases.extend(_read_one(p))
|
|
528
|
+
elif path.is_file():
|
|
529
|
+
cases = _read_one(path)
|
|
530
|
+
return cases
|
|
531
|
+
|
|
532
|
+
|
|
533
|
+
def expected_decision(expect: str | None) -> str | None:
|
|
534
|
+
"""Normalize a seed label to the OGR decision it should produce (None if unlabelled)."""
|
|
535
|
+
if not expect:
|
|
536
|
+
return None
|
|
537
|
+
e = expect.strip().lower()
|
|
538
|
+
if e in ("block", "deny", "attack", "unsafe", "malicious", "injection", "positive"):
|
|
539
|
+
return "block"
|
|
540
|
+
if e in ("allow", "pass", "benign", "safe", "negative", "clean"):
|
|
541
|
+
return "allow"
|
|
542
|
+
return None
|