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