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/gateway.py ADDED
@@ -0,0 +1,350 @@
1
+ """`interlock gateway wrap | unwrap | status`: route every stdio MCP server through `interlock mcp-proxy`.
2
+
3
+ Wrapping rewrites a server entry's launch command, keeping the original after `--` so unwrapping is exact:
4
+
5
+ {"command": "npx", "args": ["-y", "pkg@1"]}
6
+ -> {"command": "<python>", "args": ["-m", "interlockd", "mcp-proxy", "--name", "<server>", "--", "npx", "-y", "pkg@1"]}
7
+
8
+ Edited in place: the JSON configs of Cursor, Kiro, Gemini CLI, Claude Desktop, Windsurf, VS Code, and a project's
9
+ `.mcp.json`; Codex's config.toml by rewriting only the command/args of each [mcp_servers.<name>] table, verified by
10
+ re-parsing before anything is written. Claude Code's user-scope servers go through its own CLI (`claude mcp remove` +
11
+ `add-json`) because Claude rewrites ~/.claude.json itself. Not wrapped (reported instead): remote HTTP servers (they
12
+ need the HTTP gateway), disabled servers, inline-table TOML entries, and Interlock's own MCP server.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import json
18
+ import shutil
19
+ import subprocess
20
+ import sys
21
+ from dataclasses import dataclass
22
+ from pathlib import Path
23
+
24
+ PROXY_MARK = "mcp-proxy"
25
+
26
+
27
+ @dataclass
28
+ class Change:
29
+ agent: str
30
+ server: str
31
+ source: str
32
+ action: str # wrapped | unwrapped | skipped
33
+ detail: str = ""
34
+
35
+
36
+ def json_targets(home: Path, project: Path) -> list[tuple[str, Path, str]]:
37
+ return [
38
+ ("claude-code", project / ".mcp.json", "mcpServers"),
39
+ ("claude-desktop", home / "Library" / "Application Support" / "Claude" / "claude_desktop_config.json", "mcpServers"),
40
+ ("claude-desktop", home / ".config" / "Claude" / "claude_desktop_config.json", "mcpServers"),
41
+ ("cursor", home / ".cursor" / "mcp.json", "mcpServers"),
42
+ ("cursor", project / ".cursor" / "mcp.json", "mcpServers"),
43
+ ("vscode", project / ".vscode" / "mcp.json", "servers"),
44
+ ("kiro", home / ".kiro" / "settings" / "mcp.json", "mcpServers"),
45
+ ("kiro", project / ".kiro" / "settings" / "mcp.json", "mcpServers"),
46
+ ("gemini-cli", home / ".gemini" / "settings.json", "mcpServers"),
47
+ ("gemini-cli", project / ".gemini" / "settings.json", "mcpServers"),
48
+ ("windsurf", home / ".codeium" / "windsurf" / "mcp_config.json", "mcpServers"),
49
+ ]
50
+
51
+
52
+ def is_wrapped(cfg: dict) -> bool:
53
+ args = cfg.get("args") or []
54
+ return PROXY_MARK in args and "--" in args
55
+
56
+
57
+ def wrap_entry(name: str, cfg: dict, python: str | None = None) -> dict:
58
+ bare = ["--bare"] if "args" not in cfg else [] # remember the original had no args key: exact unwrap
59
+ return {**cfg, "command": python or sys.executable,
60
+ "args": ["-m", "interlockd", PROXY_MARK, "--name", name, *bare, "--", str(cfg["command"]),
61
+ *[str(a) for a in cfg.get("args") or []]]}
62
+
63
+
64
+ def unwrap_entry(cfg: dict) -> dict:
65
+ args = list(cfg.get("args") or [])
66
+ rest = args[args.index("--") + 1:]
67
+ if not rest:
68
+ return cfg
69
+ out = {**cfg, "command": rest[0], "args": rest[1:]}
70
+ if "--bare" in args[:args.index("--")]:
71
+ out.pop("args")
72
+ return out
73
+
74
+
75
+ def _local_route(cfg: dict) -> bool:
76
+ return isinstance(cfg.get("url"), str) and "/mcp/" in cfg["url"] and cfg["url"].startswith(("http://127.0.0.1", "http://localhost"))
77
+
78
+
79
+ def wrap_http(name: str, cfg: dict, routes, listen: str, dry_run: bool) -> dict:
80
+ if not dry_run:
81
+ routes.add(name, cfg["url"])
82
+ return {**cfg, "url": routes.local_url(listen, name) if not dry_run else f"http://{listen}/mcp/{name}/<secret>"}
83
+
84
+
85
+ def unwrap_http(name: str, cfg: dict, routes, dry_run: bool) -> dict | None:
86
+ upstream = (routes.load().get("routes") or {}).get(name, {}).get("url")
87
+ if not upstream:
88
+ return None
89
+ if not dry_run:
90
+ routes.remove(name)
91
+ return {**cfg, "url": upstream}
92
+
93
+
94
+ def _skip_reason(name: str, cfg: dict) -> str | None:
95
+ if not isinstance(cfg, dict):
96
+ return "not a server entry"
97
+ if name == "interlock" or (cfg.get("args") or [])[-1:] == ["mcp"] and "interlockd" in (cfg.get("args") or []):
98
+ return "Interlock's own MCP server"
99
+ if cfg.get("disabled") or cfg.get("enabled") is False:
100
+ return "disabled"
101
+ if not cfg.get("command") and not cfg.get("url"):
102
+ return "no command or url"
103
+ if cfg.get("url") and not str(cfg["url"]).startswith(("http://", "https://")):
104
+ return "unsupported url scheme"
105
+ return None
106
+
107
+
108
+ def _ctx():
109
+ from .config import Settings
110
+ from .mcp_http import Routes
111
+
112
+ s = Settings.load()
113
+ return Routes(s.home), s.listen
114
+
115
+
116
+ def _wrap_one(name: str, cfg: dict, python: str | None, dry_run: bool) -> tuple[dict | None, str | None]:
117
+ why = _skip_reason(name, cfg) or ("already wrapped" if is_wrapped(cfg) or _local_route(cfg) else None)
118
+ if why:
119
+ return None, why
120
+ if cfg.get("url") and not cfg.get("command"):
121
+ routes, listen = _ctx()
122
+ return wrap_http(name, cfg, routes, listen, dry_run), None
123
+ return wrap_entry(name, cfg, python), None
124
+
125
+
126
+ def _unwrap_one(name: str, cfg: dict, dry_run: bool) -> dict | None:
127
+ if not isinstance(cfg, dict):
128
+ return None
129
+ if is_wrapped(cfg):
130
+ return unwrap_entry(cfg)
131
+ if _local_route(cfg):
132
+ routes, _ = _ctx()
133
+ return unwrap_http(name, cfg, routes, dry_run)
134
+ return None
135
+
136
+
137
+ def _edit_json(agent: str, path: Path, key: str, wrap: bool, dry_run: bool, python: str | None) -> list[Change]:
138
+ try:
139
+ data = json.loads(path.read_text()) if path.read_text().strip() else {}
140
+ except (OSError, ValueError):
141
+ return []
142
+ servers = data.get(key)
143
+ if not isinstance(servers, dict):
144
+ return []
145
+ out, changed = [], False
146
+ for name, cfg in servers.items():
147
+ if wrap:
148
+ new, why = _wrap_one(name, cfg, python, dry_run)
149
+ if why:
150
+ out.append(Change(agent, name, str(path), "skipped", why))
151
+ continue
152
+ servers[name] = new
153
+ out.append(Change(agent, name, str(path), "wrapped", "via HTTP gateway" if cfg.get("url") and not cfg.get("command") else ""))
154
+ else:
155
+ new = _unwrap_one(name, cfg, dry_run)
156
+ if new is None:
157
+ continue
158
+ servers[name] = new
159
+ out.append(Change(agent, name, str(path), "unwrapped"))
160
+ changed = True
161
+ if changed and not dry_run:
162
+ backup = path.with_name(path.name + ".interlock-backup")
163
+ if not backup.exists():
164
+ shutil.copy2(path, backup)
165
+ path.write_text(json.dumps(data, indent=2) + "\n")
166
+ return out
167
+
168
+
169
+ def _claude_user(wrap: bool, dry_run: bool, python: str | None, home: Path, run) -> list[Change]:
170
+ """Claude Code user scope lives in ~/.claude.json, which Claude rewrites; change it only through `claude mcp`."""
171
+ f = home / ".claude.json"
172
+ try:
173
+ servers = (json.loads(f.read_text()).get("mcpServers") or {}) if f.exists() else {}
174
+ except ValueError:
175
+ return []
176
+ claude = shutil.which("claude")
177
+ out = []
178
+ for name, cfg in servers.items():
179
+ if wrap:
180
+ new, why = _wrap_one(name, cfg, python, dry_run)
181
+ else:
182
+ new = _unwrap_one(name, cfg, dry_run)
183
+ why = None
184
+ if new is None:
185
+ continue
186
+ if why:
187
+ out.append(Change("claude-code", name, str(f), "skipped", why))
188
+ continue
189
+ if not claude:
190
+ out.append(Change("claude-code", name, str(f), "skipped", "claude CLI not on PATH"))
191
+ continue
192
+ if not dry_run:
193
+ run([claude, "mcp", "remove", "--scope", "user", name])
194
+ entry = {k: v for k, v in new.items() if k in ("command", "args", "env", "type", "url", "headers")}
195
+ entry.setdefault("type", "http" if entry.get("url") else "stdio")
196
+ r = run([claude, "mcp", "add-json", "--scope", "user", name, json.dumps(entry)])
197
+ if r.returncode != 0:
198
+ run([claude, "mcp", "add-json", "--scope", "user", name, json.dumps(cfg)]) # put the original back
199
+ out.append(Change("claude-code", name, str(f), "skipped", f"claude mcp add-json failed: {r.stderr.strip()[:120]}"))
200
+ continue
201
+ out.append(Change("claude-code", name, str(f), "wrapped" if wrap else "unwrapped"))
202
+ return out
203
+
204
+
205
+ def _toml_value_end(lines: list[str], i: int) -> int:
206
+ """Index after the last line of the `key = value` starting at line i (multi-line arrays balanced outside strings)."""
207
+ depth, j = 0, i
208
+ while j < len(lines):
209
+ in_str, q = False, ""
210
+ for ch in lines[j]:
211
+ if in_str:
212
+ if ch == q:
213
+ in_str = False
214
+ elif ch in "\"'":
215
+ in_str, q = True, ch
216
+ elif ch == "#":
217
+ break
218
+ elif ch == "[":
219
+ depth += 1
220
+ elif ch == "]":
221
+ depth -= 1
222
+ j += 1
223
+ if depth <= 0:
224
+ return j
225
+ return j
226
+
227
+
228
+ def rewrite_codex_toml(text: str, update) -> tuple[str, list[str]]:
229
+ """Rewrite command/args of each [mcp_servers.<name>] table via update(name, cfg) -> new cfg | None.
230
+ Returns (new text, names changed). The caller verifies the result by re-parsing."""
231
+ import re
232
+
233
+ lines = text.splitlines(keepends=True)
234
+ out, changed, i = [], [], 0
235
+ header = re.compile(r"^\s*\[mcp_servers\.(\"[^\"]+\"|[A-Za-z0-9_-]+)\]\s*(#.*)?$")
236
+ import tomllib
237
+
238
+ data = tomllib.loads(text).get("mcp_servers") or {}
239
+ while i < len(lines):
240
+ m = header.match(lines[i].rstrip("\n"))
241
+ if not m:
242
+ out.append(lines[i]); i += 1
243
+ continue
244
+ name = m.group(1).strip('"')
245
+ out.append(lines[i]); i += 1
246
+ start = i
247
+ while i < len(lines) and not re.match(r"^\s*\[", lines[i]):
248
+ i += 1
249
+ block = lines[start:i]
250
+ new = update(name, data.get(name) or {})
251
+ if new is None:
252
+ out += block
253
+ continue
254
+ kept, k = [], 0
255
+ while k < len(block):
256
+ if re.match(r"^\s*(command|args)\s*=", block[k]):
257
+ k = _toml_value_end(block, k)
258
+ continue
259
+ kept.append(block[k]); k += 1
260
+ assign = f"command = {json.dumps(new['command'])}\n"
261
+ if "args" in new:
262
+ assign += "args = [" + ", ".join(json.dumps(a) for a in new["args"]) + "]\n"
263
+ out += [assign, *kept]
264
+ changed.append(name)
265
+ return "".join(out), changed
266
+
267
+
268
+ def _codex(wrap: bool, dry_run: bool, python: str | None, home: Path) -> list[Change]:
269
+ import tomllib
270
+
271
+ f = home / ".codex" / "config.toml"
272
+ if not f.exists():
273
+ return []
274
+ text = f.read_text()
275
+ try:
276
+ before = tomllib.loads(text)
277
+ except tomllib.TOMLDecodeError as e:
278
+ return [Change("codex", "*", str(f), "skipped", f"config.toml does not parse: {e}")]
279
+ servers = before.get("mcp_servers") or {}
280
+ out: list[Change] = []
281
+ expected: dict[str, dict] = {}
282
+
283
+ def update(name, cfg):
284
+ if wrap:
285
+ why = _skip_reason(name, cfg) or ("already wrapped" if is_wrapped(cfg) else None)
286
+ if not why and cfg.get("url") and not cfg.get("command"):
287
+ why = "remote server in config.toml: point its url at the HTTP gateway by hand (see docs)"
288
+ if why:
289
+ out.append(Change("codex", name, str(f), "skipped", why))
290
+ return None
291
+ new = wrap_entry(name, cfg, python)
292
+ else:
293
+ if not is_wrapped(cfg):
294
+ return None
295
+ new = unwrap_entry(cfg)
296
+ expected[name] = new
297
+ return new
298
+
299
+ new_text, changed = rewrite_codex_toml(text, update)
300
+ for name in servers:
301
+ if name not in changed and name not in {c.server for c in out} and wrap and servers[name] is not None \
302
+ and not is_wrapped(servers[name]):
303
+ out.append(Change("codex", name, str(f), "skipped", "not a [mcp_servers.<name>] table (inline form); wrap by hand"))
304
+ if not changed:
305
+ return out
306
+ # safety net: only command/args of the changed servers may differ
307
+ after = tomllib.loads(new_text)
308
+ want = {**before, "mcp_servers": {**servers, **{n: {**{k: v for k, v in servers[n].items() if k not in ("command", "args")},
309
+ **{k: v for k, v in expected[n].items() if k in ("command", "args")}}
310
+ for n in changed}}}
311
+ if after != want:
312
+ return out + [Change("codex", n, str(f), "skipped", "rewrite did not verify; left untouched") for n in changed]
313
+ if not dry_run:
314
+ backup = f.with_name(f.name + ".interlock-backup")
315
+ if not backup.exists():
316
+ shutil.copy2(f, backup)
317
+ f.write_text(new_text)
318
+ return out + [Change("codex", n, str(f), "wrapped" if wrap else "unwrapped") for n in changed]
319
+
320
+
321
+ def apply(wrap: bool, *, home: Path | None = None, project: Path | None = None, dry_run: bool = False,
322
+ python: str | None = None, run=None) -> list[Change]:
323
+ home = home or Path.home()
324
+ project = (project or Path.cwd()).resolve()
325
+ run = run or (lambda cmd: subprocess.run(cmd, capture_output=True, text=True, check=False))
326
+ out: list[Change] = []
327
+ seen: set[Path] = set()
328
+ for agent, path, key in json_targets(home, project):
329
+ rp = path.resolve() if path.exists() else path
330
+ if path.is_file() and rp not in seen:
331
+ seen.add(rp)
332
+ out += _edit_json(agent, path, key, wrap, dry_run, python)
333
+ out += _claude_user(wrap, dry_run, python, home, run)
334
+ out += _codex(wrap, dry_run, python, home)
335
+ return out
336
+
337
+
338
+ def status(home: Path | None = None, project: Path | None = None) -> list[Change]:
339
+ """Which stdio servers go through the gateway, without changing anything."""
340
+ from .scan import discover
341
+
342
+ servers, _ = discover(home or Path.home(), (project or Path.cwd()).resolve())
343
+ out = []
344
+ for s in servers:
345
+ cfg = {"command": s.command, "args": s.args, "disabled": s.disabled, **({"url": s.url} if s.url else {})}
346
+ why = _skip_reason(s.name, cfg)
347
+ wrapped = is_wrapped(cfg) or _local_route(cfg)
348
+ out.append(Change(s.agent, s.name, s.source, "wrapped" if wrapped else ("skipped" if why else "unwrapped"),
349
+ why or ("via HTTP gateway" if wrapped and s.url else "")))
350
+ return out
interlockd/ids.py ADDED
@@ -0,0 +1,25 @@
1
+ """ULID generation (Crockford base32, time-ordered) for event ids."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import secrets
6
+ import time
7
+
8
+ _ALPHABET = "0123456789ABCDEFGHJKMNPQRSTVWXYZ"
9
+
10
+
11
+ def ulid(ts_ms: int | None = None) -> str:
12
+ ts = int(time.time() * 1000) if ts_ms is None else ts_ms
13
+ value = (ts << 80) | secrets.randbits(80)
14
+ out = []
15
+ for _ in range(26):
16
+ out.append(_ALPHABET[value & 31])
17
+ value >>= 5
18
+ return "".join(reversed(out))
19
+
20
+
21
+ def ulid_ts_ms(value: str) -> int:
22
+ n = 0
23
+ for ch in value[:10]:
24
+ n = n * 32 + _ALPHABET.index(ch)
25
+ return n