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/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", "-")
|