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/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
|