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/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,2 @@
1
+ """In-process integrations for agent frameworks (Strands Agents, LangChain / LangGraph). Each imports its framework
2
+ lazily, so interlockd itself never depends on them."""
@@ -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"}]}