squidbrake 0.3.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.
squidbrake/__init__.py ADDED
@@ -0,0 +1,2 @@
1
+ """Squidbrake: brakes for your AI agents. See https://github.com/batrapulkit/squidbrake"""
2
+ __version__ = "0.3.0"
@@ -0,0 +1,310 @@
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
6
+ <meta name="robots" content="noindex">
7
+ <title>Approvals</title>
8
+ <style>
9
+ :root {
10
+ color-scheme: light;
11
+ --page: #f4f3f0; --card: #fcfcfb; --soft: #f0efec; --border: #e2e1dc;
12
+ --text: #0b0b0b; --text-2: #52514e; --muted: #7a7974;
13
+ --good: #0ca30c; --good-ink: #ffffff; --bad: #d03b3b; --warn: #fab219; --warn-ink: #3d2c00; --accent: #2a78d6;
14
+ --mono: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
15
+ }
16
+ @media (prefers-color-scheme: dark) {
17
+ :root:where(:not([data-theme="light"])) {
18
+ color-scheme: dark;
19
+ --page: #121211; --card: #1a1a19; --soft: #242422; --border: #2f2f2c;
20
+ --text: #ffffff; --text-2: #c3c2b7; --muted: #8f8e86; --accent: #3987e5;
21
+ }
22
+ }
23
+ :root[data-theme="dark"] {
24
+ color-scheme: dark;
25
+ --page: #121211; --card: #1a1a19; --soft: #242422; --border: #2f2f2c;
26
+ --text: #ffffff; --text-2: #c3c2b7; --muted: #8f8e86; --accent: #3987e5;
27
+ }
28
+ * { box-sizing: border-box; }
29
+ [hidden] { display: none !important; }
30
+ html, body { margin: 0; }
31
+ body { background: var(--page); color: var(--text); font: 16px/1.45 system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
32
+ -webkit-font-smoothing: antialiased; }
33
+ main { max-width: 560px; margin: 0 auto; padding: max(16px, env(safe-area-inset-top)) 16px 40px; }
34
+ header { display: flex; align-items: center; gap: 10px; margin: 4px 0 16px; }
35
+ header h1 { font-size: 17px; margin: 0; font-weight: 650; }
36
+ header .sp { flex: 1; }
37
+ .pill { font-size: 12px; color: var(--text-2); background: var(--soft); border: 1px solid var(--border); border-radius: 999px; padding: 2px 10px; }
38
+ .card { background: var(--card); border: 1px solid var(--border); border-radius: 16px; padding: 18px; margin-bottom: 14px; }
39
+ .card.held { border-left: 5px solid var(--warn); }
40
+ .kicker { font-size: 12.5px; color: var(--text-2); text-transform: uppercase; letter-spacing: .05em; margin-bottom: 6px; }
41
+ h2 { font-size: 22px; line-height: 1.25; margin: 0 0 6px; word-break: break-word; }
42
+ h2 code { font-family: var(--mono); font-size: 19px; }
43
+ .why { color: var(--text-2); margin-bottom: 12px; }
44
+ .meta { display: grid; grid-template-columns: auto 1fr; gap: 4px 12px; font-size: 14px; margin-bottom: 12px; }
45
+ .meta dt { color: var(--muted); }
46
+ .meta dd { margin: 0; word-break: break-word; }
47
+ pre { margin: 0 0 14px; background: var(--soft); border: 1px solid var(--border); border-radius: 10px; padding: 10px 12px;
48
+ font: 13px/1.5 var(--mono); white-space: pre-wrap; word-break: break-word; max-height: 260px; overflow: auto; }
49
+ .clock { font-size: 14px; color: var(--text-2); margin-bottom: 12px; font-variant-numeric: tabular-nums; }
50
+ .clock.soon { color: var(--bad); font-weight: 600; }
51
+ textarea { width: 100%; font: inherit; color: inherit; background: var(--page); border: 1px solid var(--border); border-radius: 10px;
52
+ padding: 10px 12px; min-height: 44px; resize: vertical; margin-bottom: 12px; }
53
+ .acts { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; }
54
+ button { font: inherit; cursor: pointer; border-radius: 12px; border: 1px solid var(--border); background: var(--card); color: var(--text);
55
+ padding: 14px 12px; font-weight: 650; min-height: 52px; }
56
+ button:disabled { opacity: .5; cursor: default; }
57
+ button:focus-visible, textarea:focus-visible, input:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
58
+ .approve { background: var(--good); border-color: var(--good); color: var(--good-ink); font-size: 17px; }
59
+ .reject { color: var(--bad); border-color: var(--bad); font-size: 17px; }
60
+ .stop { width: 100%; background: var(--bad); border-color: var(--bad); color: #fff; }
61
+ .result { display: flex; gap: 12px; align-items: flex-start; }
62
+ .result .ic { width: 40px; height: 40px; border-radius: 50%; display: grid; place-items: center; font-size: 22px; color: #fff; flex: none; }
63
+ .result.ok .ic { background: var(--good); }
64
+ .result.no .ic { background: var(--bad); }
65
+ .result.info .ic { background: var(--muted); }
66
+ .result b { display: block; font-size: 18px; margin-bottom: 2px; }
67
+ .muted { color: var(--muted); font-size: 14px; }
68
+ .empty { text-align: center; color: var(--muted); padding: 40px 0; }
69
+ .banner { border-radius: 12px; padding: 12px 14px; margin-bottom: 14px; background: var(--bad); color: #fff; font-size: 15px; }
70
+ input[type=password] { width: 100%; font: inherit; color: inherit; background: var(--page); border: 1px solid var(--border);
71
+ border-radius: 10px; padding: 12px; margin: 8px 0 12px; }
72
+ a { color: var(--accent); }
73
+ .signal { border-radius: 10px; padding: 10px 12px; font-size: 14px; margin-bottom: 10px; background: var(--soft);
74
+ border: 1px solid var(--border); border-left: 5px solid var(--warn); }
75
+ .signal.block { border-left-color: var(--bad); }
76
+ .story-d { margin: 0 0 12px; }
77
+ .story-d summary { cursor: pointer; color: var(--accent); font-size: 15px; padding: 4px 0; }
78
+ .story { list-style: none; margin: 8px 0 0; padding: 0 0 0 14px; border-left: 2px solid var(--border); }
79
+ .story li { margin-bottom: 10px; }
80
+ .story code { font-family: var(--mono); font-size: 13px; }
81
+ .story .step { font-size: 14px; background: var(--soft); border-radius: 8px; padding: 6px 9px; margin-top: 3px; word-break: break-word; }
82
+ </style>
83
+ </head>
84
+ <body>
85
+ <main>
86
+ <header>
87
+ <h1>Squidbrake</h1>
88
+ <span class="sp"></span>
89
+ <span class="pill" id="who" hidden></span>
90
+ </header>
91
+ <div id="stopBanner" class="banner" hidden></div>
92
+ <div id="root"></div>
93
+ <p class="muted" id="foot"></p>
94
+ </main>
95
+
96
+ <script>
97
+ (() => {
98
+ "use strict";
99
+ const $ = (s) => document.querySelector(s);
100
+ const root = $("#root");
101
+ const tokenMatch = location.pathname.match(/^\/a\/([^/]+)$/);
102
+ const token = tokenMatch ? tokenMatch[1] : null;
103
+
104
+ // Key for the /m inbox: taken once from the #key=... link the dashboard shows as a QR code, then kept
105
+ // in this browser only (the fragment is never sent to the server) and removed from the address bar.
106
+ let key = "";
107
+ try { key = localStorage.getItem("gw.key") || ""; } catch {}
108
+ const frag = new URLSearchParams(location.hash.slice(1));
109
+ if (frag.get("key")) {
110
+ key = frag.get("key");
111
+ try { localStorage.setItem("gw.key", key); } catch {}
112
+ history.replaceState(null, "", location.pathname + location.search);
113
+ }
114
+
115
+ function el(tag, attrs = {}, ...kids) {
116
+ const n = document.createElement(tag);
117
+ for (const [k, v] of Object.entries(attrs)) {
118
+ if (v == null || v === false) continue;
119
+ if (k === "class") n.className = v;
120
+ else if (k === "text") n.textContent = v;
121
+ else if (k.startsWith("on")) n.addEventListener(k.slice(2), v);
122
+ else n.setAttribute(k, v);
123
+ }
124
+ kids.flat().forEach((c) => c != null && n.append(c.nodeType ? c : document.createTextNode(String(c))));
125
+ return n;
126
+ }
127
+ async function call(method, path, body) {
128
+ const headers = { "Content-Type": "application/json" };
129
+ if (key && !token) headers["X-Gateway-Key"] = key;
130
+ const res = await fetch(path, { method, headers, body: body ? JSON.stringify(body) : undefined });
131
+ let data = null;
132
+ try { data = await res.json(); } catch {}
133
+ if (!res.ok) { const e = new Error((data && data.detail) || `${res.status}`); e.status = res.status; throw e; }
134
+ return data;
135
+ }
136
+ const pad = (n) => String(n).padStart(2, "0");
137
+ function when(iso) { return iso ? new Date(iso).toLocaleString([], { month: "short", day: "numeric", hour: "2-digit", minute: "2-digit" }) : "—"; }
138
+ function ago(iso) {
139
+ const s = Math.max(0, Math.round((Date.now() - new Date(iso)) / 1000));
140
+ return s < 60 ? `${s}s ago` : s < 3600 ? `${Math.floor(s / 60)}m ago` : s < 86400 ? `${Math.floor(s / 3600)}h ago` : `${Math.floor(s / 86400)}d ago`;
141
+ }
142
+ function pretty(v) { return v == null ? "(no input)" : typeof v === "string" ? v : JSON.stringify(v, null, 2); }
143
+ function tick() {
144
+ document.querySelectorAll("[data-deadline]").forEach((c) => {
145
+ const s = Math.round((new Date(c.dataset.deadline) - Date.now()) / 1000);
146
+ const then = c.dataset.then === "allow" ? "allowed" : "denied";
147
+ c.textContent = s > 0 ? `Decide within ${Math.floor(s / 60)}:${pad(s % 60)} — otherwise it's ${then} automatically`
148
+ : `Time's up — it will be ${then} automatically`;
149
+ c.classList.toggle("soon", s <= 60);
150
+ });
151
+ }
152
+ setInterval(tick, 1000);
153
+
154
+ function outcome(e) {
155
+ if (e.status === "awaiting_approval") return null;
156
+ if (!e.decided_by) return el("div", { class: "result info" }, el("div", { class: "ic", text: "i" }),
157
+ el("div", {}, el("b", { text: `Already ${e.status}` }), el("span", { class: "muted", text: e.reason || "" })));
158
+ const ok = e.decision === "allow";
159
+ const by = e.decided_by === "timeout" ? "Nobody decided in time" : `${ok ? "Approved" : "Rejected"} by ${e.decided_by}`;
160
+ return el("div", { class: "result " + (ok ? "ok" : "no") }, el("div", { class: "ic", text: ok ? "✓" : "✕" }),
161
+ el("div", {}, el("b", { text: e.decided_by === "timeout" ? `${by} — ${ok ? "allowed" : "denied"}` : by }),
162
+ el("span", { class: "muted", text: when(e.decided_at) + (e.decision_note ? ` · “${e.decision_note}”` : "") })));
163
+ }
164
+
165
+ // ---- the story: history warnings + what the agent did just before
166
+ const TITLES = { impersonation: "Possible scam", payment_request_in_message: "Payment requested in a message",
167
+ repeat_of_rejected: "Already rejected before", duplicate_change: "Done before",
168
+ catastrophic_command: "Destructive command", irreversible_command: "Can't be undone", hidden_command: "Hidden code", sequence: "Pattern", untrusted_destination: "Address from outside content", after_untrusted: "After reading outside content" };
169
+ function signalsEl(signals) {
170
+ if (!signals || !signals.length) return null;
171
+ return el("div", {}, signals.map((s) => el("div", { class: "signal " + s.effect },
172
+ el("b", { text: "⚠ " + (TITLES[s.check] || s.check) + ": " }), s.message)));
173
+ }
174
+ function stepText(s) {
175
+ const t = s.output || "";
176
+ const from = t.match(/"from"\s*:\s*"([^"]+)"/g), subject = t.match(/"subject"\s*:\s*"([^"]+)"/), body = t.match(/"body"\s*:\s*"([^"]{0,200})/);
177
+ if (from && from.length === 1) return `✉ ${from[0].replace(/.*"from"\s*:\s*"/, "").replace(/"$/, "")}${subject ? " — “" + subject[1] + "”" : ""}${body ? ": " + body[1] + "…" : ""}`;
178
+ if (from && from.length > 1) return "Listed several messages";
179
+ if (s.input && Object.keys(s.input).length) return Object.entries(s.input).slice(0, 3).map(([k, v]) => `${k}: ${typeof v === "object" ? JSON.stringify(v) : v}`).join(" · ");
180
+ return t.slice(0, 160);
181
+ }
182
+ function storyList(steps) {
183
+ if (!steps.length) return el("p", { class: "muted", text: "Nothing before this in the same conversation." });
184
+ return el("ol", { class: "story" }, steps.map((s) => el("li", {},
185
+ el("div", {}, el("code", { text: s.name }), el("span", { class: "muted", text: ` ${s.status.replace("_", " ")}${s.decided_by && s.decided_by !== "timeout" ? " · " + (s.decision === "allow" ? "approved" : "rejected") + " by " + s.decided_by : ""}` })),
186
+ el("div", { class: "step", text: stepText(s) }))));
187
+ }
188
+ function storyBox(e) {
189
+ const d = el("details", { class: "story-d" }, el("summary", { text: "What led to this" }));
190
+ const fill = (steps) => { d.append(storyList(steps)); d.dataset.loaded = "1"; };
191
+ if (e.context) { fill(e.context); if (e.signals && e.signals.length) d.open = true; }
192
+ else d.addEventListener("toggle", async () => {
193
+ if (!d.open || d.dataset.loaded) return;
194
+ try { fill((await call("GET", `/v1/events/${e.id}/context`)).steps); } catch { d.append(el("p", { class: "muted", text: "Couldn't load it." })); }
195
+ });
196
+ return d;
197
+ }
198
+
199
+ // One request, one card, two big buttons.
200
+ function card(e, decideFn) {
201
+ const done = outcome(e);
202
+ const note = el("textarea", { placeholder: "Note (optional) — the agent sees it", maxlength: "2000", "aria-label": "Note" });
203
+ const go = (what) => async (ev) => {
204
+ const btns = ev.target.closest(".acts").querySelectorAll("button");
205
+ btns.forEach((b) => (b.disabled = true));
206
+ try {
207
+ const d = await decideFn(what, note.value.trim() || null);
208
+ e.status = d.status; e.decision = d.decision; e.decided_by = d.decided_by; e.decided_at = new Date().toISOString(); e.decision_note = d.decision_note;
209
+ c.replaceWith(card(e, decideFn));
210
+ } catch (err) {
211
+ btns.forEach((b) => (b.disabled = false));
212
+ alert(err.message);
213
+ }
214
+ };
215
+ const c = el("section", { class: "card" + (done ? "" : " held") },
216
+ el("div", { class: "kicker", text: done ? "Request" : "Needs your decision" }),
217
+ el("h2", {}, e.source || e.client, " wants to run ", el("code", { text: e.name })),
218
+ el("div", { class: "why", text: (e.reason || "") + (e.rule_id ? ` (rule: ${e.rule_id})` : "") }),
219
+ signalsEl(e.signals),
220
+ storyBox(e),
221
+ el("dl", { class: "meta" },
222
+ el("dt", { text: "Agent" }), el("dd", { text: `${e.source || "—"} (key: ${e.client})` }),
223
+ el("dt", { text: "Asked" }), el("dd", { text: `${when(e.created_at)} · ${ago(e.created_at)}` }),
224
+ e.session_id ? [el("dt", { text: "Session" }), el("dd", { text: e.session_id })] : null),
225
+ el("pre", { text: pretty(e.input) }),
226
+ done || [
227
+ e.approval_deadline ? el("div", { class: "clock", "data-deadline": e.approval_deadline, "data-then": e.approval_on_timeout || "deny" }) : null,
228
+ note,
229
+ el("div", { class: "acts" },
230
+ el("button", { class: "reject", onclick: go("reject") }, "✕ Reject"),
231
+ el("button", { class: "approve", onclick: go("approve") }, "✓ Approve")),
232
+ ]);
233
+ return c;
234
+ }
235
+
236
+ // ---- mode 1: signed link from Slack / a phone notification (no key needed)
237
+ async function linkMode() {
238
+ try {
239
+ const e = await call("GET", `/v1/a/${token}`);
240
+ $("#who").textContent = `deciding as ${e.approver}`; $("#who").hidden = false;
241
+ root.replaceChildren(card(e, (what, note) => call("POST", `/v1/a/${token}/${what}`, { note })));
242
+ $("#foot").textContent = "This link works for this one request only.";
243
+ tick();
244
+ } catch (err) {
245
+ root.replaceChildren(el("section", { class: "card" }, el("div", { class: "result info" }, el("div", { class: "ic", text: "!" }),
246
+ el("div", {}, el("b", { text: "This link doesn't work" }), el("span", { class: "muted", text: err.message })))));
247
+ }
248
+ }
249
+
250
+ // ---- mode 2: /m inbox with your key
251
+ let me = null;
252
+ function askKey(msg) {
253
+ const input = el("input", { type: "password", placeholder: "gw_…", "aria-label": "Your key", autocomplete: "off" });
254
+ root.replaceChildren(el("section", { class: "card" },
255
+ el("h2", { text: "Sign in" }),
256
+ el("p", { class: "muted", text: msg || "Scan the QR code under “Link your phone” in the dashboard, or paste your key." }),
257
+ input,
258
+ el("button", { class: "approve", style: "width:100%", onclick: () => { key = input.value.trim(); try { localStorage.setItem("gw.key", key); } catch {} inbox(); } }, "Continue")));
259
+ }
260
+ async function inbox() {
261
+ if (!key) return askKey();
262
+ try {
263
+ me = me || await call("GET", "/v1/me");
264
+ $("#who").textContent = me.client; $("#who").hidden = false;
265
+ const [list, controls] = await Promise.all([
266
+ call("GET", "/v1/events?status=awaiting_approval&limit=100"),
267
+ call("GET", "/v1/controls"),
268
+ ]);
269
+ renderStop(controls);
270
+ const items = list.events.sort((a, b) => (a.approval_deadline || "").localeCompare(b.approval_deadline || ""));
271
+ const decideFn = (id) => (what, note) => call("POST", `/v1/events/${id}/${what}`, { note });
272
+ const typing = document.activeElement && document.activeElement.tagName === "TEXTAREA";
273
+ if (!typing) {
274
+ root.replaceChildren(...(items.length ? items.map((e) => card(e, decideFn(e.id))) :
275
+ [el("div", { class: "empty" }, el("div", { style: "font-size:36px", text: "✓" }), "Nothing is waiting for you.")]));
276
+ }
277
+ document.title = items.length ? `(${items.length}) Approvals` : "Approvals";
278
+ $("#foot").textContent = me.can_approve ? "Updates every few seconds." : `“${me.client}” can view but not approve.`;
279
+ tick();
280
+ } catch (err) {
281
+ if (err.status === 401) { key = ""; try { localStorage.removeItem("gw.key"); } catch {} askKey("That key was rejected. Try again."); }
282
+ else if (err.status === 403) askKey(err.message);
283
+ else $("#foot").textContent = "Can't reach the gateway right now; retrying…";
284
+ }
285
+ }
286
+ function renderStop(controls) {
287
+ const b = $("#stopBanner");
288
+ const s = controls && controls.all;
289
+ if ($("#stopBtn")) $("#stopBtn").hidden = !!s;
290
+ if (s) {
291
+ b.hidden = false;
292
+ b.replaceChildren(el("b", { text: "All agents are stopped" }), ` by ${s.by}${s.reason ? ": " + s.reason : ""}. An admin can resume them from the dashboard.`);
293
+ } else if (me && (me.can_approve || me.is_admin)) {
294
+ b.hidden = true;
295
+ if (!$("#stopBtn")) {
296
+ $("#foot").after(el("button", { id: "stopBtn", class: "stop", onclick: async () => {
297
+ const reason = prompt("Stop ALL agents now? Every action they try will be blocked until an admin resumes.\n\nReason:");
298
+ if (reason === null) return;
299
+ try { await call("POST", "/v1/controls/stop", { reason: reason || null }); inbox(); } catch (err) { alert(err.message); }
300
+ } }, "⏹ Emergency stop: block all agents"));
301
+ }
302
+ }
303
+ }
304
+
305
+ if (token) linkMode();
306
+ else { inbox(); setInterval(() => { if (!document.hidden) inbox(); }, 5000); }
307
+ })();
308
+ </script>
309
+ </body>
310
+ </html>
@@ -0,0 +1,145 @@
1
+ """
2
+ Claude Code hook: sends EVERY Claude Code tool call (Bash, Edit, Write, Read, WebFetch, MCP tools...)
3
+ through Squidbrake before it runs, and reports the result afterwards.
4
+
5
+ allowed by the rules -> Claude Code carries on as normal (its own permission prompts still apply)
6
+ blocked by a rule -> the call is denied and Claude is told why
7
+ held for approval -> Claude Code waits (up to ~9 minutes) until someone approves / rejects it
8
+ in the dashboard
9
+
10
+ It also records what you ask (UserPromptSubmit), so the gateway can tell addresses you gave from ones a web page gave.
11
+
12
+ Install it with: python connect.py claude-code (or the Claude Code plugin: see plugin/README.md)
13
+ Settings (env vars, set by connect.py in the hook command):
14
+ GATEWAY_URL, GATEWAY_API_KEY, GATEWAY_SOURCE (default "claude-code"),
15
+ GATEWAY_FAIL_OPEN=1 to let calls run when the gateway is unreachable (default: block them)
16
+ """
17
+ from __future__ import annotations
18
+
19
+ import json
20
+ import os
21
+ import sys
22
+ import tempfile
23
+ import time
24
+ from pathlib import Path
25
+
26
+ import httpx
27
+
28
+ def _arg(flag: str, env: str, default: str) -> str:
29
+ # Claude Code hook config has no env field, so connect.py passes settings as arguments.
30
+ # Installed as a Claude Code plugin, they come from the plugin's settings instead.
31
+ if flag in sys.argv[1:-1]:
32
+ return sys.argv[sys.argv.index(flag) + 1]
33
+ plugin_option = {"GATEWAY_URL": "CLAUDE_PLUGIN_OPTION_GATEWAY_URL", "GATEWAY_API_KEY": "CLAUDE_PLUGIN_OPTION_API_KEY"}.get(env)
34
+ return os.getenv(env) or (plugin_option and os.getenv(plugin_option)) or default
35
+
36
+
37
+ GATEWAY_URL = _arg("--url", "GATEWAY_URL", "http://localhost:8080").rstrip("/")
38
+ GATEWAY_API_KEY = _arg("--key", "GATEWAY_API_KEY", "")
39
+ SOURCE = _arg("--source", "GATEWAY_SOURCE", "claude-code")
40
+ FAIL_OPEN = os.getenv("GATEWAY_FAIL_OPEN", "").lower() in ("1", "true", "yes")
41
+ MAX_WAIT = float(os.getenv("GATEWAY_MAX_WAIT", "540")) # stay under Claude Code's 600 s hook timeout
42
+ # Tools that already report to the gateway themselves (gateway_mcp.py) - don't record them twice.
43
+ SKIP_PREFIXES = ("mcp__gateway-db__", "mcp__gw-")
44
+ STATE_DIR = Path(tempfile.gettempdir()) / "squidbrake-hook"
45
+
46
+
47
+ def deny(reason: str, stop: bool = False) -> None:
48
+ """Refuse this tool call. stop=True also ends Claude's turn (the agent or its session was stopped)."""
49
+ out: dict = {"hookSpecificOutput": {
50
+ "hookEventName": "PreToolUse", "permissionDecision": "deny", "permissionDecisionReason": reason}}
51
+ if stop:
52
+ out.update({"continue": False, "stopReason": reason})
53
+ print(json.dumps(out))
54
+ sys.exit(0)
55
+
56
+
57
+ def pre(ev: dict, http: httpx.Client) -> None:
58
+ body = {
59
+ "name": ev.get("tool_name", "unknown"), "kind": "claude_code", "input": ev.get("tool_input"),
60
+ "source": SOURCE, "session_id": ev.get("session_id"),
61
+ "metadata": {"cwd": ev.get("cwd"), "permission_mode": ev.get("permission_mode"),
62
+ "tool_use_id": ev.get("tool_use_id")},
63
+ }
64
+ try:
65
+ r = http.post("/v1/events", json=body)
66
+ if r.status_code == 401:
67
+ raise httpx.HTTPError("the gateway rejected GATEWAY_API_KEY")
68
+ r.raise_for_status()
69
+ d = r.json()
70
+ if d["decision"] == "review":
71
+ print(f"Squidbrake: '{body['name']}' is waiting for approval at {GATEWAY_URL}/dashboard",
72
+ file=sys.stderr, flush=True)
73
+ end = time.monotonic() + MAX_WAIT
74
+ while d["decision"] == "review" and time.monotonic() < end:
75
+ wait = min(25.0, end - time.monotonic())
76
+ r = http.get(f"/v1/events/{d['event_id']}/decision", params={"wait": wait}, timeout=wait + 10)
77
+ r.raise_for_status()
78
+ d = r.json()
79
+ except httpx.HTTPError as e:
80
+ if FAIL_OPEN:
81
+ return
82
+ deny(f"Squidbrake at {GATEWAY_URL} is unreachable or misconfigured ({e}). Every tool call must go "
83
+ f"through it, so this call was blocked. Ask the user to start the gateway.")
84
+
85
+ if d["decision"] == "review":
86
+ deny("Squidbrake: nobody approved this in time. Ask the user to approve it in the dashboard, then try again.")
87
+ if d["decision"] == "deny":
88
+ by = d.get("decided_by")
89
+ if d.get("rule_id") in ("emergency-stop", "session-stop") or (d.get("decision_note") or "").startswith("The session was stopped"):
90
+ deny(f"Squidbrake: {d.get('decision_note') or d.get('reason')}. Stop working and tell the user.", stop=True)
91
+ if by and by != "timeout":
92
+ note = f' Note: "{d["decision_note"]}".' if d.get("decision_note") else ""
93
+ deny(f"Squidbrake: rejected by {by}.{note} Don't retry it; ask the user how to proceed.")
94
+ deny(f"Squidbrake blocked this (rule '{d.get('rule_id')}'): {(d.get('reason') or '').rstrip('.')}. Don't try to work around it.")
95
+
96
+ # Allowed: remember the event so PostToolUse can attach the result, then let Claude Code continue normally.
97
+ if ev.get("tool_use_id"):
98
+ STATE_DIR.mkdir(exist_ok=True)
99
+ (STATE_DIR / f"{ev['tool_use_id']}.json").write_text(json.dumps({"event_id": d["event_id"], "t0": time.time()}))
100
+
101
+
102
+ def post(ev: dict, http: httpx.Client) -> None:
103
+ f = STATE_DIR / f"{ev.get('tool_use_id')}.json"
104
+ if not f.exists():
105
+ return
106
+ state = json.loads(f.read_text())
107
+ f.unlink(missing_ok=True)
108
+ try:
109
+ http.post(f"/v1/events/{state['event_id']}/result", json={
110
+ "output": ev.get("tool_response"), "duration_ms": (time.time() - state["t0"]) * 1000})
111
+ except httpx.HTTPError:
112
+ pass # the tool already ran; never fail Claude Code because reporting failed
113
+
114
+
115
+ def prompt(ev: dict, http: httpx.Client) -> None:
116
+ """Record what the user asked. Never blocks the prompt: if the gateway is down, Claude Code carries on."""
117
+ text = ev.get("prompt")
118
+ if not text:
119
+ return
120
+ try:
121
+ http.post("/v1/events", json={"name": "user.prompt", "kind": "prompt", "input": {"prompt": text},
122
+ "output": {"recorded": True}, "source": SOURCE, "session_id": ev.get("session_id")})
123
+ except httpx.HTTPError:
124
+ pass
125
+
126
+
127
+ def main() -> None:
128
+ try:
129
+ ev = json.load(sys.stdin)
130
+ except ValueError:
131
+ sys.exit(0)
132
+ if str(ev.get("tool_name", "")).startswith(SKIP_PREFIXES):
133
+ sys.exit(0)
134
+ with httpx.Client(base_url=GATEWAY_URL, headers={"X-Gateway-Key": GATEWAY_API_KEY}, timeout=10) as http:
135
+ if ev.get("hook_event_name") == "PreToolUse":
136
+ pre(ev, http)
137
+ elif ev.get("hook_event_name") == "PostToolUse":
138
+ post(ev, http)
139
+ elif ev.get("hook_event_name") == "UserPromptSubmit":
140
+ prompt(ev, http)
141
+ sys.exit(0)
142
+
143
+
144
+ if __name__ == "__main__":
145
+ main()
squidbrake/cli.py ADDED
@@ -0,0 +1,43 @@
1
+ """
2
+ The `squidbrake` command (installed with pip):
3
+
4
+ squidbrake start the gateway (same as: python server.py)
5
+ squidbrake connect claude-code connect an agent (same as: python connect.py ...)
6
+ squidbrake hook the Claude Code hook, used by the Claude Code plugin (plugin/)
7
+ squidbrake add-key NAME | keys | verify FILE | ... see: squidbrake --help
8
+
9
+ Data, keys and rules.yaml live in ~/.squidbrake (set SQUIDBRAKE_HOME to move them).
10
+ """
11
+ from __future__ import annotations
12
+
13
+ import os
14
+ import sys
15
+ from pathlib import Path
16
+
17
+
18
+ def main() -> int:
19
+ here = Path(__file__).resolve().parent
20
+ # The gateway's modules sit next to this file in the package (in a checkout, one folder up)
21
+ # and import each other by their plain names.
22
+ sys.path.insert(0, str(here if (here / "server.py").exists() else here.parent))
23
+ os.environ.setdefault("SQUIDBRAKE_CLI", "squidbrake")
24
+ argv = sys.argv[1:]
25
+ if argv[:1] in (["-V"], ["--version"]):
26
+ from squidbrake import __version__
27
+ print(f"squidbrake {__version__}")
28
+ return 0
29
+ if argv[:1] == ["connect"]:
30
+ import connect
31
+ connect.main(argv[1:])
32
+ return 0
33
+ if argv[:1] == ["hook"]: # the Claude Code plugin's hook (reads the event on stdin)
34
+ sys.argv = ["claude_hook.py", *argv[1:]]
35
+ import claude_hook
36
+ claude_hook.main()
37
+ return 0
38
+ import server
39
+ return server.main(argv)
40
+
41
+
42
+ if __name__ == "__main__":
43
+ sys.exit(main())