approve.html
Rama main · réplica de lectura del repositorio original.
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<meta name="robots" content="noindex">
<title>Approvals</title>
<link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 64 64'%3E%3Crect width='64' height='64' rx='14' fill='%23FFF8F2'/%3E%3Cpath fill='%231E2A23' d='M16 30V21.5C16 12.4 23.2 5 32 5C40.8 5 48 12.4 48 21.5V30Z'/%3E%3Cg fill='none' stroke='%231E2A23' stroke-width='5' stroke-linecap='round'%3E%3Cpath d='M23 34V48.2C23 51.4 21.7 53.9 19.6 55.8'/%3E%3Cpath d='M32 34V44.6'/%3E%3Cpath d='M41 34V48.2C41 51.4 42.3 53.9 44.4 55.8'/%3E%3C/g%3E%3Ccircle fill='%231E2A23' cx='32' cy='52.6' r='2.9'/%3E%3Crect fill='%23F2622E' x='11' y='24.5' width='42' height='11' rx='5.5'/%3E%3Ccircle fill='%232FD574' cx='41.2' cy='30' r='2.5'/%3E%3C/svg%3E">
<style>
:root {
color-scheme: light;
--page: #f4f3f0; --card: #fcfcfb; --soft: #f0efec; --border: #e2e1dc;
--text: #0b0b0b; --text-2: #52514e; --muted: #7a7974;
--good: #0ca30c; --good-ink: #ffffff; --bad: #d03b3b; --warn: #fab219; --warn-ink: #3d2c00; --accent: #2a78d6;
--mono: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
}
@media (prefers-color-scheme: dark) {
:root:where(:not([data-theme="light"])) {
color-scheme: dark;
--page: #121211; --card: #1a1a19; --soft: #242422; --border: #2f2f2c;
--text: #ffffff; --text-2: #c3c2b7; --muted: #8f8e86; --accent: #3987e5;
}
}
:root[data-theme="dark"] {
color-scheme: dark;
--page: #121211; --card: #1a1a19; --soft: #242422; --border: #2f2f2c;
--text: #ffffff; --text-2: #c3c2b7; --muted: #8f8e86; --accent: #3987e5;
}
* { box-sizing: border-box; }
[hidden] { display: none !important; }
html, body { margin: 0; }
body { background: var(--page); color: var(--text); font: 16px/1.45 system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
-webkit-font-smoothing: antialiased; }
main { max-width: 560px; margin: 0 auto; padding: max(16px, env(safe-area-inset-top)) 16px 40px; }
header { display: flex; align-items: center; gap: 10px; margin: 4px 0 16px; }
header h1 { font-size: 17px; margin: 0; font-weight: 650; }
header .sp { flex: 1; }
.pill { font-size: 12px; color: var(--text-2); background: var(--soft); border: 1px solid var(--border); border-radius: 999px; padding: 2px 10px; }
.card { background: var(--card); border: 1px solid var(--border); border-radius: 16px; padding: 18px; margin-bottom: 14px; }
.card.held { border-left: 5px solid var(--warn); }
.kicker { font-size: 12.5px; color: var(--text-2); text-transform: uppercase; letter-spacing: .05em; margin-bottom: 6px; }
h2 { font-size: 22px; line-height: 1.25; margin: 0 0 6px; word-break: break-word; }
h2 code { font-family: var(--mono); font-size: 19px; }
.why { color: var(--text-2); margin-bottom: 12px; }
.meta { display: grid; grid-template-columns: auto 1fr; gap: 4px 12px; font-size: 14px; margin-bottom: 12px; }
.meta dt { color: var(--muted); }
.meta dd { margin: 0; word-break: break-word; }
pre { margin: 0 0 14px; background: var(--soft); border: 1px solid var(--border); border-radius: 10px; padding: 10px 12px;
font: 13px/1.5 var(--mono); white-space: pre-wrap; word-break: break-word; max-height: 260px; overflow: auto; }
.clock { font-size: 14px; color: var(--text-2); margin-bottom: 12px; font-variant-numeric: tabular-nums; }
.clock.soon { color: var(--bad); font-weight: 600; }
textarea { width: 100%; font: inherit; color: inherit; background: var(--page); border: 1px solid var(--border); border-radius: 10px;
padding: 10px 12px; min-height: 44px; resize: vertical; margin-bottom: 12px; }
.acts { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; }
button { font: inherit; cursor: pointer; border-radius: 12px; border: 1px solid var(--border); background: var(--card); color: var(--text);
padding: 14px 12px; font-weight: 650; min-height: 52px; }
button:disabled { opacity: .5; cursor: default; }
button:focus-visible, textarea:focus-visible, input:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
.approve { background: var(--good); border-color: var(--good); color: var(--good-ink); font-size: 17px; }
.reject { color: var(--bad); border-color: var(--bad); font-size: 17px; }
.stop { width: 100%; background: var(--bad); border-color: var(--bad); color: #fff; }
.result { display: flex; gap: 12px; align-items: flex-start; }
.result .ic { width: 40px; height: 40px; border-radius: 50%; display: grid; place-items: center; font-size: 22px; color: #fff; flex: none; }
.result.ok .ic { background: var(--good); }
.result.no .ic { background: var(--bad); }
.result.info .ic { background: var(--muted); }
.result b { display: block; font-size: 18px; margin-bottom: 2px; }
.muted { color: var(--muted); font-size: 14px; }
.made { margin-top: 40px; font-size: 12px; text-align: center; }
.made a { color: inherit; }
.empty { text-align: center; color: var(--muted); padding: 40px 0; }
.banner { border-radius: 12px; padding: 12px 14px; margin-bottom: 14px; background: var(--bad); color: #fff; font-size: 15px; }
input[type=password] { width: 100%; font: inherit; color: inherit; background: var(--page); border: 1px solid var(--border);
border-radius: 10px; padding: 12px; margin: 8px 0 12px; }
a { color: var(--accent); }
.signal { border-radius: 10px; padding: 10px 12px; font-size: 14px; margin-bottom: 10px; background: var(--soft);
border: 1px solid var(--border); border-left: 5px solid var(--warn); }
.signal.block { border-left-color: var(--bad); }
.brief { border-radius: 10px; padding: 10px 12px; font-size: 14px; margin-bottom: 10px; background: var(--soft);
border: 1px solid var(--border); border-left: 5px solid var(--accent); white-space: pre-wrap; }
.brief small { display: block; color: var(--muted); margin-top: 4px; }
.ghost { width: 100%; margin-top: 10px; background: transparent; color: var(--bad); border-color: var(--bad); min-height: 44px; }
.faceid { width: 100%; margin-top: 10px; min-height: 44px; }
.story-d { margin: 0 0 12px; }
.story-d summary { cursor: pointer; color: var(--accent); font-size: 15px; padding: 4px 0; }
.story { list-style: none; margin: 8px 0 0; padding: 0 0 0 14px; border-left: 2px solid var(--border); }
.story li { margin-bottom: 10px; }
.story code { font-family: var(--mono); font-size: 13px; }
.story .step { font-size: 14px; background: var(--soft); border-radius: 8px; padding: 6px 9px; margin-top: 3px; word-break: break-word; }
</style>
</head>
<body>
<main>
<header>
<h1>Squidbrake</h1>
<span class="sp"></span>
<span class="pill" id="who" hidden></span>
</header>
<div id="stopBanner" class="banner" hidden></div>
<div id="root"></div>
<p class="muted" id="foot"></p>
<p class="muted made">Approvals by <a href="https://squidbrake.com/go/approve" target="_blank" rel="noopener">Squidbrake</a>: your AI agents ask, a person decides. Free and open source.</p>
</main>
<script>
(() => {
"use strict";
const $ = (s) => document.querySelector(s);
const root = $("#root");
const tokenMatch = location.pathname.match(/^\/a\/([^/]+)$/);
const token = tokenMatch ? tokenMatch[1] : null;
// Key for the /m inbox: taken once from the #key=... link the dashboard shows as a QR code, then kept
// in this browser only (the fragment is never sent to the server) and removed from the address bar.
let key = "";
try { key = localStorage.getItem("gw.key") || ""; } catch {}
const frag = new URLSearchParams(location.hash.slice(1));
if (frag.get("key")) {
key = frag.get("key");
try { localStorage.setItem("gw.key", key); } catch {}
history.replaceState(null, "", location.pathname + location.search);
}
function el(tag, attrs = {}, ...kids) {
const n = document.createElement(tag);
for (const [k, v] of Object.entries(attrs)) {
if (v == null || v === false) continue;
if (k === "class") n.className = v;
else if (k === "text") n.textContent = v;
else if (k.startsWith("on")) n.addEventListener(k.slice(2), v);
else n.setAttribute(k, v);
}
kids.flat().forEach((c) => c != null && n.append(c.nodeType ? c : document.createTextNode(String(c))));
return n;
}
async function call(method, path, body) {
const headers = { "Content-Type": "application/json" };
if (key && !token) headers["X-Gateway-Key"] = key;
const res = await fetch(path, { method, headers, body: body ? JSON.stringify(body) : undefined });
let data = null;
try { data = await res.json(); } catch {}
if (!res.ok) { const e = new Error((data && data.detail) || `${res.status}`); e.status = res.status; throw e; }
return data;
}
const pad = (n) => String(n).padStart(2, "0");
// ---- passkeys: Face ID / fingerprint signs the decision itself
const fromB64u = (s) => Uint8Array.from(atob(s.replace(/-/g, "+").replace(/_/g, "/") + "===".slice((s.length + 3) % 4)), (c) => c.charCodeAt(0));
const toB64u = (buf) => btoa(String.fromCharCode(...new Uint8Array(buf))).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
const passkeysWork = () => !!(window.PublicKeyCredential && navigator.credentials && window.isSecureContext);
// challengePath gives what to sign; returns the proof, or null when there's no device to sign with.
async function sign(challengePath, outcome) {
if (!passkeysWork()) return null;
const ch = await call("POST", challengePath, { outcome });
if (!ch.allow_credentials.length) {
if (ch.required && outcome === "approve") throw new Error("Approvals need Face ID or fingerprint, and this person has no device set up yet. Set one up first (button below, signed in with your key).");
return null;
}
const cred = await navigator.credentials.get({ publicKey: { challenge: fromB64u(ch.challenge), rpId: ch.rp_id,
userVerification: "required", timeout: 60000, allowCredentials: ch.allow_credentials.map((id) => ({ type: "public-key", id: fromB64u(id) })) } });
return { credential_id: toB64u(cred.rawId), statement: ch.statement, authenticator_data: toB64u(cred.response.authenticatorData),
client_data_json: toB64u(cred.response.clientDataJSON), signature: toB64u(cred.response.signature) };
}
async function registerDevice() {
const name = prompt("Name this device:", /iPhone|iPad|Android|Mac|Windows/.exec(navigator.userAgent)?.[0] || "My phone");
if (!name) return;
try {
const o = await call("POST", "/v1/devices/register/start");
const cred = await navigator.credentials.create({ publicKey: { ...o, challenge: fromB64u(o.challenge),
user: { ...o.user, id: fromB64u(o.user.id) }, excludeCredentials: o.excludeCredentials.map((c) => ({ ...c, id: fromB64u(c.id) })) } });
await call("POST", "/v1/devices/register/finish", { name: name.trim(), challenge: o.challenge, credential_id: toB64u(cred.rawId),
client_data_json: toB64u(cred.response.clientDataJSON), attestation_object: toB64u(cred.response.attestationObject) });
alert("Done. Your approvals from this device are now signed with Face ID / fingerprint.");
inbox();
} catch (err) { if (err.name !== "NotAllowedError") alert(err.message); }
}
function briefEl(e) {
if (!e.brief || !e.brief.text) return null;
return el("div", { class: "brief" }, el("b", { text: "AI brief: " }), e.brief.text,
el("small", { text: "Advice only. The rules held it; you decide." }));
}
function when(iso) { return iso ? new Date(iso).toLocaleString([], { month: "short", day: "numeric", hour: "2-digit", minute: "2-digit" }) : "—"; }
function ago(iso) {
const s = Math.max(0, Math.round((Date.now() - new Date(iso)) / 1000));
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`;
}
function pretty(v) { return v == null ? "(no input)" : typeof v === "string" ? v : JSON.stringify(v, null, 2); }
function tick() {
document.querySelectorAll("[data-deadline]").forEach((c) => {
const s = Math.round((new Date(c.dataset.deadline) - Date.now()) / 1000);
const then = c.dataset.then === "allow" ? "allowed" : "denied";
c.textContent = s > 0 ? `Decide within ${Math.floor(s / 60)}:${pad(s % 60)} — otherwise it's ${then} automatically`
: `Time's up — it will be ${then} automatically`;
c.classList.toggle("soon", s <= 60);
});
}
setInterval(tick, 1000);
function outcome(e) {
if (e.status === "awaiting_approval") return null;
if (!e.decided_by) return el("div", { class: "result info" }, el("div", { class: "ic", text: "i" }),
el("div", {}, el("b", { text: `Already ${e.status}` }), el("span", { class: "muted", text: e.reason || "" })));
const ok = e.decision === "allow";
const by = e.decided_by === "timeout" ? "Nobody decided in time" : `${ok ? "Approved" : "Rejected"} by ${e.decided_by}`;
return el("div", { class: "result " + (ok ? "ok" : "no") }, el("div", { class: "ic", text: ok ? "✓" : "✕" }),
el("div", {}, el("b", { text: e.decided_by === "timeout" ? `${by} — ${ok ? "allowed" : "denied"}` : by }),
el("span", { class: "muted", text: when(e.decided_at) + (e.decision_note ? ` · “${e.decision_note}”` : "") })));
}
// ---- the story: history warnings + what the agent did just before
const TITLES = { effect: "What it changes", impersonation: "Possible scam", payment_request_in_message: "Payment requested in a message",
repeat_of_rejected: "Already rejected before", duplicate_change: "Done before",
catastrophic_command: "Destructive command", irreversible_command: "Can't be undone", hidden_command: "Hidden code", secret_command: "Shows a secret", rename_command: "Renames or moves", sequence: "Pattern", untrusted_destination: "Address from outside content", after_untrusted: "After reading outside content",
after_untrusted_setup_edit: "Tainted setup change: after reading outside content" };
function signalsEl(signals) {
if (!signals || !signals.length) return null;
return el("div", {}, signals.map((s) => el("div", { class: "signal " + s.effect },
el("b", { text: "⚠ " + (TITLES[s.check] || s.check) + ": " }), s.message)));
}
function stepText(s) {
const t = s.output || "";
const from = t.match(/"from"\s*:\s*"([^"]+)"/g), subject = t.match(/"subject"\s*:\s*"([^"]+)"/), body = t.match(/"body"\s*:\s*"([^"]{0,200})/);
if (from && from.length === 1) return `✉ ${from[0].replace(/.*"from"\s*:\s*"/, "").replace(/"$/, "")}${subject ? " — “" + subject[1] + "”" : ""}${body ? ": " + body[1] + "…" : ""}`;
if (from && from.length > 1) return "Listed several messages";
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(" · ");
return t.slice(0, 160);
}
function storyList(steps) {
if (!steps.length) return el("p", { class: "muted", text: "Nothing before this in the same conversation." });
return el("ol", { class: "story" }, steps.map((s) => el("li", {},
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 : ""}` })),
el("div", { class: "step", text: stepText(s) }))));
}
function storyBox(e) {
const d = el("details", { class: "story-d" }, el("summary", { text: "What led to this" }));
const fill = (steps) => { d.append(storyList(steps)); d.dataset.loaded = "1"; };
if (e.context) { fill(e.context); if (e.signals && e.signals.length) d.open = true; }
else d.addEventListener("toggle", async () => {
if (!d.open || d.dataset.loaded) return;
try { fill((await call("GET", `/v1/events/${e.id}/context`)).steps); } catch { d.append(el("p", { class: "muted", text: "Couldn't load it." })); }
});
return d;
}
// One request, one card, two big buttons.
function card(e, decideFn, stopFn) {
const done = outcome(e);
const note = el("textarea", { placeholder: "Note (optional) — the agent sees it", maxlength: "2000", "aria-label": "Note" });
const go = (what) => async (ev) => {
if (what === "approve" && e.cannot_undo &&
!confirm(`This can't be undone once it runs:\n\n${e.reason || e.name}\n\nApprove anyway?`)) return;
const btns = ev.target.closest(".acts").querySelectorAll("button");
btns.forEach((b) => (b.disabled = true));
try {
const d = await decideFn(what, note.value.trim() || null);
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;
c.replaceWith(card(e, decideFn, stopFn));
} catch (err) {
btns.forEach((b) => (b.disabled = false));
alert(err.message);
}
};
const c = el("section", { class: "card" + (done ? "" : " held") },
el("div", { class: "kicker", text: done ? "Request" : "Needs your decision" }),
el("h2", {}, e.source || e.client, " wants to run ", el("code", { text: e.name })),
el("div", { class: "why", text: (e.reason || "") + (e.rule_id ? ` (rule: ${e.rule_id})` : "") }),
briefEl(e),
signalsEl(e.signals),
storyBox(e),
el("dl", { class: "meta" },
el("dt", { text: "Agent" }), el("dd", { text: `${e.source || "—"} (key: ${e.client})` }),
el("dt", { text: "Asked" }), el("dd", { text: `${when(e.created_at)} · ${ago(e.created_at)}` }),
e.session_id ? [el("dt", { text: "Session" }), el("dd", { text: e.session_id })] : null),
el("pre", { text: pretty(e.input) }),
done || [
e.approval_deadline ? el("div", { class: "clock", "data-deadline": e.approval_deadline, "data-then": e.approval_on_timeout || "deny" }) : null,
note,
el("div", { class: "acts" },
el("button", { class: "reject", onclick: go("reject") }, "✕ Reject"),
el("button", { class: "approve", onclick: go("approve") }, "✓ Approve")),
stopFn ? el("button", { class: "ghost", onclick: async (ev) => {
const who = e.source || e.client;
if (!confirm(`Stop ${who} now? Everything it tries is blocked until an admin resumes it.`)) return;
ev.target.disabled = true;
try { await stopFn(); ev.target.textContent = `⏹ ${who} is stopped`; } catch (err) { ev.target.disabled = false; alert(err.message); }
} }, `⏹ Stop ${e.source || e.client}`) : null,
]);
return c;
}
// ---- mode 1: signed link from Slack / a phone notification (no key needed)
async function linkMode() {
try {
const e = await call("GET", `/v1/a/${token}`);
$("#who").textContent = `deciding as ${e.approver}`; $("#who").hidden = false;
root.replaceChildren(card(e, async (what, note) => call("POST", `/v1/a/${token}/${what}`,
{ note, device_proof: await sign(`/v1/a/${token}/challenge`, what) }), () => call("POST", `/v1/a/${token}/stop`, {})));
$("#foot").textContent = "This link works for this one request only.";
tick();
} catch (err) {
root.replaceChildren(el("section", { class: "card" }, el("div", { class: "result info" }, el("div", { class: "ic", text: "!" }),
el("div", {}, el("b", { text: "This link doesn't work" }), el("span", { class: "muted", text: err.message })))));
}
}
// ---- mode 2: /m inbox with your key
let me = null;
function askKey(msg) {
const input = el("input", { type: "password", placeholder: "gw_…", "aria-label": "Your key", autocomplete: "off" });
root.replaceChildren(el("section", { class: "card" },
el("h2", { text: "Sign in" }),
el("p", { class: "muted", text: msg || "Scan the QR code under “Link your phone” in the dashboard, or paste your key." }),
input,
el("button", { class: "approve", style: "width:100%", onclick: () => { key = input.value.trim(); try { localStorage.setItem("gw.key", key); } catch {} inbox(); } }, "Continue")));
}
async function inbox() {
if (!key) return askKey();
try {
me = me || await call("GET", "/v1/me");
$("#who").textContent = me.client; $("#who").hidden = false;
const [list, controls] = await Promise.all([
call("GET", "/v1/events?status=awaiting_approval&limit=100"),
call("GET", "/v1/controls"),
]);
renderStop(controls);
const items = list.events.sort((a, b) => (a.approval_deadline || "").localeCompare(b.approval_deadline || ""));
const decideFn = (id) => async (what, note) => call("POST", `/v1/events/${id}/${what}`,
{ note, device_proof: me.can_approve ? await sign(`/v1/events/${id}/challenge`, what) : null });
const typing = document.activeElement && document.activeElement.tagName === "TEXTAREA";
if (!typing) {
root.replaceChildren(...(items.length ? items.map((e) => card(e, decideFn(e.id))) :
[el("div", { class: "empty" }, el("div", { style: "font-size:36px", text: "✓" }), "Nothing is waiting for you.")]));
}
document.title = items.length ? `(${items.length}) Approvals` : "Approvals";
$("#foot").textContent = me.can_approve ? "Updates every few seconds." : `“${me.client}” can view but not approve.`;
if (me.can_approve && passkeysWork() && !$("#faceBtn")) {
const devs = await call("GET", "/v1/devices").catch(() => ({ devices: [] }));
if (!devs.devices.some((d) => d.person === me.client))
$("#foot").after(el("button", { id: "faceBtn", class: "faceid", onclick: registerDevice }, "🔒 Set up Face ID / fingerprint for approvals"));
}
tick();
} catch (err) {
if (err.status === 401) { key = ""; try { localStorage.removeItem("gw.key"); } catch {} askKey("That key was rejected. Try again."); }
else if (err.status === 403) askKey(err.message);
else $("#foot").textContent = "Can't reach the gateway right now; retrying…";
}
}
function renderStop(controls) {
const b = $("#stopBanner");
const s = controls && controls.all;
if ($("#stopBtn")) $("#stopBtn").hidden = !!s;
if (s) {
b.hidden = false;
b.replaceChildren(el("b", { text: "All agents are stopped" }), ` by ${s.by}${s.reason ? ": " + s.reason : ""}. An admin can resume them from the dashboard.`);
} else if (me && (me.can_approve || me.is_admin)) {
b.hidden = true;
if (!$("#stopBtn")) {
$("#foot").after(el("button", { id: "stopBtn", class: "stop", onclick: async () => {
const reason = prompt("Stop ALL agents now? Every action they try will be blocked until an admin resumes.\n\nReason:");
if (reason === null) return;
try { await call("POST", "/v1/controls/stop", { reason: reason || null }); inbox(); } catch (err) { alert(err.message); }
} }, "⏹ Emergency stop: block all agents"));
}
}
}
if (token) linkMode();
else { inbox(); setInterval(() => { if (!document.hidden) inbox(); }, 5000); }
})();
</script>
</body>
</html>