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
interlockd/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """Interlock daemon package."""
2
+
3
+ __version__ = "0.1.0"
interlockd/__main__.py ADDED
@@ -0,0 +1,7 @@
1
+ """`python -m interlockd` runs the CLI (the background service uses this form)."""
2
+
3
+ import sys
4
+
5
+ from .cli import main
6
+
7
+ sys.exit(main())
interlockd/aibom.py ADDED
@@ -0,0 +1,136 @@
1
+ """`interlock aibom`: a CycloneDX 1.6 AI bill of materials for the agent stack on this machine (or a repo).
2
+
3
+ Components: the agents found (application), each stdio MCP server (application, with a package URL when it is
4
+ launched from npm / PyPI / a container image, and whether that version is pinned), models named in agent configs
5
+ (machine-learning-model), and Interlock itself. Remote MCP servers are `services` with their endpoint. Tool
6
+ definitions pinned by the MCP gateway are recorded as component properties (name + sha256). Env values, headers, and
7
+ arguments that look like secrets are never included: only env and header *names*.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import json
13
+ import re
14
+ import uuid
15
+ from datetime import UTC, datetime
16
+ from pathlib import Path
17
+
18
+ from . import __version__
19
+ from .scan import LAUNCHERS, Server, _load, _npm_pinned, _package_arg, _pypi_pinned, discover
20
+
21
+
22
+ def _purl(s: Server) -> tuple[str | None, str | None, bool | None]:
23
+ """(purl, version, pinned) for a launcher-fetched package or a container image."""
24
+ cmd = Path(s.command).name if s.command else ""
25
+ args = s.args
26
+ if cmd in ("pnpm", "yarn", "bun") and args[:1] in (["dlx"], ["x"]):
27
+ cmd, args = "npx", args[1:]
28
+ if cmd in LAUNCHERS:
29
+ spec = _package_arg(cmd, args)
30
+ if not spec:
31
+ return None, None, None
32
+ if LAUNCHERS[cmd] == "npm":
33
+ body = spec.removeprefix("@")
34
+ name, ver = (spec, None) if "@" not in body else (spec[: len(spec) - len(body.rsplit("@", 1)[1]) - 1], body.rsplit("@", 1)[1])
35
+ purl_name = name.replace("@", "%40", 1) if name.startswith("@") else name
36
+ return f"pkg:npm/{purl_name}" + (f"@{ver}" if ver else ""), ver, _npm_pinned(spec)
37
+ m = re.match(r"^([A-Za-z0-9_.\-\[\]]+?)(?:==|@)\s*([0-9][^,;\s]*)$", spec)
38
+ name, ver = (m.group(1), m.group(2)) if m else (re.split(r"[<>=@\[]", spec)[0], None)
39
+ return f"pkg:pypi/{name.lower()}" + (f"@{ver}" if ver else ""), ver, _pypi_pinned(spec)
40
+ if cmd in ("docker", "podman") and "run" in args:
41
+ image = next((a for a in args[args.index("run") + 1:] if not a.startswith("-") and "=" not in a), None)
42
+ if image:
43
+ repo, _, digest = image.partition("@")
44
+ repo, _, tag = repo.partition(":") if "/" not in repo.rsplit(":", 1)[-1] else (repo, "", "")
45
+ name = repo.rsplit("/", 1)[-1]
46
+ version = f"@{digest.replace(':', '%3A')}" if digest else ""
47
+ quals = [f"repository_url={repo}"] + ([f"tag={tag}"] if tag else [])
48
+ return f"pkg:oci/{name}{version}?{'&'.join(quals)}", digest or tag or None, bool(digest)
49
+ return None, None, None
50
+
51
+
52
+ def _models(home: Path, project: Path) -> list[tuple[str, str]]:
53
+ """(agent, model) pairs named in agent configs."""
54
+ out = []
55
+ for agent, path, keys in (("codex", home / ".codex" / "config.toml", ("model",)),
56
+ ("claude-code", home / ".claude" / "settings.json", ("model",)),
57
+ ("claude-code", project / ".claude" / "settings.json", ("model",)),
58
+ ("gemini-cli", home / ".gemini" / "settings.json", ("model",))):
59
+ d = _load(path) if path.is_file() else None
60
+ if isinstance(d, dict):
61
+ for k in keys:
62
+ v = d.get(k)
63
+ if isinstance(v, dict):
64
+ v = v.get("name")
65
+ if isinstance(v, str) and v:
66
+ out.append((agent, v))
67
+ return sorted(set(out))
68
+
69
+
70
+ def build(home: Path, interlock_home: Path, project: Path) -> dict:
71
+ from .mcp_proxy import Pins
72
+ from .onboard import detect
73
+
74
+ servers, sources = discover(home, project)
75
+ pins = Pins(interlock_home / "mcp-pins.json").load()
76
+ components, services, deps = [], [], []
77
+ il_ref = "interlock"
78
+ components.append({"type": "application", "bom-ref": il_ref, "name": "interlock", "version": __version__,
79
+ "purl": f"pkg:pypi/interlockd@{__version__}", "description": "Pre/post-action gate for AI agents"})
80
+ for d in detect(project):
81
+ if d.present:
82
+ components.append({"type": "application", "bom-ref": f"agent:{d.name}", "name": d.name,
83
+ "properties": [{"name": "interlock:detected_by", "value": d.why or ""}]})
84
+ for agent, model in _models(home, project):
85
+ components.append({"type": "machine-learning-model", "bom-ref": f"model:{agent}:{model}", "name": model,
86
+ "properties": [{"name": "interlock:configured_in", "value": agent}]})
87
+ for s in servers:
88
+ ref = f"mcp:{s.agent}:{s.name}"
89
+ props = [{"name": "interlock:agent", "value": s.agent}, {"name": "interlock:config", "value": s.source},
90
+ {"name": "interlock:disabled", "value": str(s.disabled).lower()}]
91
+ props += [{"name": "interlock:env", "value": k} for k in sorted(s.env)]
92
+ for tool, meta in sorted((pins.get(s.name) or {}).items()):
93
+ props.append({"name": f"interlock:pinned_tool:{tool}", "value": meta.get("sha256", "")})
94
+ if s.url:
95
+ services.append({"bom-ref": ref, "name": s.name, "endpoints": [s.url],
96
+ "properties": props + [{"name": "interlock:headers", "value": ",".join(sorted(s.headers))}]})
97
+ else:
98
+ purl, ver, pinned = _purl(s)
99
+ comp = {"type": "application", "bom-ref": ref, "name": s.name,
100
+ "properties": props + [{"name": "interlock:command", "value": Path(s.command).name}]}
101
+ if purl:
102
+ comp["purl"] = purl
103
+ comp["properties"].append({"name": "interlock:pinned", "value": str(bool(pinned)).lower()})
104
+ if ver:
105
+ comp["version"] = ver
106
+ components.append(comp)
107
+ deps.append({"ref": f"agent:{s.agent}", "dependsOn": [ref]})
108
+ merged: dict[str, set] = {}
109
+ for d in deps:
110
+ merged.setdefault(d["ref"], set()).update(d["dependsOn"])
111
+ known = {c["bom-ref"] for c in components} | {s["bom-ref"] for s in services}
112
+ return {
113
+ "bomFormat": "CycloneDX", "specVersion": "1.6", "serialNumber": f"urn:uuid:{uuid.uuid4()}", "version": 1,
114
+ "metadata": {"timestamp": datetime.now(UTC).isoformat(timespec="seconds"),
115
+ "tools": {"components": [{"type": "application", "name": "interlock", "version": __version__}]},
116
+ "properties": [{"name": "interlock:sources", "value": ",".join(sources)}]},
117
+ "components": components,
118
+ **({"services": services} if services else {}),
119
+ "dependencies": [{"ref": r, "dependsOn": sorted(v)} for r, v in sorted(merged.items()) if r in known],
120
+ }
121
+
122
+
123
+ def summary(bom: dict) -> str:
124
+ comps = bom["components"]
125
+ servers = [c for c in comps if c["bom-ref"].startswith("mcp:")]
126
+ unpinned = [c["name"] for c in servers if any(p["name"] == "interlock:pinned" and p["value"] == "false" for p in c.get("properties", []))]
127
+ lines = [f"AI bill of materials (CycloneDX {bom['specVersion']}):",
128
+ f" agents {sum(c['bom-ref'].startswith('agent:') for c in comps)}",
129
+ f" models {sum(c['type'] == 'machine-learning-model' for c in comps)}",
130
+ f" mcp {len(servers)} local server(s), {len(bom.get('services', []))} remote",
131
+ f" unpinned {', '.join(unpinned) or 'none'}"]
132
+ return "\n".join(lines)
133
+
134
+
135
+ def to_json(bom: dict) -> str:
136
+ return json.dumps(bom, indent=2)
interlockd/api.py ADDED
@@ -0,0 +1,307 @@
1
+ """Native HTTP API (design §5.1) on the standard library, loopback by default.
2
+
3
+ GET /interlock/v1/health no auth
4
+ GET /interlock/v1/limits bearer
5
+ POST /interlock/v1/decide bearer InterlockEvent -> Verdict
6
+ POST /interlock/v1/hooks/kiro bearer raw Kiro hook payload -> Verdict (daemon normalizes)
7
+ GET /interlock/v1/events/<event_id> bearer
8
+ GET /interlock/v1/events?workspace=&limit= bearer (tail)
9
+
10
+ OGR conformance (design §5.2), served at the bare /v1 prefix the spec uses:
11
+ GET /v1/health no auth
12
+ GET /v1/limits bearer
13
+ POST /v1/evaluate bearer GuardEvent -> OGR Verdict
14
+ POST /v1/heartbeat bearer
15
+ GET|POST|DELETE /mcp/<name>/<secret> route secret MCP gateway for remote servers (mcp_http.py)
16
+ GET /v1/rules 404 (optional per spec; not implemented)
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ import json
22
+ import logging
23
+ import socketserver
24
+ import threading
25
+ from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
26
+ from typing import Any
27
+ from urllib.parse import parse_qs, urlparse
28
+
29
+ from pydantic import ValidationError
30
+
31
+ from . import __version__
32
+ from .auth import check_bearer
33
+ from .config import MAX_REQUEST_BYTES
34
+ from .models import InterlockEvent
35
+ from .normalize.claude_code import normalize_claude_code
36
+ from .normalize.cursor import normalize_cursor
37
+ from .normalize.gemini_cli import normalize_gemini_cli
38
+ from .normalize.kiro import normalize_kiro
39
+ from .normalize.more import normalize_antigravity, normalize_copilot, normalize_windsurf
40
+ from .ogr import PROTOCOLS, evaluate_guard_event
41
+ from .pipeline import Daemon
42
+ from .render import (
43
+ render_antigravity,
44
+ render_claude_code,
45
+ render_copilot,
46
+ render_cursor,
47
+ render_gemini_cli,
48
+ render_text,
49
+ )
50
+
51
+ log = logging.getLogger("interlockd.api")
52
+
53
+
54
+ class _Server(ThreadingHTTPServer):
55
+ """HTTPServer.server_bind resolves the host with socket.getfqdn(), a reverse-DNS lookup that can stall for tens
56
+ of seconds on machines with slow DNS (seen on CI runners). The daemon binds loopback; it never needs the name."""
57
+
58
+ def server_bind(self):
59
+ socketserver.TCPServer.server_bind(self)
60
+ host, port = self.server_address[:2]
61
+ self.server_name, self.server_port = str(host), port
62
+
63
+
64
+ class _UnixServer(socketserver.ThreadingMixIn, socketserver.UnixStreamServer):
65
+ """The same API on a Unix socket created with mode 0600: only the owning user can connect at all."""
66
+ daemon_threads = True
67
+
68
+ def server_bind(self):
69
+ import os
70
+
71
+ path = self.server_address
72
+ if os.path.exists(path):
73
+ os.unlink(path) # a stale socket from a previous run
74
+ old = os.umask(0o177)
75
+ try:
76
+ super().server_bind()
77
+ finally:
78
+ os.umask(old)
79
+ os.chmod(path, 0o600)
80
+
81
+
82
+ class ApiServer:
83
+ def __init__(self, daemon: Daemon, token: str, host: str, port: int, *, unix_socket: str | None = None):
84
+ self.daemon, self.token = daemon, token
85
+ handler = self._make_handler()
86
+ self.httpd = _Server((host, port), handler)
87
+ self.httpd.daemon_threads = True
88
+ self.unix = _UnixServer(unix_socket, handler) if unix_socket else None
89
+ self._thread: threading.Thread | None = None
90
+ self._unix_thread: threading.Thread | None = None
91
+
92
+ @property
93
+ def address(self) -> tuple[str, int]:
94
+ return self.httpd.server_address[0], self.httpd.server_address[1]
95
+
96
+ def serve_forever(self) -> None:
97
+ if self.unix is not None and self._unix_thread is None:
98
+ self._unix_thread = threading.Thread(target=self.unix.serve_forever, kwargs={"poll_interval": 0.5},
99
+ name="interlock-api-unix", daemon=True)
100
+ self._unix_thread.start()
101
+ self.httpd.serve_forever(poll_interval=0.5)
102
+
103
+ def start_background(self) -> None:
104
+ self._thread = threading.Thread(target=self.serve_forever, name="interlock-api", daemon=True)
105
+ self._thread.start()
106
+
107
+ def shutdown(self) -> None:
108
+ self.httpd.shutdown()
109
+ self.httpd.server_close()
110
+ if self.unix is not None:
111
+ import os
112
+
113
+ if self._unix_thread is not None: # socketserver.shutdown() blocks forever if its loop never started
114
+ self.unix.shutdown()
115
+ self.unix.server_close()
116
+ try:
117
+ os.unlink(self.unix.server_address)
118
+ except OSError:
119
+ pass
120
+
121
+ def _make_handler(self):
122
+ server = self
123
+
124
+ class Handler(BaseHTTPRequestHandler):
125
+ protocol_version = "HTTP/1.1"
126
+ server_version = f"interlockd/{__version__}"
127
+
128
+ def log_message(self, fmt, *args): # quiet by default; the audit store is the log
129
+ log.debug(fmt, *args)
130
+
131
+ # -- helpers
132
+ def _send(self, status: int, body: dict[str, Any]) -> None:
133
+ data = json.dumps(body, separators=(",", ":")).encode()
134
+ self.send_response(status)
135
+ self.send_header("Content-Type", "application/json")
136
+ self.send_header("Content-Length", str(len(data)))
137
+ self.end_headers()
138
+ self.wfile.write(data)
139
+
140
+ def _authed(self) -> bool:
141
+ if check_bearer(self.headers.get("Authorization"), server.token):
142
+ return True
143
+ self._send(401, {"error": "unauthorized"})
144
+ return False
145
+
146
+ def _read_json(self) -> Any | None:
147
+ n = int(self.headers.get("Content-Length") or 0)
148
+ if n > MAX_REQUEST_BYTES:
149
+ self._send(413, {"error": "request too large", "max_request_bytes": MAX_REQUEST_BYTES})
150
+ return None
151
+ raw = self.rfile.read(n) if n else b""
152
+ try:
153
+ return json.loads(raw or b"{}")
154
+ except json.JSONDecodeError as e:
155
+ self._send(400, {"error": f"invalid json: {e.msg}"})
156
+ return None
157
+
158
+ # -- routes
159
+ def _mcp(self, method: str) -> bool:
160
+ if not self.path.startswith("/mcp/"):
161
+ return False
162
+ from .mcp_http import handle
163
+
164
+ return handle(self, server.daemon, method)
165
+
166
+ def do_DELETE(self) -> None:
167
+ if not self._mcp("DELETE"):
168
+ self._send(404, {"error": "not found"})
169
+
170
+ def do_GET(self) -> None:
171
+ if self._mcp("GET"):
172
+ return
173
+ u = urlparse(self.path)
174
+ if u.path == "/interlock/v1/health":
175
+ s = server.daemon.settings
176
+ self._send(200, {"status": "ok", "version": __version__, "mode": server.daemon.mode, "profile": s.profile,
177
+ "policy_sha256": server.daemon.policy_sha256})
178
+ return
179
+ if u.path == "/v1/health": # OGR health is unauthenticated per the spec
180
+ self._send(200, {"status": "ok", "version": __version__, "provider": "interlock"})
181
+ return
182
+ if not self._authed():
183
+ return
184
+ if u.path == "/v1/limits": # OGR
185
+ self._send(200, {"max_request_bytes": MAX_REQUEST_BYTES, "protocols": list(PROTOCOLS),
186
+ "decisions": ["allow", "block"], "ask_maps_to_default": "block"})
187
+ return
188
+ if u.path == "/v1/rules": # optional per the OGR spec; Interlock does not expose it
189
+ self._send(404, {"error": "rules endpoint not implemented"})
190
+ return
191
+ if u.path == "/interlock/v1/limits":
192
+ s = server.daemon.settings
193
+ self._send(200, {"max_request_bytes": MAX_REQUEST_BYTES, "pre_timeout_ms": s.pre_timeout_ms,
194
+ "post_timeout_ms": s.post_timeout_ms, "fail_mode": server.daemon.policy.fail_mode, "mode": server.daemon.mode})
195
+ return
196
+ if u.path.startswith("/interlock/v1/events/"):
197
+ eid = u.path.rsplit("/", 1)[-1]
198
+ rec = server.daemon.repo.get_event(eid) if server.daemon.repo else None
199
+ if rec is None:
200
+ rec = next((r for r in server.daemon.spool.iter_records() if r["event_id"] == eid), None)
201
+ self._send(200, rec) if rec else self._send(404, {"error": "not found"})
202
+ return
203
+ if u.path == "/interlock/v1/events":
204
+ q = parse_qs(u.query)
205
+ limit = min(int(q.get("limit", ["50"])[0]), 500)
206
+ ws = q.get("workspace", [None])[0]
207
+ rows = [r for r in server.daemon.spool.iter_records(limit=limit * 4)
208
+ if ws is None or r.get("agent_workspace") == ws][:limit]
209
+ self._send(200, {"events": rows})
210
+ return
211
+ self._send(404, {"error": "not found"})
212
+
213
+ def do_POST(self) -> None:
214
+ if self._mcp("POST"):
215
+ return
216
+ u = urlparse(self.path)
217
+ if u.path in ("/v1/evaluate", "/v1/heartbeat"): # OGR conformance seat (design §5.2)
218
+ if not self._authed():
219
+ return
220
+ body = self._read_json()
221
+ if body is None:
222
+ return
223
+ if u.path == "/v1/heartbeat":
224
+ self._send(200, {"status": "ok", "version": __version__, "provider": "interlock"})
225
+ return
226
+ try:
227
+ ask = parse_qs(u.query).get("ask_maps_to", ["block"])[0]
228
+ verdict = evaluate_guard_event(server.daemon, body, ask_maps_to=ask)
229
+ except Exception as e:
230
+ log.exception("ogr evaluate failed")
231
+ self._send(400, {"error": f"evaluate failed: {type(e).__name__}: {e}"})
232
+ return
233
+ self._send(200, verdict)
234
+ return
235
+ if u.path not in ("/interlock/v1/decide", "/interlock/v1/hooks/kiro", "/interlock/v1/hooks/claude-code",
236
+ "/interlock/v1/hooks/codex", "/interlock/v1/hooks/cursor", "/interlock/v1/hooks/gemini-cli",
237
+ "/interlock/v1/hooks/copilot-cli", "/interlock/v1/hooks/windsurf", "/interlock/v1/hooks/antigravity"):
238
+ self._send(404, {"error": "not found"})
239
+ return
240
+ if not self._authed():
241
+ return
242
+ body = self._read_json()
243
+ if body is None:
244
+ return
245
+ try:
246
+ shim = body.get("adapter") or {}
247
+ if u.path == "/interlock/v1/hooks/kiro":
248
+ event = normalize_kiro(body.get("payload") or {}, agent_id=server.daemon.settings.agent_id,
249
+ adapter_version=str(shim.get("version", "0")))
250
+ elif u.path == "/interlock/v1/hooks/cursor":
251
+ event = normalize_cursor(body.get("payload") or {}, agent_id=server.daemon.settings.agent_id,
252
+ adapter_version=str(shim.get("version", "0")))
253
+ elif u.path in ("/interlock/v1/hooks/copilot-cli", "/interlock/v1/hooks/windsurf", "/interlock/v1/hooks/antigravity"):
254
+ fn = {"copilot-cli": normalize_copilot, "windsurf": normalize_windsurf,
255
+ "antigravity": normalize_antigravity}[u.path.rsplit("/", 1)[-1]]
256
+ event = fn(body.get("payload") or {}, event=str(shim.get("event") or "pre"),
257
+ agent_id=server.daemon.settings.agent_id, adapter_version=str(shim.get("version", "0")))
258
+ elif u.path == "/interlock/v1/hooks/gemini-cli":
259
+ event = normalize_gemini_cli(body.get("payload") or {}, agent_id=server.daemon.settings.agent_id,
260
+ adapter_version=str(shim.get("version", "0")))
261
+ elif u.path.startswith("/interlock/v1/hooks/"):
262
+ harness = u.path.rsplit("/", 1)[-1]
263
+ payload = body.get("payload") or {}
264
+ event = normalize_claude_code(payload, agent_type=harness, agent_id=server.daemon.settings.agent_id,
265
+ adapter_version=str(shim.get("version", "0")))
266
+ else:
267
+ event = InterlockEvent.model_validate(body)
268
+ except ValidationError as e:
269
+ self._send(400, {"error": "invalid event", "details": json.loads(e.json())[:5]})
270
+ return
271
+ except Exception as e: # noqa: BLE001 boundary: malformed hook payloads must never take the daemon down
272
+ self._send(400, {"error": f"could not normalize payload: {type(e).__name__}"})
273
+ return
274
+ try:
275
+ verdict = server.daemon.decide(event)
276
+ except Exception as e:
277
+ log.exception("decide failed")
278
+ self._send(500, {"error": f"decide failed: {type(e).__name__}"})
279
+ return
280
+ fmt = parse_qs(u.query).get("format", [""])[0]
281
+ if fmt == "text":
282
+ data = render_text(verdict).encode()
283
+ self.send_response(200)
284
+ self.send_header("Content-Type", "text/plain; charset=utf-8")
285
+ self.send_header("Content-Length", str(len(data)))
286
+ self.end_headers()
287
+ self.wfile.write(data)
288
+ return
289
+ if fmt in ("claude-code", "cursor", "gemini-cli", "copilot-cli", "antigravity"):
290
+ # The exact JSON the hook prints on stdout; an empty body means "no opinion" (the shim decides
291
+ # how to express that to its harness).
292
+ payload = (body.get("payload") or {}) if u.path.startswith("/interlock/v1/hooks/") else {}
293
+ raw = payload.get("tool_output", payload.get("result_json")) if fmt == "cursor" else payload.get("tool_response")
294
+ renderer = {"claude-code": render_claude_code, "cursor": render_cursor, "gemini-cli": render_gemini_cli,
295
+ "copilot-cli": render_copilot, "antigravity": render_antigravity}[fmt]
296
+ extra = {"harness": u.path.rsplit("/", 1)[-1]} if fmt == "claude-code" else {}
297
+ out = renderer(verdict, kind=event.kind, tool_response=raw, **extra)
298
+ data = json.dumps(out, separators=(",", ":")).encode() if out else b""
299
+ self.send_response(200)
300
+ self.send_header("Content-Type", "application/json")
301
+ self.send_header("Content-Length", str(len(data)))
302
+ self.end_headers()
303
+ self.wfile.write(data)
304
+ return
305
+ self._send(200, verdict.model_dump())
306
+
307
+ return Handler
interlockd/auth.py ADDED
@@ -0,0 +1,38 @@
1
+ """Per-install bearer token (design D6). Created on first serve, mode 0600, compared in constant time."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import hmac
6
+ import os
7
+ import secrets
8
+ from pathlib import Path
9
+
10
+
11
+ def load_or_create_token(path: Path) -> str:
12
+ if path.exists():
13
+ return path.read_text().strip()
14
+ path.parent.mkdir(parents=True, exist_ok=True)
15
+ token = secrets.token_urlsafe(32)
16
+ fd = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600)
17
+ with os.fdopen(fd, "w") as f:
18
+ f.write(token + "\n")
19
+ return token
20
+
21
+
22
+ def check_bearer(header_value: str | None, token: str) -> bool:
23
+ if not header_value or not header_value.startswith("Bearer "):
24
+ return False
25
+ return hmac.compare_digest(header_value[7:].strip(), token)
26
+
27
+
28
+ def rotate_token(path: Path) -> str:
29
+ """Replace the token atomically (mode 0600 from creation). Hooks read it per call; the daemon needs a restart."""
30
+ path.parent.mkdir(parents=True, exist_ok=True)
31
+ token = secrets.token_urlsafe(32)
32
+ tmp = path.with_name(path.name + ".new")
33
+ tmp.unlink(missing_ok=True)
34
+ fd = os.open(tmp, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600)
35
+ with os.fdopen(fd, "w") as f:
36
+ f.write(token + "\n")
37
+ os.replace(tmp, path)
38
+ return token