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.
Files changed (84) hide show
  1. interlockd/__init__.py +3 -0
  2. interlockd/__main__.py +7 -0
  3. interlockd/aibom.py +136 -0
  4. interlockd/api.py +307 -0
  5. interlockd/auth.py +38 -0
  6. interlockd/calibration.py +260 -0
  7. interlockd/cli.py +1920 -0
  8. interlockd/compose.py +84 -0
  9. interlockd/config.py +80 -0
  10. interlockd/corpus.py +505 -0
  11. interlockd/data/packs/_corpus.yaml +7 -0
  12. interlockd/data/packs/egress.jsonl +38 -0
  13. interlockd/data/packs/fileops.jsonl +20 -0
  14. interlockd/data/packs/git.jsonl +51 -0
  15. interlockd/data/packs/packages.jsonl +24 -0
  16. interlockd/data/packs/persistence.jsonl +31 -0
  17. interlockd/data/packs/secrets.jsonl +22 -0
  18. interlockd/data/packs/shell.jsonl +75 -0
  19. interlockd/det/__init__.py +53 -0
  20. interlockd/det/describe.py +66 -0
  21. interlockd/det/engine.py +153 -0
  22. interlockd/det/flow.py +106 -0
  23. interlockd/det/mandate.py +108 -0
  24. interlockd/det/mcp.py +70 -0
  25. interlockd/det/memory.py +50 -0
  26. interlockd/det/packs.py +531 -0
  27. interlockd/det/shellparse.py +438 -0
  28. interlockd/egress.py +124 -0
  29. interlockd/gateway.py +350 -0
  30. interlockd/ids.py +25 -0
  31. interlockd/insights.py +316 -0
  32. interlockd/integrations/__init__.py +2 -0
  33. interlockd/integrations/client.py +85 -0
  34. interlockd/integrations/langchain.py +59 -0
  35. interlockd/integrations/openai_agents.py +72 -0
  36. interlockd/integrations/strands.py +69 -0
  37. interlockd/judges/__init__.py +107 -0
  38. interlockd/judges/guardrails.py +94 -0
  39. interlockd/judges/local_probe.py +192 -0
  40. interlockd/judges/projector.py +82 -0
  41. interlockd/judges/strands_decider.py +160 -0
  42. interlockd/ledger.py +62 -0
  43. interlockd/mcp_http.py +216 -0
  44. interlockd/mcp_proxy.py +315 -0
  45. interlockd/mcp_server.py +211 -0
  46. interlockd/models.py +98 -0
  47. interlockd/normalize/__init__.py +1 -0
  48. interlockd/normalize/claude_code.py +89 -0
  49. interlockd/normalize/cursor.py +59 -0
  50. interlockd/normalize/gemini_cli.py +55 -0
  51. interlockd/normalize/kiro.py +65 -0
  52. interlockd/normalize/more.py +98 -0
  53. interlockd/ogr.py +542 -0
  54. interlockd/onboard.py +300 -0
  55. interlockd/otel.py +139 -0
  56. interlockd/paths.py +63 -0
  57. interlockd/pipeline.py +370 -0
  58. interlockd/policy/__init__.py +220 -0
  59. interlockd/policy_lint.py +76 -0
  60. interlockd/render.py +142 -0
  61. interlockd/sandbox.py +87 -0
  62. interlockd/scan.py +405 -0
  63. interlockd/schema/verdict.schema.json +68 -0
  64. interlockd/selftest.py +101 -0
  65. interlockd/service.py +247 -0
  66. interlockd/shims/claude-code/interlock-hook.sh +113 -0
  67. interlockd/shims/kiro/interlock-hook.mjs +101 -0
  68. interlockd/shims/kiro/interlock-hook.sh +82 -0
  69. interlockd/shims/opencode/interlock.js +60 -0
  70. interlockd/store/__init__.py +22 -0
  71. interlockd/store/drainer.py +74 -0
  72. interlockd/store/dynamodb.py +144 -0
  73. interlockd/store/memory.py +28 -0
  74. interlockd/store/spool.py +159 -0
  75. interlockd/testkit.py +401 -0
  76. interlockd/tiers.py +95 -0
  77. interlockd/transcript/__init__.py +1 -0
  78. interlockd/transcript/claude_code.py +73 -0
  79. interlockd/transcript/kiro.py +102 -0
  80. interlockd-0.1.0.dist-info/METADATA +125 -0
  81. interlockd-0.1.0.dist-info/RECORD +84 -0
  82. interlockd-0.1.0.dist-info/WHEEL +4 -0
  83. interlockd-0.1.0.dist-info/entry_points.txt +2 -0
  84. 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