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/service.py ADDED
@@ -0,0 +1,247 @@
1
+ """Run the daemon as a background service: launchd (macOS), systemd --user (Linux), or a detached process.
2
+
3
+ One `ServiceManager` per Interlock home. The service runs `<python> -m interlockd serve` with INTERLOCK_HOME set,
4
+ logs to `<home>/interlockd.log`, and restarts on crash under launchd/systemd. The detached-process backend is the
5
+ fallback (containers, CI, a Linux box without a user systemd) and records its pid in `<home>/interlockd.pid`.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import hashlib
11
+ import os
12
+ import platform
13
+ import shutil
14
+ import signal
15
+ import subprocess
16
+ import sys
17
+ import time
18
+ import urllib.request
19
+ from collections.abc import Callable
20
+ from dataclasses import dataclass
21
+ from pathlib import Path
22
+ from typing import Literal
23
+
24
+ Backend = Literal["launchd", "systemd", "process"]
25
+ DEFAULT_HOME = Path("~/.interlock").expanduser()
26
+
27
+
28
+ def _run(cmd: list[str]) -> subprocess.CompletedProcess:
29
+ return subprocess.run(cmd, capture_output=True, text=True, check=False)
30
+
31
+
32
+ def detect_backend() -> Backend:
33
+ if platform.system() == "Darwin" and shutil.which("launchctl"):
34
+ return "launchd"
35
+ if platform.system() == "Linux" and shutil.which("systemctl"):
36
+ r = _run(["systemctl", "--user", "show-environment"])
37
+ if r.returncode == 0:
38
+ return "systemd"
39
+ return "process"
40
+
41
+
42
+ @dataclass
43
+ class ServiceStatus:
44
+ backend: Backend
45
+ installed: bool
46
+ running: bool
47
+ pid: int | None = None
48
+ detail: str = ""
49
+
50
+
51
+ class ServiceManager:
52
+ def __init__(self, home: Path, *, listen: str = "127.0.0.1:7391", backend: Backend | None = None,
53
+ python: str | None = None, runner: Callable[[list[str]], subprocess.CompletedProcess] = _run):
54
+ self.home = Path(home).expanduser()
55
+ self.listen = listen
56
+ self.backend: Backend = backend or detect_backend()
57
+ self.python = python or sys.executable
58
+ self._run = runner
59
+ # one service per home, so a test or second install never collides with the default one
60
+ suffix = "" if self.home.resolve() == DEFAULT_HOME.resolve() else "." + hashlib.sha256(
61
+ str(self.home.resolve()).encode()).hexdigest()[:8]
62
+ self.label = f"dev.interlock.daemon{suffix}"
63
+ self.unit = f"interlockd{suffix}.service"
64
+
65
+ # ---- paths
66
+ @property
67
+ def log_path(self) -> Path:
68
+ return self.home / "interlockd.log"
69
+
70
+ @property
71
+ def pid_path(self) -> Path:
72
+ return self.home / "interlockd.pid"
73
+
74
+ @property
75
+ def unit_path(self) -> Path:
76
+ if self.backend == "launchd":
77
+ return Path.home() / "Library" / "LaunchAgents" / f"{self.label}.plist"
78
+ if self.backend == "systemd":
79
+ return Path(os.environ.get("XDG_CONFIG_HOME", Path.home() / ".config")) / "systemd" / "user" / self.unit
80
+ return self.pid_path
81
+
82
+ def command(self) -> list[str]:
83
+ return [self.python, "-m", "interlockd", "--home", str(self.home), "serve", "--listen", self.listen]
84
+
85
+ # ---- unit files
86
+ def _plist(self) -> str:
87
+ args = "\n".join(f" <string>{a}</string>" for a in self.command())
88
+ return f"""<?xml version="1.0" encoding="UTF-8"?>
89
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
90
+ <plist version="1.0">
91
+ <dict>
92
+ <key>Label</key><string>{self.label}</string>
93
+ <key>ProgramArguments</key>
94
+ <array>
95
+ {args}
96
+ </array>
97
+ <key>EnvironmentVariables</key>
98
+ <dict><key>INTERLOCK_HOME</key><string>{self.home}</string></dict>
99
+ <key>RunAtLoad</key><true/>
100
+ <key>KeepAlive</key><dict><key>SuccessfulExit</key><false/></dict>
101
+ <key>ThrottleInterval</key><integer>10</integer>
102
+ <key>StandardOutPath</key><string>{self.log_path}</string>
103
+ <key>StandardErrorPath</key><string>{self.log_path}</string>
104
+ </dict>
105
+ </plist>
106
+ """
107
+
108
+ def _systemd_unit(self) -> str:
109
+ cmd = " ".join(f'"{a}"' if " " in a else a for a in self.command())
110
+ return f"""[Unit]
111
+ Description=Interlock pre/post-action gate for AI agents
112
+ After=default.target
113
+
114
+ [Service]
115
+ Environment=INTERLOCK_HOME={self.home}
116
+ ExecStart={cmd}
117
+ Restart=on-failure
118
+ RestartSec=5
119
+ StandardOutput=append:{self.log_path}
120
+ StandardError=append:{self.log_path}
121
+
122
+ [Install]
123
+ WantedBy=default.target
124
+ """
125
+
126
+ # ---- lifecycle
127
+ def install(self) -> Path:
128
+ """Write the unit (launchd/systemd). The process backend has nothing to install."""
129
+ self.home.mkdir(parents=True, exist_ok=True)
130
+ if self.backend == "launchd":
131
+ self.unit_path.parent.mkdir(parents=True, exist_ok=True)
132
+ self.unit_path.write_text(self._plist())
133
+ elif self.backend == "systemd":
134
+ self.unit_path.parent.mkdir(parents=True, exist_ok=True)
135
+ self.unit_path.write_text(self._systemd_unit())
136
+ self._run(["systemctl", "--user", "daemon-reload"])
137
+ self._run(["systemctl", "--user", "enable", self.unit])
138
+ return self.unit_path
139
+
140
+ def start(self) -> None:
141
+ if self.backend == "launchd":
142
+ if not self.unit_path.exists():
143
+ self.install()
144
+ domain = f"gui/{os.getuid()}"
145
+ # bootout is asynchronous, so a bootstrap right after a stop can fail for a moment: retry briefly
146
+ for _ in range(15):
147
+ if self._run(["launchctl", "bootstrap", domain, str(self.unit_path)]).returncode == 0:
148
+ break
149
+ if self._run(["launchctl", "kickstart", f"{domain}/{self.label}"]).returncode == 0:
150
+ break # already loaded
151
+ time.sleep(0.2)
152
+ elif self.backend == "systemd":
153
+ if not self.unit_path.exists():
154
+ self.install()
155
+ self._run(["systemctl", "--user", "start", self.unit])
156
+ else:
157
+ if self._pid_alive():
158
+ return
159
+ self.home.mkdir(parents=True, exist_ok=True)
160
+ log = open(self.log_path, "ab") # noqa: SIM115 handed to the child
161
+ env = {**os.environ, "INTERLOCK_HOME": str(self.home)}
162
+ p = subprocess.Popen(self.command(), stdout=log, stderr=log, stdin=subprocess.DEVNULL, env=env,
163
+ start_new_session=True)
164
+ self.pid_path.write_text(str(p.pid))
165
+
166
+ def stop(self) -> None:
167
+ if self.backend == "launchd":
168
+ self._run(["launchctl", "bootout", f"gui/{os.getuid()}/{self.label}"])
169
+ # bootout returns before the job is torn down; a bootstrap before then fails with EIO
170
+ for _ in range(50):
171
+ if not self._launchd_loaded():
172
+ break
173
+ time.sleep(0.2)
174
+ elif self.backend == "systemd":
175
+ self._run(["systemctl", "--user", "stop", self.unit])
176
+ pid = self._read_pid()
177
+ if pid and self._alive(pid):
178
+ os.kill(pid, signal.SIGTERM)
179
+ for _ in range(30):
180
+ if not self._alive(pid):
181
+ break
182
+ time.sleep(0.1)
183
+ if self.pid_path.exists():
184
+ self.pid_path.unlink()
185
+
186
+ def restart(self) -> None:
187
+ if self.backend == "launchd": # kill and relaunch in place; no unload/load race
188
+ r = self._run(["launchctl", "kickstart", "-k", f"gui/{os.getuid()}/{self.label}"])
189
+ if r.returncode == 0:
190
+ return
191
+ if self.backend == "systemd":
192
+ self._run(["systemctl", "--user", "restart", self.unit])
193
+ return
194
+ self.stop()
195
+ self.start()
196
+
197
+ def uninstall(self) -> None:
198
+ self.stop()
199
+ if self.backend == "systemd":
200
+ self._run(["systemctl", "--user", "disable", self.unit])
201
+ if self.backend in ("launchd", "systemd") and self.unit_path.exists():
202
+ self.unit_path.unlink()
203
+ if self.backend == "systemd":
204
+ self._run(["systemctl", "--user", "daemon-reload"])
205
+
206
+ def status(self) -> ServiceStatus:
207
+ installed = self.unit_path.exists() if self.backend != "process" else True
208
+ return ServiceStatus(self.backend, installed, self.healthy(), self._read_pid(), detail=str(self.unit_path))
209
+
210
+ # ---- health
211
+ def healthy(self, timeout: float = 1.0) -> bool:
212
+ host, _, port = self.listen.rpartition(":")
213
+ try:
214
+ with urllib.request.urlopen(f"http://{host or '127.0.0.1'}:{port}/interlock/v1/health", timeout=timeout) as r:
215
+ return r.status == 200
216
+ except Exception: # noqa: BLE001
217
+ return False
218
+
219
+ def wait_healthy(self, seconds: float = 8.0) -> bool:
220
+ deadline = time.monotonic() + seconds
221
+ while time.monotonic() < deadline:
222
+ if self.healthy(timeout=0.5):
223
+ return True
224
+ time.sleep(0.2)
225
+ return False
226
+
227
+ def _launchd_loaded(self) -> bool:
228
+ return self._run(["launchctl", "print", f"gui/{os.getuid()}/{self.label}"]).returncode == 0
229
+
230
+ # ---- pid helpers
231
+ def _read_pid(self) -> int | None:
232
+ try:
233
+ return int(self.pid_path.read_text().strip())
234
+ except (OSError, ValueError):
235
+ return None
236
+
237
+ @staticmethod
238
+ def _alive(pid: int) -> bool:
239
+ try:
240
+ os.kill(pid, 0)
241
+ return True
242
+ except OSError:
243
+ return False
244
+
245
+ def _pid_alive(self) -> bool:
246
+ pid = self._read_pid()
247
+ return bool(pid and self._alive(pid))
@@ -0,0 +1,113 @@
1
+ #!/bin/sh
2
+ # Interlock hook shim for JSON-on-stdin / JSON-on-stdout harnesses: Claude Code and Codex (PreToolUse/PostToolUse),
3
+ # Cursor (preToolUse/postToolUse), Gemini CLI (BeforeTool/AfterTool). POSIX sh + curl.
4
+ #
5
+ # The daemon normalizes the payload and renders the exact reply each harness applies (deny with a reason, a warning,
6
+ # or a replaced result), so this shim only forwards and prints. An empty daemon reply means "no opinion", which
7
+ # leaves the harness's own permission system in charge; Interlock never auto-approves. Cursor treats exit 0 with
8
+ # output that isn't valid permission JSON as a block, so for Cursor "no opinion" is a non-zero exit (fail-open).
9
+ #
10
+ # Also GitHub Copilot CLI, Windsurf (Cascade), and Antigravity CLI. Installers pass "pre" or "post" as $1 where the
11
+ # payload does not name its event (Copilot's camelCase form). Windsurf blocks with exit 2 and a message on stderr.
12
+ #
13
+ # Install writes HARNESS into the copied shim: claude-code | codex | cursor | gemini-cli | copilot-cli | windsurf | antigravity.
14
+
15
+ HARNESS="__HARNESS__"
16
+ HOME_DIR="${INTERLOCK_HOME:-$HOME/.interlock}"
17
+ PRE_TIMEOUT="${INTERLOCK_PRE_TIMEOUT_MS:-1500}"
18
+ POST_TIMEOUT="${INTERLOCK_POST_TIMEOUT_MS:-2500}"
19
+ VERSION="0.1.0"
20
+
21
+ log_local() {
22
+ mkdir -p "$HOME_DIR" 2>/dev/null
23
+ printf '%s %s\n' "$(date -u +%Y-%m-%dT%H:%M:%SZ)" "$1" >> "$HOME_DIR/hook.log" 2>/dev/null
24
+ }
25
+
26
+ payload=$(cat)
27
+ [ -n "$payload" ] || exit 0
28
+ is_post=0
29
+ EVENT="$1"
30
+ case "$HARNESS" in
31
+ cursor) case "$payload" in *'postToolUse'*|*'afterShellExecution'*|*'afterMCPExecution'*) is_post=1 ;; esac ;;
32
+ gemini-cli) case "$payload" in *'AfterTool'*) is_post=1 ;; esac ;;
33
+ windsurf) case "$payload" in *'"agent_action_name":"post_'*|*'"agent_action_name": "post_'*) is_post=1 ;; esac ;;
34
+ *) case "$payload" in *PostToolUse*|*postToolUse*) is_post=1 ;; esac ;;
35
+ esac
36
+ case "$EVENT" in post) is_post=1 ;; pre) is_post=0 ;; esac
37
+ EVENT=pre; [ "$is_post" = 1 ] && EVENT=post
38
+ timeout_ms=$PRE_TIMEOUT; [ "$is_post" = 1 ] && timeout_ms=$POST_TIMEOUT
39
+
40
+ no_opinion() { # leave the decision to the harness
41
+ if [ "$HARNESS" = cursor ] && [ "$is_post" = 0 ]; then exit 1; fi
42
+ if [ "$HARNESS" = antigravity ]; then # a pre-tool decision is required; post-tool hooks return {}
43
+ if [ "$is_post" = 0 ]; then printf '{"decision":"allow"}\n'; else printf '{}\n'; fi
44
+ fi
45
+ exit 0
46
+ }
47
+
48
+ token=""; [ -r "$HOME_DIR/token" ] && read -r token < "$HOME_DIR/token"
49
+ if [ -z "$token" ]; then log_local "inert: no token at $HOME_DIR/token"; no_opinion; fi
50
+ if [ -n "$INTERLOCK_URL" ]; then base="${INTERLOCK_URL%/}"
51
+ else
52
+ listen=""
53
+ if [ -r "$HOME_DIR/daemon.json" ]; then
54
+ read -r line < "$HOME_DIR/daemon.json"
55
+ case "$line" in *'"listen": "'*) listen=${line#*'"listen": "'}; listen=${listen%%\"*} ;; esac
56
+ fi
57
+ base="http://${listen:-127.0.0.1:7391}"
58
+ fi
59
+
60
+ FORMAT=claude-code; case "$HARNESS" in cursor|gemini-cli|copilot-cli|antigravity) FORMAT=$HARNESS ;; windsurf) FORMAT=text ;; esac
61
+ secs=$(( timeout_ms / 1000 )).$(( (timeout_ms % 1000) / 100 ))
62
+ # prefer the daemon's Unix socket (mode 0600: only this user can connect) unless a URL was given explicitly
63
+ sock=""; [ -z "$INTERLOCK_URL" ] && [ -S "$HOME_DIR/interlockd.sock" ] && sock="$HOME_DIR/interlockd.sock"
64
+ if [ -n "$sock" ]; then base="http://localhost"; set -- --unix-socket "$sock"; else set --; fi
65
+ reply=$(printf '{"adapter":{"name":"%s","version":"%s","event":"%s"},"payload":%s}' "$HARNESS" "$VERSION" "$EVENT" "$payload" | \
66
+ curl -sf -m "$secs" "$@" -H "authorization: Bearer $token" -H "content-type: application/json" \
67
+ --data-binary @- "$base/interlock/v1/hooks/$HARNESS?format=$FORMAT" 2>/dev/null)
68
+ rc=$?
69
+
70
+ if [ $rc -ne 0 ]; then
71
+ mode=open
72
+ case "$INTERLOCK_FAIL_MODE" in open|closed) mode=$INTERLOCK_FAIL_MODE ;; *)
73
+ [ -r "$HOME_DIR/fail-mode.json" ] && grep -q '"default": *"closed"' "$HOME_DIR/fail-mode.json" && mode=closed ;;
74
+ esac
75
+ case "$payload" in
76
+ *'"tool_name":"'*) tool=${payload#*'"tool_name":"'} ;;
77
+ *'"tool_name": "'*) tool=${payload#*'"tool_name": "'} ;;
78
+ *) tool="?" ;;
79
+ esac
80
+ tool=${tool%%\"*}
81
+ kind=pre; [ "$is_post" = 1 ] && kind=post
82
+ log_local "unjudged harness=$HARNESS tool=$tool kind=$kind curl_rc=$rc fail_mode=$mode"
83
+ if [ "$is_post" = 0 ] && [ "$mode" = "closed" ]; then
84
+ msg='[interlock] This action was NOT executed: the Interlock daemon is unreachable and this workspace fails closed. Start it with `interlock start`, then retry.'
85
+ case "$HARNESS" in
86
+ cursor) printf '{"permission":"deny","agent_message":"%s","user_message":"Interlock daemon unreachable (fail closed)"}\n' "$msg" ;;
87
+ gemini-cli|antigravity) printf '{"decision":"deny","reason":"%s"}\n' "$msg" ;;
88
+ copilot-cli) printf '{"permissionDecision":"deny","permissionDecisionReason":"%s"}\n' "$msg" ;;
89
+ windsurf) printf '%s\n' "$msg" >&2; exit 2 ;;
90
+ *) printf '{"hookSpecificOutput":{"hookEventName":"PreToolUse","permissionDecision":"deny","permissionDecisionReason":"%s"}}\n' "$msg" ;;
91
+ esac
92
+ exit 0
93
+ fi
94
+ no_opinion
95
+ fi
96
+
97
+ # Windsurf: the daemon replies in text form ("<disposition> <mode> <tier>" then the notice); block via exit 2
98
+ if [ "$HARNESS" = windsurf ]; then
99
+ first=${reply%%
100
+ *}
101
+ set -- $first
102
+ if [ "$2" = enforce ] && { [ "$1" = block ] || [ "$1" = ask ]; }; then
103
+ printf '%s\n' "${reply#*
104
+ }" >&2
105
+ exit 2
106
+ fi
107
+ exit 0
108
+ fi
109
+
110
+ # the daemon's reply is the hook's stdout; empty means no opinion
111
+ [ -n "$reply" ] || no_opinion
112
+ printf '%s\n' "$reply"
113
+ exit 0
@@ -0,0 +1,101 @@
1
+ #!/usr/bin/env node
2
+ // Interlock hook shim for Kiro (PreToolUse and PostToolUse). Zero dependencies, Node >= 18.
3
+ //
4
+ // Reads the hook event from stdin, posts it to the local daemon, and renders the verdict in Kiro's
5
+ // vocabulary (adapters/kiro/PAYLOADS.md §5): exit 0 = allow, exit 2 + stderr = block. Kiro has no
6
+ // hook-driven "ask", so an ask renders as a block whose notice asks the agent to confirm with the user.
7
+ // PostToolUse is record-only on Kiro: always exit 0. If the daemon does not answer, the configured
8
+ // fail mode decides (default open) and the miss is logged locally.
9
+
10
+ import { readFileSync, appendFileSync, mkdirSync } from "node:fs";
11
+ import { homedir } from "node:os";
12
+ import path from "node:path";
13
+
14
+ const VERSION = "0.1.0";
15
+ const HOME = process.env.INTERLOCK_HOME || path.join(homedir(), ".interlock");
16
+ const BASE = (process.env.INTERLOCK_URL || readListen() || "http://127.0.0.1:7391").replace(/\/$/, "");
17
+ const PRE_TIMEOUT_MS = Number(process.env.INTERLOCK_PRE_TIMEOUT_MS || 1500);
18
+ const POST_TIMEOUT_MS = Number(process.env.INTERLOCK_POST_TIMEOUT_MS || 2500);
19
+
20
+ function readListen() {
21
+ try {
22
+ const d = JSON.parse(readFileSync(path.join(HOME, "daemon.json"), "utf8"));
23
+ return d.listen ? `http://${d.listen}` : null;
24
+ } catch { return null; }
25
+ }
26
+
27
+ function logLocal(line) {
28
+ try {
29
+ mkdirSync(HOME, { recursive: true });
30
+ appendFileSync(path.join(HOME, "hook.log"), `${new Date().toISOString()} ${line}\n`);
31
+ } catch { /* never let logging break the hook */ }
32
+ }
33
+
34
+ function failMode() {
35
+ // M0: no finding categories are known at the shim, so the table's default applies.
36
+ const env = process.env.INTERLOCK_FAIL_MODE;
37
+ if (env === "open" || env === "closed") return env;
38
+ try {
39
+ const t = JSON.parse(readFileSync(path.join(HOME, "fail-mode.json"), "utf8"));
40
+ return t.default === "closed" ? "closed" : "open";
41
+ } catch { return "open"; }
42
+ }
43
+
44
+ async function readStdin() {
45
+ const chunks = [];
46
+ for await (const c of process.stdin) chunks.push(c);
47
+ return Buffer.concat(chunks).toString("utf8");
48
+ }
49
+
50
+ function block(notice) {
51
+ process.stderr.write(notice.endsWith("\n") ? notice : notice + "\n");
52
+ process.exit(2);
53
+ }
54
+
55
+ async function main() {
56
+ const raw = await readStdin();
57
+ let payload;
58
+ try { payload = JSON.parse(raw); } catch { logLocal("skip: stdin was not JSON"); process.exit(0); }
59
+ const isPost = String(payload.hook_event_name || "").toLowerCase() === "posttooluse";
60
+ const timeout = isPost ? POST_TIMEOUT_MS : PRE_TIMEOUT_MS;
61
+
62
+ let token;
63
+ try { token = readFileSync(path.join(HOME, "token"), "utf8").trim(); }
64
+ catch { logLocal("inert: no token at " + path.join(HOME, "token")); process.exit(0); }
65
+
66
+ const ctl = new AbortController();
67
+ const timer = setTimeout(() => ctl.abort(), timeout);
68
+ let verdict;
69
+ try {
70
+ const res = await fetch(`${BASE}/interlock/v1/hooks/kiro`, {
71
+ method: "POST",
72
+ headers: { "content-type": "application/json", authorization: `Bearer ${token}` },
73
+ body: JSON.stringify({ adapter: { name: "kiro", version: VERSION }, payload }),
74
+ signal: ctl.signal,
75
+ });
76
+ if (!res.ok) throw new Error(`HTTP ${res.status}`);
77
+ verdict = await res.json();
78
+ } catch (e) {
79
+ clearTimeout(timer);
80
+ const mode = failMode();
81
+ logLocal(`unjudged tool=${payload.tool_name} kind=${isPost ? "post" : "pre"} reason=${e.name === "AbortError" ? "timeout" : e.message} fail_mode=${mode}`);
82
+ if (!isPost && mode === "closed") {
83
+ block("[interlock] This action was NOT executed: the Interlock daemon is unreachable and this workspace fails closed. Start it with `interlock serve`, then retry.");
84
+ }
85
+ process.exit(0);
86
+ }
87
+ clearTimeout(timer);
88
+
89
+ if (isPost) process.exit(0); // record-only on Kiro
90
+ if (verdict.mode === "shadow") process.exit(0); // shadow: decided and recorded, never enforced
91
+
92
+ if (verdict.disposition === "block") {
93
+ block(verdict.notice || `[interlock] This action was refused by a security policy and was NOT executed. Reason: ${verdict.reason || "policy"}. You may pursue the user's goal by other legitimate means, but you MUST NOT retry this action in a disguised form.`);
94
+ }
95
+ if (verdict.disposition === "ask") {
96
+ block(verdict.notice || `[interlock] This action was NOT executed because it needs the user's confirmation. Reason: ${verdict.reason || "policy"}. Ask the user whether to proceed, and only retry after they confirm.`);
97
+ }
98
+ process.exit(0);
99
+ }
100
+
101
+ main().catch((e) => { logLocal(`shim error: ${e && e.message}`); process.exit(0); });
@@ -0,0 +1,82 @@
1
+ #!/bin/sh
2
+ # Interlock hook shim for Kiro (PreToolUse and PostToolUse). POSIX sh + curl, nothing else.
3
+ #
4
+ # Hot path is two forks (cat for stdin, curl for the request); everything else is shell builtins, because
5
+ # each fork costs 5-10 ms on macOS. Measured on an M4 Pro: ~30 ms end to end for a tier-0 call, against
6
+ # ~108 ms for the Node shim (whose startup alone is ~79 ms). The daemon answers shims in text:
7
+ # line 1: "<disposition> <mode> <tier>" following lines: the notice or reason
8
+ # Kiro vocabulary (adapters/kiro/PAYLOADS.md §5): exit 0 = allow, exit 2 + stderr = block. Kiro has no
9
+ # hook-driven "ask", so ask renders as a block whose notice asks the agent to confirm with the user.
10
+ # PostToolUse is record-only on Kiro: always exit 0. No answer from the daemon -> fail mode (default open).
11
+
12
+ HOME_DIR="${INTERLOCK_HOME:-$HOME/.interlock}"
13
+ PRE_TIMEOUT="${INTERLOCK_PRE_TIMEOUT_MS:-1500}"
14
+ POST_TIMEOUT="${INTERLOCK_POST_TIMEOUT_MS:-2500}"
15
+ VERSION="0.1.0"
16
+ nl='
17
+ '
18
+
19
+ log_local() { # failure path only; forks are fine here
20
+ mkdir -p "$HOME_DIR" 2>/dev/null
21
+ printf '%s %s\n' "$(date -u +%Y-%m-%dT%H:%M:%SZ)" "$1" >> "$HOME_DIR/hook.log" 2>/dev/null
22
+ }
23
+
24
+ payload=$(cat)
25
+ [ -n "$payload" ] || exit 0
26
+ case "$payload" in
27
+ *PostToolUse*|*postToolUse*) is_post=1; timeout_ms=$POST_TIMEOUT ;;
28
+ *) is_post=0; timeout_ms=$PRE_TIMEOUT ;;
29
+ esac
30
+
31
+ # token and daemon address with builtins only
32
+ token=""; [ -r "$HOME_DIR/token" ] && read -r token < "$HOME_DIR/token"
33
+ if [ -z "$token" ]; then log_local "inert: no token at $HOME_DIR/token"; exit 0; fi
34
+ if [ -n "$INTERLOCK_URL" ]; then base="${INTERLOCK_URL%/}"
35
+ else
36
+ listen=""
37
+ if [ -r "$HOME_DIR/daemon.json" ]; then
38
+ read -r line < "$HOME_DIR/daemon.json"
39
+ case "$line" in *'"listen": "'*) listen=${line#*'"listen": "'}; listen=${listen%%\"*} ;; esac
40
+ fi
41
+ base="http://${listen:-127.0.0.1:7391}"
42
+ fi
43
+
44
+ secs=$(( timeout_ms / 1000 )).$(( (timeout_ms % 1000) / 100 ))
45
+ verdict=$(printf '{"adapter":{"name":"kiro","version":"%s"},"payload":%s}' "$VERSION" "$payload" | \
46
+ curl -sf -m "$secs" -H "authorization: Bearer $token" -H "content-type: application/json" \
47
+ --data-binary @- "$base/interlock/v1/hooks/kiro?format=text" 2>/dev/null)
48
+ rc=$?
49
+
50
+ if [ $rc -ne 0 ] || [ -z "$verdict" ]; then
51
+ mode=open
52
+ case "$INTERLOCK_FAIL_MODE" in open|closed) mode=$INTERLOCK_FAIL_MODE ;; *)
53
+ [ -r "$HOME_DIR/fail-mode.json" ] && grep -q '"default": *"closed"' "$HOME_DIR/fail-mode.json" && mode=closed ;;
54
+ esac
55
+ case "$payload" in
56
+ *'"tool_name":"'*) tool=${payload#*'"tool_name":"'} ;;
57
+ *'"tool_name": "'*) tool=${payload#*'"tool_name": "'} ;;
58
+ *) tool="?" ;;
59
+ esac
60
+ tool=${tool%%\"*}
61
+ kind=pre; [ "$is_post" = 1 ] && kind=post
62
+ log_local "unjudged tool=$tool kind=$kind curl_rc=$rc fail_mode=$mode"
63
+ if [ "$is_post" = 0 ] && [ "$mode" = "closed" ]; then
64
+ printf '[interlock] This action was NOT executed: the Interlock daemon is unreachable and this workspace fails closed. Start it with `interlock serve`, then retry.\n' >&2
65
+ exit 2
66
+ fi
67
+ exit 0
68
+ fi
69
+
70
+ [ "$is_post" = 1 ] && exit 0 # record-only on Kiro
71
+ first=${verdict%%"$nl"*}
72
+ notice=${verdict#*"$nl"}; [ "$notice" = "$verdict" ] && notice=""
73
+ set -- $first # disposition mode tier
74
+ disposition=$1; mode=$2
75
+ [ "$mode" = "shadow" ] && exit 0 # decided and recorded, never enforced
76
+ case "$disposition" in
77
+ block|ask)
78
+ [ -n "$notice" ] || notice="[interlock] This action was refused by a security policy and was NOT executed. You may pursue the user's goal by other legitimate means, but you MUST NOT retry this action in a disguised form."
79
+ printf '%s\n' "$notice" >&2
80
+ exit 2 ;;
81
+ esac
82
+ exit 0
@@ -0,0 +1,60 @@
1
+ // Interlock plugin for OpenCode (https://opencode.ai/docs/plugins/). Installed by `interlock init` into
2
+ // ~/.config/opencode/plugins/. Asks the local Interlock daemon about every tool call before it runs and
3
+ // throws to refuse it (OpenCode's documented way to block); checks tool results afterwards (record-only:
4
+ // replacing a result is not documented). No dependencies; runs on OpenCode's own runtime.
5
+ import { readFileSync } from "node:fs";
6
+ import { homedir } from "node:os";
7
+ import { join } from "node:path";
8
+
9
+ const VERSION = "__VERSION__";
10
+ const HOME = process.env.INTERLOCK_HOME || join(homedir(), ".interlock");
11
+
12
+ function read(p) { try { return readFileSync(join(HOME, p), "utf8"); } catch { return ""; } }
13
+
14
+ function base() {
15
+ if (process.env.INTERLOCK_URL) return process.env.INTERLOCK_URL.replace(/\/$/, "");
16
+ try { return `http://${JSON.parse(read("daemon.json")).listen || "127.0.0.1:7391"}`; } catch { return "http://127.0.0.1:7391"; }
17
+ }
18
+
19
+ function failsClosed() {
20
+ const env = process.env.INTERLOCK_FAIL_MODE;
21
+ if (env === "open" || env === "closed") return env === "closed";
22
+ try { return JSON.parse(read("fail-mode.json")).default === "closed"; } catch { return false; }
23
+ }
24
+
25
+ async function decide(kind, tool, args, cwd, session, content) {
26
+ const token = read("token").trim();
27
+ if (!token) return null;
28
+ const event = {
29
+ kind, adapter: { name: "opencode", version: VERSION }, session_hint: session || null,
30
+ identity: { agent_type: "opencode" }, tool_call: { name: String(tool || ""), arguments: args || {} },
31
+ context: { cwd: cwd || process.cwd() }, capabilities: { ask: false, rewrite: false, withhold: false },
32
+ };
33
+ if (content !== undefined) event.result = { content: String(content).slice(0, 200000) };
34
+ const ctl = new AbortController();
35
+ const timer = setTimeout(() => ctl.abort(), kind === "pre_action" ? 1500 : 2500);
36
+ try {
37
+ const r = await fetch(`${base()}/interlock/v1/decide`, {
38
+ method: "POST", signal: ctl.signal, body: JSON.stringify(event),
39
+ headers: { "content-type": "application/json", authorization: `Bearer ${token}` },
40
+ });
41
+ return r.ok ? await r.json() : null;
42
+ } catch { return null; } finally { clearTimeout(timer); }
43
+ }
44
+
45
+ export const Interlock = async ({ directory }) => ({
46
+ "tool.execute.before": async (input, output) => {
47
+ const v = await decide("pre_action", input.tool, output.args, directory, input.sessionID);
48
+ if (v === null) {
49
+ if (failsClosed()) throw new Error("[interlock] This action was NOT executed: the Interlock daemon is unreachable and this machine fails closed.");
50
+ return;
51
+ }
52
+ if (v.mode === "enforce" && (v.disposition === "block" || v.disposition === "ask")) {
53
+ throw new Error(v.notice || `[interlock] refused: ${v.reason || "policy"}`);
54
+ }
55
+ },
56
+ "tool.execute.after": async (input, output) => {
57
+ const text = typeof output?.output === "string" ? output.output : JSON.stringify(output?.output ?? output?.result ?? "");
58
+ await decide("post_action", input.tool, output?.args || {}, directory, input.sessionID, text);
59
+ },
60
+ });
@@ -0,0 +1,22 @@
1
+ """Audit store: a local write-ahead spool in front of a repository (memory for tests, DynamoDB for real).
2
+
3
+ Design §7: one record per event, findings and answers embedded; separate items for explanations,
4
+ feedback, and session summaries. The decision path writes only to the spool (fsync) and returns.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from typing import Any, Protocol
10
+
11
+
12
+ class Repository(Protocol):
13
+ def put_event(self, record: dict[str, Any]) -> None: ...
14
+ def get_event(self, event_id: str) -> dict[str, Any] | None: ...
15
+ def tail(self, workspace: str | None = None, limit: int = 50) -> list[dict[str, Any]]: ...
16
+ def put_session(self, summary: dict[str, Any]) -> None: ...
17
+ def get_session(self, session_id: str) -> dict[str, Any] | None: ...
18
+
19
+
20
+ def hour_bucket(received_at: str) -> str:
21
+ # "2026-10-08T01:08:33.123Z" -> "2026-10-08-01"
22
+ return received_at[:13].replace("T", "-")