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
interlockd/mcp_server.py
ADDED
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
"""`interlock mcp`: Interlock as an MCP server (stdio, JSON-RPC 2.0, newline-delimited), for use inside an agent.
|
|
2
|
+
|
|
3
|
+
Advisory and read-only by design. Enforcement lives in the hooks, which the agent cannot skip; this server only
|
|
4
|
+
answers questions: what would the gate decide for this action, what was stopped and why, are my MCP servers risky,
|
|
5
|
+
is protection on. It can make an agent more careful, never less: no tool approves an action, changes the mode, or
|
|
6
|
+
edits policy (`suggest_rule_override` returns a snippet for a human to apply).
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import json
|
|
12
|
+
import sys
|
|
13
|
+
import tempfile
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
from typing import Any, TextIO
|
|
16
|
+
|
|
17
|
+
from . import __version__
|
|
18
|
+
from .config import Settings
|
|
19
|
+
|
|
20
|
+
PROTOCOL_VERSION = "2025-06-18"
|
|
21
|
+
|
|
22
|
+
TOOLS: list[dict[str, Any]] = [
|
|
23
|
+
{"name": "check_action",
|
|
24
|
+
"description": ("Ask Interlock what its gate would decide for an action BEFORE you take it: a shell command, or "
|
|
25
|
+
"any tool with arguments. Returns allow / ask / block with the rules that fired. Advisory: the "
|
|
26
|
+
"real gate still runs on the actual call."),
|
|
27
|
+
"inputSchema": {"type": "object", "properties": {
|
|
28
|
+
"command": {"type": "string", "description": "A shell command to check"},
|
|
29
|
+
"tool": {"type": "string", "description": "Tool name, e.g. Bash, Write, WebFetch (default: Bash when command is set)"},
|
|
30
|
+
"arguments": {"type": "object", "description": "Tool arguments, e.g. {\"file_path\": \"...\"}"},
|
|
31
|
+
"cwd": {"type": "string", "description": "Working directory (default: the server's cwd)"}}}},
|
|
32
|
+
{"name": "explain_decision",
|
|
33
|
+
"description": "Explain an Interlock decision: why a call was blocked, asked, warned on, or withheld. Pass an event_id, or omit it for the most recent stop.",
|
|
34
|
+
"inputSchema": {"type": "object", "properties": {"event_id": {"type": "string"}}}},
|
|
35
|
+
{"name": "recent_decisions",
|
|
36
|
+
"description": "List recent Interlock decisions (newest first). Set only_stops to see just blocks, asks, warnings, and withholds.",
|
|
37
|
+
"inputSchema": {"type": "object", "properties": {"limit": {"type": "integer", "default": 10, "maximum": 50},
|
|
38
|
+
"only_stops": {"type": "boolean", "default": True}}}},
|
|
39
|
+
{"name": "scan_mcp_servers",
|
|
40
|
+
"description": "Audit the MCP servers and agent settings on this machine (static; nothing is run or contacted; secrets are never shown).",
|
|
41
|
+
"inputSchema": {"type": "object", "properties": {"project": {"type": "string"}}}},
|
|
42
|
+
{"name": "interlock_status",
|
|
43
|
+
"description": "Is Interlock protecting this machine? Daemon, mode, policy, hooks per agent, shim integrity.",
|
|
44
|
+
"inputSchema": {"type": "object", "properties": {}}},
|
|
45
|
+
{"name": "suggest_rule_override",
|
|
46
|
+
"description": ("Draft the policy snippet that would change how one rule behaves (e.g. a false positive). Returns "
|
|
47
|
+
"YAML for the user to review and add themselves; it does not change any policy."),
|
|
48
|
+
"inputSchema": {"type": "object", "required": ["rule_id", "action"], "properties": {
|
|
49
|
+
"rule_id": {"type": "string"}, "action": {"type": "string", "enum": ["block", "ask", "flag", "allow"]},
|
|
50
|
+
"reason": {"type": "string"}}}},
|
|
51
|
+
]
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class InterlockMCP:
|
|
55
|
+
def __init__(self, settings: Settings | None = None):
|
|
56
|
+
self.settings = settings or Settings.load()
|
|
57
|
+
|
|
58
|
+
# ---- tools
|
|
59
|
+
def check_action(self, command: str | None = None, tool: str | None = None, arguments: dict | None = None,
|
|
60
|
+
cwd: str | None = None) -> dict:
|
|
61
|
+
from .models import AdapterInfo, Context, Identity, InterlockEvent, ToolCall
|
|
62
|
+
from .pipeline import Daemon
|
|
63
|
+
from .policy import load_policy
|
|
64
|
+
from .store.memory import MemoryRepository
|
|
65
|
+
from .store.spool import Spool
|
|
66
|
+
|
|
67
|
+
if not command and not tool:
|
|
68
|
+
return {"error": "pass a command, or a tool with arguments"}
|
|
69
|
+
args = dict(arguments or {})
|
|
70
|
+
if command:
|
|
71
|
+
args.setdefault("command", command)
|
|
72
|
+
name = tool or "Bash"
|
|
73
|
+
cwd = cwd or str(Path.cwd())
|
|
74
|
+
pol = load_policy(cwd, home=self.settings.home)
|
|
75
|
+
pol.mode = "enforce" # answer "what would enforce do", whatever the live mode is
|
|
76
|
+
if not pol.trust.workspace_roots:
|
|
77
|
+
pol.trust.workspace_roots = [cwd]
|
|
78
|
+
with tempfile.TemporaryDirectory() as td: # nothing from a what-if lands in the real audit log
|
|
79
|
+
d = Daemon(self.settings, spool=Spool(Path(td)), repo=MemoryRepository(), policy=pol)
|
|
80
|
+
v = d.decide(InterlockEvent(kind="pre_action", adapter=AdapterInfo(name="interlock-mcp", version=__version__),
|
|
81
|
+
identity=Identity(agent_type="interlock-mcp"), tool_call=ToolCall(name=name, arguments=args),
|
|
82
|
+
context=Context(cwd=cwd)))
|
|
83
|
+
return {"decision": v.disposition, "reason": v.reason,
|
|
84
|
+
"rules": [{"rule": f.rule, "category": f.category, "severity": f.severity, "subject": f.subject}
|
|
85
|
+
for f in v.findings if f.rule],
|
|
86
|
+
"note": ("Advisory. The gate decides on the real call. If this is blocked and the user truly needs it, "
|
|
87
|
+
"ask the user; do not rephrase or split the action to get around the rule.") if v.disposition != "allow" else ""}
|
|
88
|
+
|
|
89
|
+
def _records(self, limit: int = 500) -> list[dict]:
|
|
90
|
+
from .store.spool import Spool
|
|
91
|
+
|
|
92
|
+
sd = self.settings.spool_dir
|
|
93
|
+
recs = Spool(sd, fsync=False).iter_records(limit=limit) if sd.exists() else []
|
|
94
|
+
return [r for r in recs if not str(r.get("session_id") or "").startswith("interlock-selftest")]
|
|
95
|
+
|
|
96
|
+
def explain_decision(self, event_id: str | None = None) -> dict:
|
|
97
|
+
from .det.describe import why
|
|
98
|
+
|
|
99
|
+
recs = self._records()
|
|
100
|
+
rec = next((r for r in recs if r.get("event_id") == event_id), None) if event_id else \
|
|
101
|
+
next((r for r in recs if r.get("disposition") in ("block", "ask", "warn", "withhold")), None)
|
|
102
|
+
if not rec:
|
|
103
|
+
return {"error": "no such decision" if event_id else "no recent blocks, asks, warnings, or withholds"}
|
|
104
|
+
return {"event_id": rec.get("event_id"), "at": rec.get("received_at"), "agent": rec.get("agent_type"),
|
|
105
|
+
"tool": rec.get("tool_name"), "kind": rec.get("kind"), "decision": rec.get("disposition"),
|
|
106
|
+
"mode": rec.get("mode"), "reason": rec.get("reason"),
|
|
107
|
+
"rules": [{"rule": f.get("rule"), "category": f.get("category"), "severity": f.get("severity"),
|
|
108
|
+
"detector": f.get("detector"), "score": f.get("score"), "why": why(f.get("rule"))}
|
|
109
|
+
for f in rec.get("findings") or []],
|
|
110
|
+
"what_to_do": ("Pursue the user's goal another legitimate way, or ask the user. If this is a false "
|
|
111
|
+
"positive, the user can override the rule (see suggest_rule_override).")}
|
|
112
|
+
|
|
113
|
+
def recent_decisions(self, limit: int = 10, only_stops: bool = True) -> dict:
|
|
114
|
+
limit = max(1, min(int(limit or 10), 50))
|
|
115
|
+
out = []
|
|
116
|
+
for r in self._records():
|
|
117
|
+
if only_stops and r.get("disposition") not in ("block", "ask", "warn", "withhold"):
|
|
118
|
+
continue
|
|
119
|
+
a = r.get("arguments") or {}
|
|
120
|
+
out.append({"event_id": r.get("event_id"), "at": r.get("received_at"), "agent": r.get("agent_type"),
|
|
121
|
+
"tool": r.get("tool_name"), "decision": r.get("disposition"), "mode": r.get("mode"),
|
|
122
|
+
"target": str(a.get("command") or a.get("file_path") or a.get("path") or a.get("url") or "")[:120],
|
|
123
|
+
"reason": (r.get("reason") or "")[:160]})
|
|
124
|
+
if len(out) >= limit:
|
|
125
|
+
break
|
|
126
|
+
return {"decisions": out}
|
|
127
|
+
|
|
128
|
+
def scan_mcp_servers(self, project: str | None = None) -> dict:
|
|
129
|
+
from .scan import run_scan
|
|
130
|
+
|
|
131
|
+
r = run_scan(Path.home(), self.settings.home, Path(project or Path.cwd()).expanduser().resolve())
|
|
132
|
+
j = r.to_json()
|
|
133
|
+
return {"servers": [{k: s[k] for k in ("agent", "name", "transport", "command", "url", "disabled")} for s in j["servers"]],
|
|
134
|
+
"findings": [f for f in j["findings"] if f["severity"] != "info"]}
|
|
135
|
+
|
|
136
|
+
def interlock_status(self) -> dict:
|
|
137
|
+
from .insights import run_doctor
|
|
138
|
+
|
|
139
|
+
checks = run_doctor(self.settings.home, self.settings.listen,
|
|
140
|
+
self.settings.workspace_roots[0] if self.settings.workspace_roots else None)
|
|
141
|
+
return {"ok": all(c.ok or c.warn for c in checks),
|
|
142
|
+
"checks": [{"check": c.name, "ok": c.ok, "warning": c.warn, "detail": c.detail, "fix": c.fix} for c in checks]}
|
|
143
|
+
|
|
144
|
+
def suggest_rule_override(self, rule_id: str, action: str, reason: str | None = None) -> dict:
|
|
145
|
+
from .det import (
|
|
146
|
+
RULES,
|
|
147
|
+
packs, # noqa: F401 registers the rules
|
|
148
|
+
)
|
|
149
|
+
|
|
150
|
+
if rule_id not in RULES:
|
|
151
|
+
return {"error": f"unknown rule {rule_id!r}", "hint": "rule ids appear in check_action and explain_decision results"}
|
|
152
|
+
snippet = f"packs:\n overrides:\n {rule_id}: {{ action: {action} }}" + (f" # {reason}" if reason else "")
|
|
153
|
+
return {"apply_to": str(self.settings.home / "policy.yaml") + " (user) or <repo>/.interlock/policy.yaml (workspace)",
|
|
154
|
+
"yaml": snippet, "note": "Interlock does not apply this. The user reviews it, adds it, and runs `interlock restart`."}
|
|
155
|
+
|
|
156
|
+
# ---- JSON-RPC
|
|
157
|
+
def call(self, name: str, args: dict) -> dict:
|
|
158
|
+
fn = {t["name"]: getattr(self, t["name"]) for t in TOOLS}.get(name)
|
|
159
|
+
if fn is None:
|
|
160
|
+
raise KeyError(name)
|
|
161
|
+
return fn(**{k: v for k, v in (args or {}).items() if v is not None})
|
|
162
|
+
|
|
163
|
+
def handle(self, msg: dict) -> dict | None:
|
|
164
|
+
method, mid = msg.get("method"), msg.get("id")
|
|
165
|
+
if mid is None: # notification (e.g. notifications/initialized)
|
|
166
|
+
return None
|
|
167
|
+
try:
|
|
168
|
+
if method == "initialize":
|
|
169
|
+
result = {"protocolVersion": (msg.get("params") or {}).get("protocolVersion") or PROTOCOL_VERSION,
|
|
170
|
+
"capabilities": {"tools": {"listChanged": False}},
|
|
171
|
+
"serverInfo": {"name": "interlock", "version": __version__},
|
|
172
|
+
"instructions": ("Interlock guards this machine's agent tool calls. Use check_action before "
|
|
173
|
+
"risky shell, git, network, or file actions; use explain_decision when "
|
|
174
|
+
"something was blocked. Never try to get around a block.")}
|
|
175
|
+
elif method == "ping":
|
|
176
|
+
result = {}
|
|
177
|
+
elif method == "tools/list":
|
|
178
|
+
result = {"tools": TOOLS}
|
|
179
|
+
elif method == "tools/call":
|
|
180
|
+
p = msg.get("params") or {}
|
|
181
|
+
try:
|
|
182
|
+
out = self.call(p.get("name", ""), p.get("arguments") or {})
|
|
183
|
+
result = {"content": [{"type": "text", "text": json.dumps(out, indent=2, default=str)}],
|
|
184
|
+
"structuredContent": out, "isError": "error" in out}
|
|
185
|
+
except KeyError:
|
|
186
|
+
return {"jsonrpc": "2.0", "id": mid, "error": {"code": -32602, "message": f"unknown tool {p.get('name')!r}"}}
|
|
187
|
+
except TypeError as e:
|
|
188
|
+
result = {"content": [{"type": "text", "text": f"bad arguments: {e}"}], "isError": True}
|
|
189
|
+
else:
|
|
190
|
+
return {"jsonrpc": "2.0", "id": mid, "error": {"code": -32601, "message": f"method not found: {method}"}}
|
|
191
|
+
except Exception as e: # noqa: BLE001 a tool failure must not kill the server
|
|
192
|
+
result = {"content": [{"type": "text", "text": f"{type(e).__name__}: {e}"}], "isError": True}
|
|
193
|
+
return {"jsonrpc": "2.0", "id": mid, "result": result}
|
|
194
|
+
|
|
195
|
+
def serve(self, stdin: TextIO = sys.stdin, stdout: TextIO = sys.stdout) -> int:
|
|
196
|
+
for line in stdin:
|
|
197
|
+
line = line.strip()
|
|
198
|
+
if not line:
|
|
199
|
+
continue
|
|
200
|
+
try:
|
|
201
|
+
msg = json.loads(line)
|
|
202
|
+
except ValueError:
|
|
203
|
+
stdout.write(json.dumps({"jsonrpc": "2.0", "id": None, "error": {"code": -32700, "message": "parse error"}}) + "\n")
|
|
204
|
+
stdout.flush()
|
|
205
|
+
continue
|
|
206
|
+
for m in (msg if isinstance(msg, list) else [msg]):
|
|
207
|
+
reply = self.handle(m) if isinstance(m, dict) else None
|
|
208
|
+
if reply is not None:
|
|
209
|
+
stdout.write(json.dumps(reply, default=str) + "\n")
|
|
210
|
+
stdout.flush()
|
|
211
|
+
return 0
|
interlockd/models.py
ADDED
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
"""Wire and audit models. Field names follow phase1-design.md §5.1 and §7."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any, Literal
|
|
6
|
+
|
|
7
|
+
from pydantic import BaseModel, ConfigDict, Field
|
|
8
|
+
|
|
9
|
+
Kind = Literal["pre_action", "post_action"]
|
|
10
|
+
Disposition = Literal["allow", "ask", "block", "pass", "warn", "withhold"]
|
|
11
|
+
Mode = Literal["shadow", "enforce"]
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class Strict(BaseModel):
|
|
15
|
+
model_config = ConfigDict(extra="forbid")
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class AdapterInfo(Strict):
|
|
19
|
+
name: str
|
|
20
|
+
version: str = "0"
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class Identity(Strict):
|
|
24
|
+
agent_id: str = ""
|
|
25
|
+
agent_type: str = ""
|
|
26
|
+
agent_workspace: str = ""
|
|
27
|
+
agent_user: str = ""
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class ToolCall(Strict):
|
|
31
|
+
id: str | None = None
|
|
32
|
+
name: str
|
|
33
|
+
arguments: dict[str, Any] = Field(default_factory=dict)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class UserMessage(Strict):
|
|
37
|
+
n: int
|
|
38
|
+
text: str
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class Context(Strict):
|
|
42
|
+
cwd: str | None = None
|
|
43
|
+
user_messages: list[UserMessage] | None = None
|
|
44
|
+
prior_tool_calls: list[dict[str, Any]] = Field(default_factory=list)
|
|
45
|
+
mcp_server: dict[str, Any] | None = None
|
|
46
|
+
permission_mode: str | None = None
|
|
47
|
+
transcript_path: str | None = None # harness transcript the daemon may read for user messages
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class ToolResult(Strict):
|
|
51
|
+
content: str
|
|
52
|
+
truncated: bool = False
|
|
53
|
+
evidence: dict[str, Any] = Field(default_factory=dict)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
class Capabilities(Strict):
|
|
57
|
+
ask: bool = False
|
|
58
|
+
rewrite: bool = False
|
|
59
|
+
withhold: bool = False
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
class InterlockEvent(Strict):
|
|
63
|
+
kind: Kind
|
|
64
|
+
adapter: AdapterInfo
|
|
65
|
+
identity: Identity = Field(default_factory=Identity)
|
|
66
|
+
session_hint: str | None = None
|
|
67
|
+
tool_call: ToolCall
|
|
68
|
+
context: Context = Field(default_factory=Context)
|
|
69
|
+
result: ToolResult | None = None
|
|
70
|
+
capabilities: Capabilities = Field(default_factory=Capabilities)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
class Finding(Strict):
|
|
74
|
+
category: str
|
|
75
|
+
severity: Literal["low", "medium", "high", "critical"] = "medium"
|
|
76
|
+
rule: str | None = None
|
|
77
|
+
detector: str
|
|
78
|
+
path: str | None = None
|
|
79
|
+
start: int | None = None
|
|
80
|
+
end: int | None = None
|
|
81
|
+
score: float = 1.0
|
|
82
|
+
subject: str | None = None
|
|
83
|
+
whitelisted: bool = False
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
class Verdict(Strict):
|
|
87
|
+
event_id: str
|
|
88
|
+
disposition: Disposition
|
|
89
|
+
reason: str | None = None
|
|
90
|
+
notice: str | None = None
|
|
91
|
+
updated_arguments: dict[str, Any] | None = None
|
|
92
|
+
findings: list[Finding] = Field(default_factory=list)
|
|
93
|
+
unjudged: list[str] = Field(default_factory=list)
|
|
94
|
+
mode: Mode
|
|
95
|
+
tier: int
|
|
96
|
+
action_class: str
|
|
97
|
+
timing_ms: dict[str, float] = Field(default_factory=dict)
|
|
98
|
+
explanation_pending: bool = False
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Harness payload normalizers."""
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
"""Claude Code (and Codex, which uses the same event schema) hook payload -> InterlockEvent.
|
|
2
|
+
|
|
3
|
+
Documented PreToolUse fields: session_id, prompt_id, transcript_path, cwd, permission_mode, tool_name, tool_input,
|
|
4
|
+
tool_use_id, mcp_server {name, source}. PostToolUse adds tool_response (shape depends on the tool; Bash carries
|
|
5
|
+
stdout/stderr and may carry bashEditDiff.changedFiles). Written to the published contract; the first real capture
|
|
6
|
+
replaces this docstring with measured facts, as adapters/kiro/PAYLOADS.md did for Kiro.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import getpass
|
|
12
|
+
import json
|
|
13
|
+
import socket
|
|
14
|
+
from typing import Any
|
|
15
|
+
|
|
16
|
+
from ..models import AdapterInfo, Capabilities, Context, Identity, InterlockEvent, ToolCall, ToolResult
|
|
17
|
+
from .kiro import workspace_name
|
|
18
|
+
|
|
19
|
+
CLAUDE_CAPABILITIES = Capabilities(ask=True, rewrite=True, withhold=True)
|
|
20
|
+
CODEX_CAPABILITIES = Capabilities(ask=True, rewrite=True, withhold=True) # via decision "block" (measured)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _response_text(resp: Any) -> tuple[str, dict[str, Any]]:
|
|
24
|
+
"""Flatten a tool_response into text plus structured evidence."""
|
|
25
|
+
evidence: dict[str, Any] = {}
|
|
26
|
+
if resp is None:
|
|
27
|
+
return "", evidence
|
|
28
|
+
if isinstance(resp, str):
|
|
29
|
+
return resp, evidence
|
|
30
|
+
if isinstance(resp, dict):
|
|
31
|
+
for k in ("exit_code", "exitCode", "returncode", "interrupted", "duration_ms"):
|
|
32
|
+
if k in resp:
|
|
33
|
+
evidence[k] = resp[k]
|
|
34
|
+
diff = resp.get("bashEditDiff")
|
|
35
|
+
if isinstance(diff, dict):
|
|
36
|
+
evidence["changed_files"] = diff.get("changedFiles") or []
|
|
37
|
+
for k in ("moreFiles", "unavailable", "skipped", "shared"):
|
|
38
|
+
if k in diff:
|
|
39
|
+
evidence[f"changed_files_{k}"] = diff[k]
|
|
40
|
+
parts = []
|
|
41
|
+
for k in ("stdout", "stderr", "output", "content", "text", "result", "file", "message"):
|
|
42
|
+
v = resp.get(k)
|
|
43
|
+
if isinstance(v, str) and v:
|
|
44
|
+
parts.append(v)
|
|
45
|
+
elif isinstance(v, dict) and isinstance(v.get("content"), str):
|
|
46
|
+
parts.append(v["content"])
|
|
47
|
+
if parts:
|
|
48
|
+
return "\n".join(parts), evidence
|
|
49
|
+
return json.dumps(resp, ensure_ascii=False)[:200_000], evidence
|
|
50
|
+
if isinstance(resp, list):
|
|
51
|
+
texts = [b.get("text", "") if isinstance(b, dict) else str(b) for b in resp]
|
|
52
|
+
return "\n".join(t for t in texts if t), evidence
|
|
53
|
+
return str(resp), evidence
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def normalize_claude_code(payload: dict[str, Any], *, agent_type: str = "claude-code", agent_id: str = "",
|
|
57
|
+
adapter_version: str = "0") -> InterlockEvent:
|
|
58
|
+
hook = str(payload.get("hook_event_name") or "")
|
|
59
|
+
kind = "post_action" if hook.lower() in ("posttooluse", "posttoolusefailure") else "pre_action"
|
|
60
|
+
cwd = payload.get("cwd")
|
|
61
|
+
tool_input = payload.get("tool_input")
|
|
62
|
+
if not isinstance(tool_input, dict):
|
|
63
|
+
tool_input = {"value": tool_input} if tool_input is not None else {}
|
|
64
|
+
result = None
|
|
65
|
+
if kind == "post_action":
|
|
66
|
+
text, evidence = _response_text(payload.get("tool_response"))
|
|
67
|
+
if hook.lower() == "posttoolusefailure":
|
|
68
|
+
evidence["failed"] = True
|
|
69
|
+
if payload.get("error"):
|
|
70
|
+
text = text or str(payload.get("error"))
|
|
71
|
+
result = ToolResult(content=text, evidence=evidence)
|
|
72
|
+
mcp = payload.get("mcp_server") if isinstance(payload.get("mcp_server"), dict) else None
|
|
73
|
+
caps = CLAUDE_CAPABILITIES if agent_type == "claude-code" else CODEX_CAPABILITIES
|
|
74
|
+
return InterlockEvent(
|
|
75
|
+
kind=kind,
|
|
76
|
+
adapter=AdapterInfo(name=agent_type, version=adapter_version),
|
|
77
|
+
identity=Identity(
|
|
78
|
+
agent_id=agent_id or f"{agent_type}-{socket.gethostname().split('.')[0]}",
|
|
79
|
+
agent_type=agent_type,
|
|
80
|
+
agent_workspace=workspace_name(cwd),
|
|
81
|
+
agent_user=getpass.getuser(),
|
|
82
|
+
),
|
|
83
|
+
session_hint=payload.get("session_id"),
|
|
84
|
+
tool_call=ToolCall(id=payload.get("tool_use_id"), name=str(payload.get("tool_name") or ""), arguments=tool_input),
|
|
85
|
+
context=Context(cwd=cwd, user_messages=None, mcp_server=mcp, permission_mode=payload.get("permission_mode"),
|
|
86
|
+
transcript_path=payload.get("transcript_path")),
|
|
87
|
+
result=result,
|
|
88
|
+
capabilities=caps,
|
|
89
|
+
)
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
"""Cursor agent hook payload -> InterlockEvent (https://cursor.com/docs/agent/hooks, read 2026-10-17).
|
|
2
|
+
|
|
3
|
+
Interlock registers `preToolUse` and `postToolUse` (one generic pair for every tool type: Shell, Read, Write, Grep,
|
|
4
|
+
Delete, Task, MCP:<tool>). The shell-specific `beforeShellExecution` / `afterShellExecution` shapes are accepted too.
|
|
5
|
+
Measured 2026-10-18 on the Cursor CLI (cursor-agent 2026.10.16): see adapters/cursor/PAYLOADS.md.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import getpass
|
|
11
|
+
import json
|
|
12
|
+
import socket
|
|
13
|
+
from typing import Any
|
|
14
|
+
|
|
15
|
+
from ..models import AdapterInfo, Capabilities, Context, Identity, InterlockEvent, ToolCall, ToolResult
|
|
16
|
+
from .claude_code import _response_text
|
|
17
|
+
from .kiro import workspace_name
|
|
18
|
+
|
|
19
|
+
POST_EVENTS = {"posttooluse", "posttoolusefailure", "aftershellexecution", "aftermcpexecution"}
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def normalize_cursor(payload: dict[str, Any], *, agent_id: str = "", adapter_version: str = "0") -> InterlockEvent:
|
|
23
|
+
hook = str(payload.get("hook_event_name") or "")
|
|
24
|
+
kind = "post_action" if hook.lower() in POST_EVENTS else "pre_action"
|
|
25
|
+
roots = payload.get("workspace_roots") or []
|
|
26
|
+
cwd = payload.get("cwd") or (roots[0] if roots else None)
|
|
27
|
+
name = str(payload.get("tool_name") or "")
|
|
28
|
+
args = payload.get("tool_input")
|
|
29
|
+
if hook.lower() in ("beforeshellexecution", "aftershellexecution"):
|
|
30
|
+
name, args = "Shell", {"command": payload.get("command", "")}
|
|
31
|
+
if not isinstance(args, dict):
|
|
32
|
+
args = {"value": args} if args is not None else {}
|
|
33
|
+
result = None
|
|
34
|
+
if kind == "post_action":
|
|
35
|
+
raw = payload.get("tool_output", payload.get("output", payload.get("result_json")))
|
|
36
|
+
if isinstance(raw, str) and raw[:1] in "{[": # measured: Cursor sends tool_output as a JSON string
|
|
37
|
+
try:
|
|
38
|
+
raw = json.loads(raw)
|
|
39
|
+
except ValueError:
|
|
40
|
+
pass
|
|
41
|
+
text, evidence = _response_text(raw)
|
|
42
|
+
if hook.lower() == "posttoolusefailure":
|
|
43
|
+
evidence["failed"] = True
|
|
44
|
+
text = text or str(payload.get("error_message") or "")
|
|
45
|
+
result = ToolResult(content=text, evidence=evidence)
|
|
46
|
+
is_mcp = name.startswith("MCP:") or bool(payload.get("mcp_server_name"))
|
|
47
|
+
return InterlockEvent(
|
|
48
|
+
kind=kind,
|
|
49
|
+
adapter=AdapterInfo(name="cursor", version=adapter_version),
|
|
50
|
+
identity=Identity(agent_id=agent_id or f"cursor-{socket.gethostname().split('.')[0]}", agent_type="cursor",
|
|
51
|
+
agent_workspace=workspace_name(cwd), agent_user=getpass.getuser()),
|
|
52
|
+
session_hint=payload.get("conversation_id"),
|
|
53
|
+
tool_call=ToolCall(id=payload.get("tool_use_id"), name=name, arguments=args),
|
|
54
|
+
context=Context(cwd=cwd, mcp_server={"name": payload["mcp_server_name"]} if payload.get("mcp_server_name") else None,
|
|
55
|
+
transcript_path=payload.get("transcript_path")),
|
|
56
|
+
result=result,
|
|
57
|
+
# ask is "accepted by the schema but not enforced for preToolUse"; output replacement is MCP-only
|
|
58
|
+
capabilities=Capabilities(ask=False, rewrite=True, withhold=is_mcp),
|
|
59
|
+
)
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
"""Gemini CLI hook payload -> InterlockEvent (https://geminicli.com/docs/hooks/reference/, read 2026-10-17).
|
|
2
|
+
|
|
3
|
+
`BeforeTool` / `AfterTool` cover every tool, including shell (`run_shell_command`) and MCP (`mcp_<server>_<tool>`).
|
|
4
|
+
AfterTool carries `tool_response {llmContent, returnDisplay, error}`. Built from the documentation; not yet captured
|
|
5
|
+
from a running Gemini CLI (see adapters/gemini-cli/PAYLOADS.md).
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import getpass
|
|
11
|
+
import socket
|
|
12
|
+
from typing import Any
|
|
13
|
+
|
|
14
|
+
from ..models import AdapterInfo, Capabilities, Context, Identity, InterlockEvent, ToolCall, ToolResult
|
|
15
|
+
from .claude_code import _response_text
|
|
16
|
+
from .kiro import workspace_name
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _gemini_text(resp: Any) -> tuple[str, dict[str, Any]]:
|
|
20
|
+
if isinstance(resp, dict) and ("llmContent" in resp or "returnDisplay" in resp):
|
|
21
|
+
text, evidence = _response_text(resp.get("llmContent"))
|
|
22
|
+
if not text:
|
|
23
|
+
text, _ = _response_text(resp.get("returnDisplay"))
|
|
24
|
+
if resp.get("error"):
|
|
25
|
+
evidence["failed"] = True
|
|
26
|
+
text = text or str(resp["error"])
|
|
27
|
+
return text, evidence
|
|
28
|
+
return _response_text(resp)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def normalize_gemini_cli(payload: dict[str, Any], *, agent_id: str = "", adapter_version: str = "0") -> InterlockEvent:
|
|
32
|
+
hook = str(payload.get("hook_event_name") or "")
|
|
33
|
+
kind = "post_action" if hook.lower() == "aftertool" else "pre_action"
|
|
34
|
+
cwd = payload.get("cwd")
|
|
35
|
+
args = payload.get("tool_input")
|
|
36
|
+
if not isinstance(args, dict):
|
|
37
|
+
args = {"value": args} if args is not None else {}
|
|
38
|
+
result = None
|
|
39
|
+
if kind == "post_action":
|
|
40
|
+
text, evidence = _gemini_text(payload.get("tool_response"))
|
|
41
|
+
result = ToolResult(content=text, evidence=evidence)
|
|
42
|
+
name = str(payload.get("tool_name") or "")
|
|
43
|
+
mcp = payload.get("mcp_context") if isinstance(payload.get("mcp_context"), dict) else None
|
|
44
|
+
return InterlockEvent(
|
|
45
|
+
kind=kind,
|
|
46
|
+
adapter=AdapterInfo(name="gemini-cli", version=adapter_version),
|
|
47
|
+
identity=Identity(agent_id=agent_id or f"gemini-cli-{socket.gethostname().split('.')[0]}", agent_type="gemini-cli",
|
|
48
|
+
agent_workspace=workspace_name(cwd), agent_user=getpass.getuser()),
|
|
49
|
+
session_hint=payload.get("session_id"),
|
|
50
|
+
tool_call=ToolCall(name=name, arguments=args),
|
|
51
|
+
context=Context(cwd=cwd, mcp_server=mcp, transcript_path=payload.get("transcript_path")),
|
|
52
|
+
result=result,
|
|
53
|
+
# no documented ask; AfterTool deny replaces the result the model sees
|
|
54
|
+
capabilities=Capabilities(ask=False, rewrite=True, withhold=True),
|
|
55
|
+
)
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
"""Kiro hook payload -> InterlockEvent. Field mapping per adapters/kiro/PAYLOADS.md §8."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import getpass
|
|
6
|
+
import os
|
|
7
|
+
import re
|
|
8
|
+
import socket
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
from ..models import AdapterInfo, Capabilities, Context, Identity, InterlockEvent, ToolCall, ToolResult
|
|
12
|
+
from ..paths import find_git_root
|
|
13
|
+
|
|
14
|
+
_EXIT = re.compile(r"Exit Code:\s*(-?\d+)\s*$")
|
|
15
|
+
|
|
16
|
+
KIRO_CAPABILITIES = Capabilities(ask=False, rewrite=False, withhold=False)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def default_agent_id() -> str:
|
|
20
|
+
return f"kiro-{socket.gethostname().split('.')[0]}"
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def workspace_name(cwd: str | None) -> str:
|
|
24
|
+
root = find_git_root(cwd) if cwd else None
|
|
25
|
+
base = root or cwd or ""
|
|
26
|
+
return os.path.basename(os.path.normpath(base)) if base else ""
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def normalize_kiro(payload: dict[str, Any], *, agent_id: str = "", adapter_version: str = "0") -> InterlockEvent:
|
|
30
|
+
hook = payload.get("hook_event_name") or ""
|
|
31
|
+
kind = "post_action" if hook.lower() == "posttooluse" else "pre_action"
|
|
32
|
+
cwd = payload.get("cwd")
|
|
33
|
+
tool_name = str(payload.get("tool_name") or "")
|
|
34
|
+
tool_input = payload.get("tool_input")
|
|
35
|
+
if not isinstance(tool_input, dict):
|
|
36
|
+
tool_input = {"value": tool_input} if tool_input is not None else {}
|
|
37
|
+
|
|
38
|
+
result = None
|
|
39
|
+
if kind == "post_action":
|
|
40
|
+
content = payload.get("tool_response")
|
|
41
|
+
if content is None:
|
|
42
|
+
content = ""
|
|
43
|
+
if not isinstance(content, str):
|
|
44
|
+
content = str(content)
|
|
45
|
+
evidence: dict[str, Any] = {}
|
|
46
|
+
m = _EXIT.search(content.strip()[-64:])
|
|
47
|
+
if m:
|
|
48
|
+
evidence["exit_code"] = int(m.group(1))
|
|
49
|
+
result = ToolResult(content=content, evidence=evidence)
|
|
50
|
+
|
|
51
|
+
return InterlockEvent(
|
|
52
|
+
kind=kind,
|
|
53
|
+
adapter=AdapterInfo(name="kiro", version=adapter_version),
|
|
54
|
+
identity=Identity(
|
|
55
|
+
agent_id=agent_id or default_agent_id(),
|
|
56
|
+
agent_type="kiro",
|
|
57
|
+
agent_workspace=workspace_name(cwd),
|
|
58
|
+
agent_user=getpass.getuser(),
|
|
59
|
+
),
|
|
60
|
+
session_hint=payload.get("session_id"),
|
|
61
|
+
tool_call=ToolCall(id=None, name=tool_name, arguments=tool_input),
|
|
62
|
+
context=Context(cwd=cwd, user_messages=None),
|
|
63
|
+
result=result,
|
|
64
|
+
capabilities=KIRO_CAPABILITIES,
|
|
65
|
+
)
|