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/insights.py
ADDED
|
@@ -0,0 +1,316 @@
|
|
|
1
|
+
"""`interlock report` (what the gate decided, from the local spool) and `interlock doctor` (is protection actually on).
|
|
2
|
+
|
|
3
|
+
Both read only local state: the spool, the install manifest, the hook files, and the daemon's health endpoint.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import json
|
|
9
|
+
import os
|
|
10
|
+
import stat
|
|
11
|
+
import urllib.request
|
|
12
|
+
from collections import Counter
|
|
13
|
+
from dataclasses import dataclass, field
|
|
14
|
+
from datetime import UTC, datetime, timedelta
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
from typing import Any
|
|
17
|
+
|
|
18
|
+
from . import __version__
|
|
19
|
+
|
|
20
|
+
STOPPED = ("block", "ask", "withhold")
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
# ------------------------------------------------------------------------------------------------ report
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@dataclass
|
|
27
|
+
class Report:
|
|
28
|
+
days: int
|
|
29
|
+
total: int = 0
|
|
30
|
+
pre: int = 0
|
|
31
|
+
post: int = 0
|
|
32
|
+
by_disposition: Counter = field(default_factory=Counter)
|
|
33
|
+
shadow_would: Counter = field(default_factory=Counter) # decisions recorded in shadow that enforce would apply
|
|
34
|
+
enforced: Counter = field(default_factory=Counter)
|
|
35
|
+
rules: Counter = field(default_factory=Counter)
|
|
36
|
+
agents: Counter = field(default_factory=Counter)
|
|
37
|
+
examples: list[dict] = field(default_factory=list)
|
|
38
|
+
latencies: list[float] = field(default_factory=list)
|
|
39
|
+
unjudged: int = 0
|
|
40
|
+
gateway_hidden: list[str] = field(default_factory=list) # "server/tool: reason" from the MCP gateway log
|
|
41
|
+
gateway_pinned: int = 0
|
|
42
|
+
|
|
43
|
+
def p(self, q: float) -> float:
|
|
44
|
+
xs = sorted(self.latencies)
|
|
45
|
+
return round(xs[min(len(xs) - 1, int(q * len(xs)))], 1) if xs else 0.0
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _detail(rec: dict) -> str:
|
|
49
|
+
a = rec.get("arguments") or {}
|
|
50
|
+
d = a.get("command") or a.get("path") or a.get("file_path") or a.get("url") or a.get("query") or ""
|
|
51
|
+
return str(d).replace("\n", " ")[:90]
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def build_report(records: list[dict], *, days: int = 7, now: datetime | None = None) -> Report:
|
|
55
|
+
now = now or datetime.now(UTC)
|
|
56
|
+
cutoff = now - timedelta(days=days)
|
|
57
|
+
r = Report(days=days)
|
|
58
|
+
for rec in records:
|
|
59
|
+
if str(rec.get("session_id") or "").startswith("interlock-selftest"):
|
|
60
|
+
continue
|
|
61
|
+
try:
|
|
62
|
+
ts = datetime.fromisoformat(rec.get("received_at", ""))
|
|
63
|
+
except ValueError:
|
|
64
|
+
continue
|
|
65
|
+
if ts < cutoff:
|
|
66
|
+
continue
|
|
67
|
+
r.total += 1
|
|
68
|
+
r.pre += rec.get("kind") == "pre_action"
|
|
69
|
+
r.post += rec.get("kind") == "post_action"
|
|
70
|
+
disp = rec.get("disposition", "?")
|
|
71
|
+
r.by_disposition[disp] += 1
|
|
72
|
+
r.agents[rec.get("agent_type") or rec.get("adapter") or "?"] += 1
|
|
73
|
+
if rec.get("unjudged"):
|
|
74
|
+
r.unjudged += 1
|
|
75
|
+
t = (rec.get("timing_ms") or {}).get("total")
|
|
76
|
+
if isinstance(t, (int, float)):
|
|
77
|
+
r.latencies.append(float(t))
|
|
78
|
+
if disp in STOPPED or disp == "warn":
|
|
79
|
+
(r.shadow_would if rec.get("mode") == "shadow" else r.enforced)[disp] += 1
|
|
80
|
+
for f in rec.get("findings") or []:
|
|
81
|
+
if f.get("rule") and not str(f.get("category", "")).startswith("x.interlock"):
|
|
82
|
+
r.rules[f["rule"]] += 1
|
|
83
|
+
if disp in STOPPED and len(r.examples) < 8:
|
|
84
|
+
r.examples.append({"at": rec.get("received_at", "")[:16].replace("T", " "), "disposition": disp,
|
|
85
|
+
"mode": rec.get("mode"), "tool": rec.get("tool_name"), "detail": _detail(rec),
|
|
86
|
+
"reason": (rec.get("reason") or "")[:90]})
|
|
87
|
+
return r
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def add_gateway_log(r: Report, log: Path, *, now: datetime | None = None) -> None:
|
|
91
|
+
"""Tool-list interventions live in ~/.interlock/mcp-proxy.log (calls and results are already in the spool)."""
|
|
92
|
+
if not log.exists():
|
|
93
|
+
return
|
|
94
|
+
cutoff = ((now or datetime.now(UTC)) - timedelta(days=r.days)).strftime("%Y-%m-%dT%H:%M:%SZ")
|
|
95
|
+
for line in log.read_text(errors="replace").splitlines():
|
|
96
|
+
if line[:20] < cutoff:
|
|
97
|
+
continue
|
|
98
|
+
try:
|
|
99
|
+
server = line.split("[", 1)[1].split("]", 1)[0]
|
|
100
|
+
msg = line.split("] ", 1)[1]
|
|
101
|
+
except IndexError:
|
|
102
|
+
continue
|
|
103
|
+
for marker in ("hid tool ", "would hide (shadow) tool "):
|
|
104
|
+
if msg.startswith(marker):
|
|
105
|
+
tool, _, why = msg[len(marker):].partition(": ")
|
|
106
|
+
entry = f"{server}/{tool}: {why[:80]}" + (" (shadow)" if "shadow" in marker else "")
|
|
107
|
+
if entry not in r.gateway_hidden:
|
|
108
|
+
r.gateway_hidden.append(entry)
|
|
109
|
+
if msg.startswith("pinned "):
|
|
110
|
+
r.gateway_pinned += int(msg.split()[1])
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def format_report(r: Report) -> str:
|
|
114
|
+
if r.total == 0 and not r.gateway_hidden:
|
|
115
|
+
return (f"No decisions recorded in the last {r.days} days. Is the daemon running and are hooks installed? "
|
|
116
|
+
"Try `interlock doctor`.")
|
|
117
|
+
lines = [f"Interlock report: last {r.days} days", "",
|
|
118
|
+
f" {r.total} decisions ({r.pre} before a tool ran, {r.post} on tool results) across "
|
|
119
|
+
+ ", ".join(f"{n} {a}" for a, n in r.agents.most_common()),
|
|
120
|
+
f" latency p50 {r.p(0.5)} ms, p95 {r.p(0.95)} ms"]
|
|
121
|
+
if r.shadow_would:
|
|
122
|
+
lines.append(" in shadow mode, enforce would have: " + ", ".join(
|
|
123
|
+
f"{_verb(d)} {n}" for d, n in r.shadow_would.most_common()))
|
|
124
|
+
if r.enforced:
|
|
125
|
+
lines.append(" enforced: " + ", ".join(f"{_verb(d)} {n}" for d, n in r.enforced.most_common()))
|
|
126
|
+
if not r.shadow_would and not r.enforced:
|
|
127
|
+
lines.append(" nothing was blocked, asked, or withheld")
|
|
128
|
+
if r.unjudged:
|
|
129
|
+
lines.append(f" {r.unjudged} decisions were partly unjudged (a detector was unavailable)")
|
|
130
|
+
if r.rules:
|
|
131
|
+
lines += ["", "Top rules:"] + [f" {n:>4} {rule}" for rule, n in r.rules.most_common(8)]
|
|
132
|
+
if r.gateway_hidden or r.gateway_pinned:
|
|
133
|
+
lines += ["", f"MCP gateway: {r.gateway_pinned} tool definitions pinned; hidden from the agent:"]
|
|
134
|
+
lines += [f" {h}" for h in r.gateway_hidden[:10]] or [" none"]
|
|
135
|
+
if r.examples:
|
|
136
|
+
lines += ["", "Recent stops:"] + [
|
|
137
|
+
f" {e['at']} {e['disposition']:<8}{' (shadow)' if e['mode'] == 'shadow' else ' '} {e['tool']:<10} {e['detail']}"
|
|
138
|
+
for e in r.examples]
|
|
139
|
+
if r.shadow_would:
|
|
140
|
+
lines += ["", ("Ready to enforce? `interlock enforce`. Too noisy? override a rule in ~/.interlock/policy.yaml "
|
|
141
|
+
"(packs.overrides) and rerun this report.")]
|
|
142
|
+
return "\n".join(lines)
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def _verb(d: str) -> str:
|
|
146
|
+
return {"block": "blocked", "ask": "asked on", "withhold": "withheld", "warn": "warned on"}.get(d, d)
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
# ------------------------------------------------------------------------------------------------ doctor
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
@dataclass
|
|
153
|
+
class Check:
|
|
154
|
+
ok: bool
|
|
155
|
+
name: str
|
|
156
|
+
detail: str
|
|
157
|
+
fix: str = ""
|
|
158
|
+
warn: bool = False # a soft finding: reported, does not fail the run
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def _health(listen: str) -> dict[str, Any] | None:
|
|
162
|
+
host, _, port = listen.rpartition(":")
|
|
163
|
+
try:
|
|
164
|
+
with urllib.request.urlopen(f"http://{host or '127.0.0.1'}:{port}/interlock/v1/health", timeout=1.5) as r:
|
|
165
|
+
return json.loads(r.read().decode())
|
|
166
|
+
except Exception: # noqa: BLE001
|
|
167
|
+
return None
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def expected_shim(harness: str) -> str | None:
|
|
171
|
+
from .cli import CLAUDE_SHIM_SRC, HOOK_SHIMS
|
|
172
|
+
|
|
173
|
+
if harness in ("claude-code", "codex", "cursor", "gemini-cli", "copilot-cli", "windsurf", "antigravity"):
|
|
174
|
+
return CLAUDE_SHIM_SRC.read_text().replace("__HARNESS__", harness)
|
|
175
|
+
if harness == "kiro":
|
|
176
|
+
return HOOK_SHIMS["sh"].read_text()
|
|
177
|
+
if harness == "opencode":
|
|
178
|
+
from . import __version__
|
|
179
|
+
from .cli import OPENCODE_PLUGIN_SRC
|
|
180
|
+
|
|
181
|
+
return OPENCODE_PLUGIN_SRC.read_text().replace("__VERSION__", __version__)
|
|
182
|
+
return None
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def _has_interlock_hook(f: Path, home: Path) -> str | None:
|
|
186
|
+
"""The shim path an existing hook file points at, if it is one of ours (covers installs made before the manifest)."""
|
|
187
|
+
if not f.exists():
|
|
188
|
+
return None
|
|
189
|
+
text = f.read_text(errors="replace")
|
|
190
|
+
for name in ("claude-code", "codex", "cursor", "gemini-cli", "copilot-cli", "windsurf", "antigravity", "kiro"):
|
|
191
|
+
shim_dir = str(home / "adapters" / name)
|
|
192
|
+
if shim_dir in text:
|
|
193
|
+
for shim in (home / "adapters" / name).glob("interlock-hook.*"):
|
|
194
|
+
if str(shim) in text:
|
|
195
|
+
return str(shim)
|
|
196
|
+
return None
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def run_doctor(home: Path, listen: str, workspace_root: str | None = None) -> list[Check]:
|
|
200
|
+
from .onboard import Manifest, detect
|
|
201
|
+
from .policy import load_policy
|
|
202
|
+
|
|
203
|
+
out: list[Check] = []
|
|
204
|
+
token = home / "token"
|
|
205
|
+
if not token.exists():
|
|
206
|
+
out.append(Check(False, "token", f"{token} missing", "run `interlock init`"))
|
|
207
|
+
else:
|
|
208
|
+
mode = stat.S_IMODE(token.stat().st_mode)
|
|
209
|
+
out.append(Check(mode & 0o077 == 0, "token", f"{token} mode {oct(mode)}", f"chmod 600 {token}"))
|
|
210
|
+
if token.stat().st_uid != os.getuid():
|
|
211
|
+
out.append(Check(False, "token owner", "token is owned by another user", f"chown $USER {token}"))
|
|
212
|
+
|
|
213
|
+
h = _health(listen)
|
|
214
|
+
if h is None:
|
|
215
|
+
out.append(Check(False, "daemon", f"not answering on http://{listen}", "run `interlock start`; logs in ~/.interlock/interlockd.log"))
|
|
216
|
+
else:
|
|
217
|
+
out.append(Check(True, "daemon", f"running {h.get('version')} on http://{listen}, mode {h.get('mode')}"))
|
|
218
|
+
if h.get("version") != __version__:
|
|
219
|
+
out.append(Check(False, "version", f"daemon {h.get('version')} vs CLI {__version__}", "run `interlock restart`"))
|
|
220
|
+
|
|
221
|
+
try:
|
|
222
|
+
pol = load_policy(workspace_root, home=home) # the same layers the daemon loads
|
|
223
|
+
live = (h or {}).get("mode") or pol.mode
|
|
224
|
+
out.append(Check(True, "policy", f"valid: {pol.source_paths or ['built-in defaults']}, mode {pol.mode}"))
|
|
225
|
+
if h and h.get("mode") != pol.mode:
|
|
226
|
+
out.append(Check(True, "mode", f"daemon runs {h.get('mode')} but the policy on disk says {pol.mode}",
|
|
227
|
+
"run `interlock restart` to apply the policy", warn=True))
|
|
228
|
+
from .policy_lint import lint
|
|
229
|
+
|
|
230
|
+
issues = lint(pol)
|
|
231
|
+
errs = [i for i in issues if i.level == "error"]
|
|
232
|
+
if errs:
|
|
233
|
+
out.append(Check(False, "policy lint", f"{errs[0].where}: {errs[0].message}" + (f" (+{len(errs) - 1} more)" if len(errs) > 1 else ""),
|
|
234
|
+
"interlock policy lint"))
|
|
235
|
+
if live == "shadow":
|
|
236
|
+
out.append(Check(True, "mode", "shadow: decisions are recorded, nothing is blocked", "`interlock enforce` when ready", warn=True))
|
|
237
|
+
except Exception as e: # noqa: BLE001
|
|
238
|
+
out.append(Check(False, "policy", f"invalid: {str(e)[:160]}", f"fix {home / 'policy.yaml'}"))
|
|
239
|
+
|
|
240
|
+
man = Manifest.load(home)
|
|
241
|
+
hooked = set()
|
|
242
|
+
for hk in (man.hooks if man else []):
|
|
243
|
+
f, marker = Path(hk["file"]), hk["marker"]
|
|
244
|
+
hooked.add(hk["harness"])
|
|
245
|
+
if not f.exists() or marker not in f.read_text():
|
|
246
|
+
out.append(Check(False, f"hooks {hk['harness']}", f"Interlock hook missing from {f} (removed or edited)",
|
|
247
|
+
f"run `interlock init --yes --harness {hk['harness']}`"))
|
|
248
|
+
continue
|
|
249
|
+
shim = Path(marker)
|
|
250
|
+
want = expected_shim(hk["harness"])
|
|
251
|
+
if not shim.exists():
|
|
252
|
+
out.append(Check(False, f"hooks {hk['harness']}", f"shim {shim} missing", "run `interlock init --yes`"))
|
|
253
|
+
elif want is not None and shim.read_text() != want:
|
|
254
|
+
out.append(Check(False, f"hooks {hk['harness']}", f"shim {shim} differs from the installed version (tampered or outdated)",
|
|
255
|
+
"run `interlock init --yes` to reinstall it"))
|
|
256
|
+
else:
|
|
257
|
+
out.append(Check(True, f"hooks {hk['harness']}", f"{f}"))
|
|
258
|
+
for d in detect():
|
|
259
|
+
if not (d.present and d.target) or d.name in hooked:
|
|
260
|
+
continue
|
|
261
|
+
shim = _has_interlock_hook(d.target, home)
|
|
262
|
+
if shim:
|
|
263
|
+
hooked.add(d.name)
|
|
264
|
+
want = expected_shim(d.name)
|
|
265
|
+
same = Path(shim).exists() and (want is None or Path(shim).read_text() == want)
|
|
266
|
+
out.append(Check(same, f"hooks {d.name}", f"{d.target} (installed with `interlock install`)" if same else
|
|
267
|
+
f"shim {shim} differs from the packaged version (tampered or outdated)",
|
|
268
|
+
"" if same else f"run `interlock init --yes --harness {d.name}`"))
|
|
269
|
+
else:
|
|
270
|
+
out.append(Check(True, f"hooks {d.name}", f"{d.name} is installed but not protected",
|
|
271
|
+
f"run `interlock init --yes --harness {d.name}`", warn=True))
|
|
272
|
+
if not hooked:
|
|
273
|
+
out.append(Check(False, "hooks", "no Interlock hooks found in any harness", "run `interlock init`"))
|
|
274
|
+
|
|
275
|
+
sd = home / "spool"
|
|
276
|
+
if sd.exists():
|
|
277
|
+
from .store.spool import Spool
|
|
278
|
+
|
|
279
|
+
r = Spool(sd, fsync=False).verify()
|
|
280
|
+
out.append(Check(r["ok"], "audit log", f"intact: {r['records']} chained records" + (f" (+{r['legacy']} legacy)" if r["legacy"] else "")
|
|
281
|
+
if r["ok"] else f"BROKEN at {r['break']['segment']} line {r['break']['line']}: {r['break']['reason']}",
|
|
282
|
+
"" if r["ok"] else "investigate: the local audit log was modified outside Interlock"))
|
|
283
|
+
try:
|
|
284
|
+
from . import gateway
|
|
285
|
+
|
|
286
|
+
rows = [c for c in gateway.status() if c.action in ("wrapped", "unwrapped")] # coverable stdio servers
|
|
287
|
+
bare = [f"{c.agent}/{c.server}" for c in rows if c.action == "unwrapped"]
|
|
288
|
+
if rows:
|
|
289
|
+
out.append(Check(True, "mcp gateway", (f"{len(rows) - len(bare)} of {len(rows)} stdio MCP servers go through the gateway"
|
|
290
|
+
+ (f"; not: {', '.join(bare[:5])}" if bare else "")),
|
|
291
|
+
"interlock gateway wrap" if bare else "", warn=bool(bare)))
|
|
292
|
+
except Exception as e: # noqa: BLE001 a config we can't parse must not break doctor
|
|
293
|
+
out.append(Check(True, "mcp gateway", f"could not read MCP configs ({type(e).__name__})", warn=True))
|
|
294
|
+
|
|
295
|
+
log = home / "hook.log"
|
|
296
|
+
if log.exists():
|
|
297
|
+
recent = [ln for ln in log.read_text(errors="replace").splitlines()[-200:] if "unjudged" in ln or "inert" in ln]
|
|
298
|
+
cutoff = (datetime.now(UTC) - timedelta(days=1)).strftime("%Y-%m-%dT%H:%M:%SZ")
|
|
299
|
+
last_day = [ln for ln in recent if ln[:20] >= cutoff]
|
|
300
|
+
if last_day:
|
|
301
|
+
out.append(Check(True, "fail-open", f"{len(last_day)} hook calls in the last day could not reach the daemon "
|
|
302
|
+
f"(see {log})", "keep the daemon running: `interlock start`", warn=True))
|
|
303
|
+
return out
|
|
304
|
+
|
|
305
|
+
|
|
306
|
+
def format_doctor(checks: list[Check]) -> tuple[str, int]:
|
|
307
|
+
lines, failed = [], 0
|
|
308
|
+
for c in checks:
|
|
309
|
+
mark = "!" if c.warn else ("✓" if c.ok else "✗")
|
|
310
|
+
failed += (not c.ok) and not c.warn
|
|
311
|
+
lines.append(f" {mark} {c.name:<18} {c.detail}")
|
|
312
|
+
if (not c.ok or c.warn) and c.fix:
|
|
313
|
+
lines.append(f" {'':<18} → {c.fix}")
|
|
314
|
+
lines.append("")
|
|
315
|
+
lines.append("All checks passed." if failed == 0 else f"{failed} problem{'s' if failed > 1 else ''} found.")
|
|
316
|
+
return "\n".join(lines), (1 if failed else 0)
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
"""A small client for the local Interlock daemon, shared by framework integrations.
|
|
2
|
+
|
|
3
|
+
from interlockd.integrations.client import InterlockClient
|
|
4
|
+
v = InterlockClient().decide("pre_action", "shell", {"command": "git push --force origin main"})
|
|
5
|
+
if InterlockClient.stops(v): ...
|
|
6
|
+
|
|
7
|
+
Reads the token, the daemon address, and the fail mode from ~/.interlock (or INTERLOCK_HOME / INTERLOCK_URL), like
|
|
8
|
+
the hook shims. Returns the daemon's verdict dict, or None when the daemon can't be reached (apply `fails_closed()`).
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import json
|
|
14
|
+
import os
|
|
15
|
+
import urllib.request
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
from typing import Any
|
|
18
|
+
|
|
19
|
+
from .. import __version__
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class InterlockClient:
|
|
23
|
+
def __init__(self, *, home: str | Path | None = None, url: str | None = None, token: str | None = None,
|
|
24
|
+
agent_type: str = "sdk", session: str | None = None, timeout: float = 1.5):
|
|
25
|
+
self.home = Path(home or os.environ.get("INTERLOCK_HOME") or "~/.interlock").expanduser()
|
|
26
|
+
self._url, self._token = url or os.environ.get("INTERLOCK_URL"), token
|
|
27
|
+
self.agent_type, self.session, self.timeout = agent_type, session, timeout
|
|
28
|
+
|
|
29
|
+
@property
|
|
30
|
+
def url(self) -> str:
|
|
31
|
+
if self._url:
|
|
32
|
+
return self._url.rstrip("/")
|
|
33
|
+
try:
|
|
34
|
+
listen = json.loads((self.home / "daemon.json").read_text()).get("listen") or "127.0.0.1:7391"
|
|
35
|
+
except (OSError, ValueError):
|
|
36
|
+
listen = "127.0.0.1:7391"
|
|
37
|
+
return f"http://{listen}"
|
|
38
|
+
|
|
39
|
+
@property
|
|
40
|
+
def token(self) -> str:
|
|
41
|
+
if self._token is None:
|
|
42
|
+
try:
|
|
43
|
+
self._token = (self.home / "token").read_text().strip()
|
|
44
|
+
except OSError:
|
|
45
|
+
self._token = ""
|
|
46
|
+
return self._token
|
|
47
|
+
|
|
48
|
+
def fails_closed(self) -> bool:
|
|
49
|
+
env = os.environ.get("INTERLOCK_FAIL_MODE")
|
|
50
|
+
if env in ("open", "closed"):
|
|
51
|
+
return env == "closed"
|
|
52
|
+
try:
|
|
53
|
+
return json.loads((self.home / "fail-mode.json").read_text()).get("default") == "closed"
|
|
54
|
+
except (OSError, ValueError):
|
|
55
|
+
return False
|
|
56
|
+
|
|
57
|
+
def decide(self, kind: str, tool: str, arguments: dict | None = None, *, content: str | None = None,
|
|
58
|
+
session: str | None = None, cwd: str | None = None, withhold: bool = True) -> dict[str, Any] | None:
|
|
59
|
+
if not self.token:
|
|
60
|
+
return None
|
|
61
|
+
event: dict[str, Any] = {
|
|
62
|
+
"kind": kind, "adapter": {"name": self.agent_type, "version": __version__},
|
|
63
|
+
"session_hint": session or self.session, "identity": {"agent_type": self.agent_type},
|
|
64
|
+
"tool_call": {"name": str(tool), "arguments": arguments if isinstance(arguments, dict) else {"value": arguments}},
|
|
65
|
+
"context": {"cwd": cwd or os.getcwd()}, "capabilities": {"ask": False, "rewrite": False, "withhold": withhold}}
|
|
66
|
+
if content is not None:
|
|
67
|
+
event["result"] = {"content": content[:200_000]}
|
|
68
|
+
req = urllib.request.Request(f"{self.url}/interlock/v1/decide", data=json.dumps(event).encode(), method="POST")
|
|
69
|
+
req.add_header("authorization", f"Bearer {self.token}")
|
|
70
|
+
req.add_header("content-type", "application/json")
|
|
71
|
+
try:
|
|
72
|
+
with urllib.request.urlopen(req, timeout=self.timeout if kind == "pre_action" else self.timeout + 1) as r:
|
|
73
|
+
return json.loads(r.read())
|
|
74
|
+
except Exception: # noqa: BLE001 unreachable daemon: the caller applies fails_closed()
|
|
75
|
+
return None
|
|
76
|
+
|
|
77
|
+
@staticmethod
|
|
78
|
+
def stops(v: dict | None) -> bool:
|
|
79
|
+
return bool(v) and v.get("mode") == "enforce" and v.get("disposition") in ("block", "ask")
|
|
80
|
+
|
|
81
|
+
@staticmethod
|
|
82
|
+
def withholds(v: dict | None) -> bool:
|
|
83
|
+
return bool(v) and v.get("mode") == "enforce" and v.get("disposition") == "withhold"
|
|
84
|
+
|
|
85
|
+
UNREACHABLE = "[interlock] This action was NOT executed: the Interlock daemon is unreachable and this machine fails closed."
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
"""Interlock for LangChain and LangGraph: wrap tools so every call is gated and every result checked.
|
|
2
|
+
|
|
3
|
+
from interlockd.integrations.langchain import guard_tools
|
|
4
|
+
tools = guard_tools([search, shell, ...])
|
|
5
|
+
agent = create_react_agent(model, tools) # or ToolNode(tools), AgentExecutor(tools=tools)
|
|
6
|
+
|
|
7
|
+
A refused call returns Interlock's notice as the tool's output (the tool never runs), which the model reads like any
|
|
8
|
+
tool error; a withheld result is replaced by the notice. Built against langchain-core 1.6.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
from typing import Any
|
|
14
|
+
|
|
15
|
+
from .client import InterlockClient
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def guard_tools(tools: list[Any], client: InterlockClient | None = None, *, session: str | None = None) -> list[Any]:
|
|
19
|
+
from langchain_core.tools import BaseTool
|
|
20
|
+
|
|
21
|
+
client = client or InterlockClient(agent_type="langchain")
|
|
22
|
+
|
|
23
|
+
class GuardedTool(BaseTool):
|
|
24
|
+
inner: Any = None
|
|
25
|
+
|
|
26
|
+
def _decide_pre(self, args: dict) -> str | None:
|
|
27
|
+
v = client.decide("pre_action", self.name, args, session=session)
|
|
28
|
+
if v is None:
|
|
29
|
+
return InterlockClient.UNREACHABLE if client.fails_closed() else None
|
|
30
|
+
return (v.get("notice") or f"[interlock] refused: {v.get('reason')}") if InterlockClient.stops(v) else None
|
|
31
|
+
|
|
32
|
+
def _decide_post(self, args: dict, out: Any) -> Any:
|
|
33
|
+
text = out if isinstance(out, str) else str(getattr(out, "content", out))
|
|
34
|
+
v = client.decide("post_action", self.name, args, content=text, session=session)
|
|
35
|
+
return (v.get("notice") or "[interlock] tool result withheld") if InterlockClient.withholds(v) else out
|
|
36
|
+
|
|
37
|
+
def _run(self, *args: Any, **kwargs: Any) -> Any:
|
|
38
|
+
kwargs.pop("run_manager", None)
|
|
39
|
+
refusal = self._decide_pre(kwargs)
|
|
40
|
+
if refusal:
|
|
41
|
+
return refusal
|
|
42
|
+
return self._decide_post(kwargs, self.inner.invoke(kwargs if kwargs else (args[0] if args else {})))
|
|
43
|
+
|
|
44
|
+
async def _arun(self, *args: Any, **kwargs: Any) -> Any:
|
|
45
|
+
kwargs.pop("run_manager", None)
|
|
46
|
+
refusal = self._decide_pre(kwargs)
|
|
47
|
+
if refusal:
|
|
48
|
+
return refusal
|
|
49
|
+
return self._decide_post(kwargs, await self.inner.ainvoke(kwargs if kwargs else (args[0] if args else {})))
|
|
50
|
+
|
|
51
|
+
out = []
|
|
52
|
+
for t in tools:
|
|
53
|
+
if not isinstance(t, BaseTool):
|
|
54
|
+
from langchain_core.tools import tool as as_tool
|
|
55
|
+
|
|
56
|
+
t = as_tool(t)
|
|
57
|
+
out.append(GuardedTool(name=t.name, description=t.description, args_schema=t.args_schema, inner=t,
|
|
58
|
+
return_direct=getattr(t, "return_direct", False)))
|
|
59
|
+
return out
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
"""Interlock for the OpenAI Agents SDK: native tool guardrails on every function tool.
|
|
2
|
+
|
|
3
|
+
from agents import Agent
|
|
4
|
+
from interlockd.integrations.openai_agents import guard_agent_tools
|
|
5
|
+
agent = guard_agent_tools(Agent(name="dev", tools=[shell, search]))
|
|
6
|
+
|
|
7
|
+
Uses the SDK's own ToolInputGuardrail / ToolOutputGuardrail: a refused call or a withheld result becomes
|
|
8
|
+
`reject_content(<notice>)`, so the tool never runs (or its output is replaced) and the model reads the notice.
|
|
9
|
+
Built against openai-agents 0.24.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import json
|
|
15
|
+
from typing import Any
|
|
16
|
+
|
|
17
|
+
from .client import InterlockClient
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def interlock_guardrails(client: InterlockClient | None = None, *, session: str | None = None):
|
|
21
|
+
"""(input_guardrail, output_guardrail) to add to any FunctionTool."""
|
|
22
|
+
from agents import ToolGuardrailFunctionOutput, tool_input_guardrail, tool_output_guardrail
|
|
23
|
+
|
|
24
|
+
client = client or InterlockClient(agent_type="openai-agents")
|
|
25
|
+
|
|
26
|
+
def _args(ctx: Any) -> dict:
|
|
27
|
+
raw = getattr(ctx, "tool_arguments", None) or "{}"
|
|
28
|
+
try:
|
|
29
|
+
v = json.loads(raw) if isinstance(raw, str) else raw
|
|
30
|
+
except ValueError:
|
|
31
|
+
v = {"value": raw}
|
|
32
|
+
return v if isinstance(v, dict) else {"value": v}
|
|
33
|
+
|
|
34
|
+
@tool_input_guardrail(name="interlock")
|
|
35
|
+
def interlock_input(data: Any) -> Any:
|
|
36
|
+
ctx = data.context
|
|
37
|
+
v = client.decide("pre_action", ctx.tool_name, _args(ctx), session=session)
|
|
38
|
+
if v is None:
|
|
39
|
+
return ToolGuardrailFunctionOutput.reject_content(InterlockClient.UNREACHABLE) if client.fails_closed() \
|
|
40
|
+
else ToolGuardrailFunctionOutput.allow()
|
|
41
|
+
if InterlockClient.stops(v):
|
|
42
|
+
return ToolGuardrailFunctionOutput.reject_content(v.get("notice") or f"[interlock] refused: {v.get('reason')}",
|
|
43
|
+
output_info={"rules": [f.get("rule") for f in v.get("findings") or []]})
|
|
44
|
+
return ToolGuardrailFunctionOutput.allow()
|
|
45
|
+
|
|
46
|
+
@tool_output_guardrail(name="interlock")
|
|
47
|
+
def interlock_output(data: Any) -> Any:
|
|
48
|
+
ctx = data.context
|
|
49
|
+
out = data.output
|
|
50
|
+
text = out if isinstance(out, str) else json.dumps(out, default=str)
|
|
51
|
+
v = client.decide("post_action", ctx.tool_name, _args(ctx), content=text, session=session)
|
|
52
|
+
if InterlockClient.withholds(v):
|
|
53
|
+
return ToolGuardrailFunctionOutput.reject_content(v.get("notice") or "[interlock] tool result withheld")
|
|
54
|
+
return ToolGuardrailFunctionOutput.allow()
|
|
55
|
+
|
|
56
|
+
return interlock_input, interlock_output
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def guard_agent_tools(agent: Any, client: InterlockClient | None = None, *, session: str | None = None) -> Any:
|
|
60
|
+
"""Attach Interlock's guardrails to every FunctionTool on the agent (idempotent). Returns the agent."""
|
|
61
|
+
from agents import FunctionTool
|
|
62
|
+
|
|
63
|
+
gin, gout = interlock_guardrails(client, session=session)
|
|
64
|
+
for t in agent.tools:
|
|
65
|
+
if isinstance(t, FunctionTool):
|
|
66
|
+
ins = list(t.tool_input_guardrails or [])
|
|
67
|
+
outs = list(t.tool_output_guardrails or [])
|
|
68
|
+
if not any(getattr(g, "name", None) == "interlock" for g in ins):
|
|
69
|
+
t.tool_input_guardrails = [*ins, gin]
|
|
70
|
+
if not any(getattr(g, "name", None) == "interlock" for g in outs):
|
|
71
|
+
t.tool_output_guardrails = [*outs, gout]
|
|
72
|
+
return agent
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
"""Interlock for Strands Agents: a HookProvider that gates every tool call and checks every tool result.
|
|
2
|
+
|
|
3
|
+
from strands import Agent
|
|
4
|
+
from interlockd.integrations.strands import InterlockHooks
|
|
5
|
+
agent = Agent(tools=[...], hooks=[InterlockHooks()])
|
|
6
|
+
|
|
7
|
+
BeforeToolCallEvent: a refusal sets `cancel_tool` to Interlock's notice (Strands returns it to the model as an error
|
|
8
|
+
tool result, and the tool never runs). AfterToolCallEvent: a withhold replaces `result` with the notice. Measured
|
|
9
|
+
against strands-agents 1.61.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import json
|
|
15
|
+
from typing import Any
|
|
16
|
+
|
|
17
|
+
from .client import InterlockClient
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _text(result: Any) -> str:
|
|
21
|
+
if isinstance(result, dict):
|
|
22
|
+
parts = []
|
|
23
|
+
for c in result.get("content") or []:
|
|
24
|
+
if isinstance(c, dict):
|
|
25
|
+
if isinstance(c.get("text"), str):
|
|
26
|
+
parts.append(c["text"])
|
|
27
|
+
elif "json" in c:
|
|
28
|
+
parts.append(json.dumps(c["json"]))
|
|
29
|
+
return "\n".join(parts)
|
|
30
|
+
return "" if result is None else str(result)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class InterlockHooks:
|
|
34
|
+
"""Strands HookProvider (duck-typed; Strands checks `register_hooks`)."""
|
|
35
|
+
|
|
36
|
+
def __init__(self, client: InterlockClient | None = None, *, session: str | None = None):
|
|
37
|
+
self.client = client or InterlockClient(agent_type="strands")
|
|
38
|
+
self.session = session
|
|
39
|
+
|
|
40
|
+
def register_hooks(self, registry: Any, **kwargs: Any) -> None:
|
|
41
|
+
from strands.hooks import AfterToolCallEvent, BeforeToolCallEvent
|
|
42
|
+
|
|
43
|
+
registry.add_callback(BeforeToolCallEvent, self.before_tool)
|
|
44
|
+
registry.add_callback(AfterToolCallEvent, self.after_tool)
|
|
45
|
+
|
|
46
|
+
def _session(self, event: Any) -> str | None:
|
|
47
|
+
return self.session or getattr(getattr(event, "agent", None), "agent_id", None) or getattr(getattr(event, "agent", None), "name", None)
|
|
48
|
+
|
|
49
|
+
def before_tool(self, event: Any) -> None:
|
|
50
|
+
tu = event.tool_use
|
|
51
|
+
v = self.client.decide("pre_action", tu.get("name", ""), tu.get("input") or {}, session=self._session(event))
|
|
52
|
+
if v is None:
|
|
53
|
+
if self.client.fails_closed():
|
|
54
|
+
event.cancel_tool = InterlockClient.UNREACHABLE
|
|
55
|
+
return
|
|
56
|
+
if InterlockClient.stops(v):
|
|
57
|
+
event.cancel_tool = v.get("notice") or f"[interlock] refused: {v.get('reason')}"
|
|
58
|
+
|
|
59
|
+
def after_tool(self, event: Any) -> None:
|
|
60
|
+
if getattr(event, "cancel_message", None) or not isinstance(event.result, dict):
|
|
61
|
+
return
|
|
62
|
+
text = _text(event.result)
|
|
63
|
+
if not text.strip():
|
|
64
|
+
return
|
|
65
|
+
tu = event.tool_use
|
|
66
|
+
v = self.client.decide("post_action", tu.get("name", ""), tu.get("input") or {}, content=text, session=self._session(event))
|
|
67
|
+
if InterlockClient.withholds(v):
|
|
68
|
+
event.result = {"toolUseId": event.result.get("toolUseId", tu.get("toolUseId")), "status": "success",
|
|
69
|
+
"content": [{"text": v.get("notice") or "[interlock] tool result withheld"}]}
|