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/__init__.py
ADDED
interlockd/__main__.py
ADDED
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
|