Let bots manage their hooks, with the user confirming what widens them

Bots get list_hooks, pause_hook and delete_hook, which act directly since
they only take away. A new hook, or a new name, brief or filter, or
switching a paused one back on, goes through propose_hook: it stores a
hook_proposals row and puts a [hook-proposal:ID] card in the reply, which
the user it was proposed to confirms or rejects. The card is read from
what Confirm saves, with the bot's reason labelled as its own; confirming
checks everything again and saves through the Hooks modal's helpers. A
new hook's URL is shown on the card once, to the user only, never in the
outcome the bot is told. What calls a hook, its computers and its signing
are never proposed. Only chat replies propose, and a hook call's own run
gets no hook tools, so a caller's payload can't reach them.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
2026-10-25 15:18:54 +01:00
co-authored by Claude Opus 5.5
parent 045167715d
commit 380d3ebb2f
54 changed files with 1983 additions and 55 deletions
@@ -0,0 +1,19 @@
import fetchApi from "@moduletrace/twui/utils/fetch/fetchApi";
import type { APIResponseObject } from "@moduletrace/bunext/types";
import type { HookProposalDecision } from "@/src/types";
type Params = {
proposal_id: number;
decision: "confirm" | "reject";
};
/**
* Confirms or rejects a hook proposal from its card, returning it as it
* now stands and, for a new hook called at a URL, its token (shown once).
*/
export default async function decideHookProposal({ proposal_id, decision }: Params) {
return await fetchApi<{ proposal_id: number }, APIResponseObject<HookProposalDecision>>(
`/api/admin/hook-proposals/${decision}-proposal`,
{ method: "POST", body: { proposal_id } },
);
}
@@ -0,0 +1,66 @@
import fetchApi from "@moduletrace/twui/utils/fetch/fetchApi";
import type { APIResponseObject } from "@moduletrace/bunext/types";
import { useCallback, useEffect, useState } from "react";
import type { HookProposalView } from "@/src/types";
type Params = {
proposal_id: number;
};
/**
* Hook proposals already read, by id, so a thread naming one twice (or a
* re-render) asks once. A decision replaces its entry.
*/
const loaded = new Map<number, Promise<HookProposalView | null>>();
function loadProposal({ proposal_id }: Params) {
const cached = loaded.get(proposal_id);
if (cached) return cached;
const pending = fetchApi<{}, APIResponseObject<HookProposalView>>(
`/api/admin/hook-proposals/get-proposal?proposal_id=${proposal_id}`,
{ method: "GET" },
)
.then((res) => (res?.success && res.singleRes ? res.singleRes : null))
.catch(() => null);
loaded.set(proposal_id, pending);
return pending;
}
/**
* A hook a bot proposed (`[hook-proposal:ID]`), for its chip and confirm
* card: read when the chip renders, so it shows whether it's still
* waiting. Only the user it was proposed to can read it; anyone else gets
* null.
*/
export default function useHookProposal({ proposal_id }: Params) {
const [view, setViewState] = useState<HookProposalView | null>(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
let live = true;
loadProposal({ proposal_id }).then((result) => {
if (!live) return;
setViewState(result);
setLoading(false);
});
return () => {
live = false;
};
}, [proposal_id]);
const setView = useCallback(
(next: HookProposalView) => {
loaded.set(proposal_id, Promise.resolve(next));
setViewState(next);
},
[proposal_id],
);
return { view, loading, setView };
}
@@ -42,7 +42,8 @@ type Props = {
* (a webhook, a script) calls to give the bot a task, whose result lands
* here. Open it from the header menu; the list loads then, so the page's
* props stay small. The user makes, pauses, edits, re-keys and deletes
* their own; bots never make hooks.
* their own; bots propose new hooks and changes from the chat
* (`propose_hook`), which the user confirms there.
*/
export default function BotHooksModal({ bot, workspace, on_open }: Props) {
const { pageProps } = useContext(AppContext);
@@ -0,0 +1,49 @@
import { describe, expect, test } from "bun:test";
import { renderToString } from "react-dom/server";
import type { HookProposalView } from "@/src/types";
import HookProposalCard from "./hook-proposal-card";
const pushes: HookProposalView = {
id: 4,
action: "create",
hook_id: null,
title: "GitHub pushes",
bot_id: 6,
bot_name: "Ada",
status: "pending",
expired: false,
expires_at: Date.now() + 60 * 60 * 1000,
outcome: "",
result_id: null,
makes_url: true,
details: [
{ label: "Called by", value: "Anyone with its URL (shown to you once, when you confirm)" },
{ label: "Brief", value: "Summarise each push.", block: true },
],
reason: "You asked for a summary of every push.",
};
function render({ view }: { view: HookProposalView }) {
return renderToString(<HookProposalCard view={view} onDecided={() => {}} onDone={() => {}} />);
}
describe("HookProposalCard", () => {
test("shows what would be saved, the bot's reason and the buttons", () => {
const html = render({ view: pushes });
expect(html).toContain("Summarise each push.");
expect(html).toContain("Why (written by the bot)");
expect(html).toContain("You asked for a summary of every push.");
expect(html).toContain("Its URL is shown here once you confirm");
expect(html).toContain(">Confirm<");
});
test("offers no buttons once it's decided or expired", () => {
expect(render({ view: { ...pushes, status: "confirmed", outcome: "Saved as hook #2." } })).not.toContain(">Confirm<");
expect(render({ view: { ...pushes, expired: true } })).not.toContain(">Confirm<");
});
test("a change says nothing about a URL", () => {
expect(render({ view: { ...pushes, action: "change", hook_id: 2, makes_url: false } })).not.toContain("Its URL");
});
});
@@ -0,0 +1,129 @@
import { useContext, useState } from "react";
import Button from "@moduletrace/twui/layout/Button";
import Row from "@moduletrace/twui/layout/Row";
import Span from "@moduletrace/twui/layout/Span";
import Stack from "@moduletrace/twui/layout/Stack";
import HookUrlNotice from "@/src/components/general/hook-url-notice";
import { AppContext } from "@/src/pages/__root";
import type { HookProposalView } from "@/src/types";
import decideHookProposal from "../(functions)/decide-hook-proposal";
import sendBotMessage from "../(functions)/send-bot-message";
import hookProposalDecisionMessage from "../(utils)/hook-proposal-decision-message";
import proposalExpiryLabel from "../(utils)/proposal-expiry-label";
import MessageDetailsHeading from "./message-details-heading";
import ProposalDetailRow from "./proposal-detail-row";
type Props = {
view: HookProposalView;
onDecided: (view: HookProposalView) => void;
/**
* The user is done with the card (after deciding, or after copying a
* new hook's URL).
*/
onDone: () => void;
};
/**
* The confirm card for a hook a bot proposed. Everything above the bot's
* reason is read from what Confirm saves (what calls it, where it runs,
* the whole brief; for a change each field as it is and would be).
* Confirming a new hook called at a URL shows that URL here, once: the
* bot never gets it. Deciding tells the bot in chat what happened.
*/
export default function HookProposalCard({ view, onDecided, onDone }: Props) {
const { pageProps } = useContext(AppContext);
const [busy, setBusy] = useState<"confirm" | "reject">();
const [error, setError] = useState<string>();
const [token, setToken] = useState<string>();
const open = view.status == "pending" && !view.expired;
const decide = async (decision: "confirm" | "reject") => {
const question =
decision == "reject"
? `Reject this hook? Nothing will be saved.`
: view.action == "create"
? `Add the hook ${view.title}? Each call runs its brief for ${view.bot_name} on its own, as you.`
: `Change the hook ${view.title}?`;
if (!window.confirm(question)) return;
setBusy(decision);
setError(undefined);
const res = await decideHookProposal({ proposal_id: view.id, decision }).catch(() => null);
if (!res?.success || !res.singleRes) {
setBusy(undefined);
setError(res?.msg || `Couldn't ${decision} it.`);
return;
}
setToken(res.singleRes.token);
onDecided(res.singleRes.view);
await sendBotMessage({
bot_id: Number(pageProps?.bot?.id) || 0,
workspace_id: pageProps?.workspace?.id || null,
text: hookProposalDecisionMessage({ view: res.singleRes.view }),
media_ids: [],
}).catch(() => null);
setBusy(undefined);
if (!res.singleRes.token) onDone();
};
if (token) return <HookUrlNotice token={token} onDone={onDone} />;
return (
<Stack className="w-full gap-0! items-stretch">
<MessageDetailsHeading>What would be saved</MessageDetailsHeading>
{view.details.map((detail) => (
<ProposalDetailRow key={detail.label} {...detail} />
))}
{view.makes_url ? (
<Span className="mt-1 text-[12px] leading-4 text-ink-secondary">
{`Its URL is shown here once you confirm, to you only. To require signed calls, add a signing secret from the Hooks menu afterwards.`}
</Span>
) : null}
<MessageDetailsHeading>Why (written by the bot)</MessageDetailsHeading>
<Span className="whitespace-pre-wrap text-[13px] leading-5 text-ink break-words">{view.reason}</Span>
{view.outcome ? <Span className="mt-4 text-[13px] leading-5 text-ink">{view.outcome}</Span> : null}
{error ? <Span className="mt-2 text-[13px] leading-5 text-ink">{error}</Span> : null}
{open ? (
<Row className="mt-5 w-full gap-2! justify-end">
<Span className="grow text-[12px] leading-4 text-ink-secondary">
{proposalExpiryLabel({ expires_at: view.expires_at })}
</Span>
<Button
type="button"
variant="outlined"
color="gray"
size="small"
title={`Reject the hook ${view.title}: nothing is saved`}
loading={busy == "reject"}
disabled={Boolean(busy)}
onClick={() => decide("reject")}
>
Reject
</Button>
<Button
type="button"
size="small"
title={`Save the hook ${view.title}`}
loading={busy == "confirm"}
disabled={Boolean(busy)}
onClick={() => decide("confirm")}
>
Confirm
</Button>
</Row>
) : null}
</Stack>
);
}
@@ -0,0 +1,69 @@
import { useState } from "react";
import Modal from "@moduletrace/twui/elements/Modal";
import Button from "@moduletrace/twui/layout/Button";
import H2 from "@moduletrace/twui/layout/H2";
import Span from "@moduletrace/twui/layout/Span";
import Stack from "@moduletrace/twui/layout/Stack";
import { Webhook } from "lucide-react";
import useHookProposal from "../(hooks)/use-hook-proposal";
import hookProposalTitle from "../(utils)/hook-proposal-title";
import proposalStatusLabel from "../(utils)/proposal-status-label";
import HookProposalCard from "./hook-proposal-card";
type Props = {
proposal_id: number;
};
/**
* A hook a bot proposed (`hookProposalRef`): a chip naming it and where
* it stands, which opens the card the user confirms or rejects it from.
* Anyone it wasn't proposed to sees its number only.
*/
export default function HookProposalRefChip({ proposal_id }: Props) {
const { view, loading, setView } = useHookProposal({ proposal_id });
const [open, setOpen] = useState(false);
if (!view) {
return (
<Span className="font-bold">
{loading ? `hook proposal ${proposal_id}` : `hook proposal ${proposal_id} (not available)`}
</Span>
);
}
const title = hookProposalTitle({ view });
const status = proposalStatusLabel({ view });
return (
<Modal
target={
<Button
type="button"
variant="outlined"
color="gray"
size="smaller"
title={`Review: ${title}`}
className="inline-flex h-6 gap-1.5! flex-nowrap align-middle px-2! text-[13px] font-bold text-ink whitespace-nowrap"
>
<Webhook size={14} aria-hidden className="shrink-0" />
{`${title} · ${status}`}
</Button>
}
targetWrapperProps={{ className: "inline-flex align-middle" }}
open={open}
setOpen={setOpen}
className="max-w-xl bg-paper p-6"
>
<Stack className="w-full gap-1! items-stretch">
<H2 className="text-lg! leading-6! tracking-normal! font-bold text-ink">{title}</H2>
<Span className="text-[13px] leading-[18px] text-ink-secondary">
{`${status}. ${view.bot_name} proposed this; nothing is saved until you confirm. Everything above the bot's own words is read from what Confirm would save.`}
</Span>
</Stack>
<Stack className="mt-2 max-h-[65vh] w-full gap-0! items-stretch overflow-y-auto">
<HookProposalCard view={view} onDecided={setView} onDone={() => setOpen(false)} />
</Stack>
</Modal>
);
}
@@ -5,6 +5,7 @@ import {
BotRefHrefPrefix,
ComputerRefHrefPrefix,
FileRefHrefPrefix,
HookProposalRefHrefPrefix,
ProposalRefHrefPrefix,
TakeOverRefHrefPrefix,
WorkspaceRefHrefPrefix,
@@ -14,6 +15,7 @@ import BotRefChip from "./bot-ref-chip";
import ComputerRefChip from "./computer-ref-chip";
import FileRefChip from "./file-ref-chip";
import FolderRefChip from "./folder-ref-chip";
import HookProposalRefChip from "./hook-proposal-ref-chip";
import ProposalRefChip from "./proposal-ref-chip";
import TakeOverChip from "./take-over-chip";
import WorkspaceRefChip from "./workspace-ref-chip";
@@ -23,9 +25,9 @@ import WorkspaceRefChip from "./workspace-ref-chip";
* becomes its switch button, a take-over request a button that opens the
* computer, a bot it named a link to that bot's conversation, a workspace
* a link to that workspace, a file it saved a chip that downloads it, a
* folder it named a chip that opens it in the files browser, an MCP server
* or tool it proposed a chip that opens the confirm card; anything else
* opens in a new tab.
* folder it named a chip that opens it in the files browser, an MCP server,
* tool, skill or hook it proposed a chip that opens the confirm card;
* anything else opens in a new tab.
*/
export default function MarkdownLink({ href, children }: ComponentProps<"a">) {
if (href?.startsWith(ComputerRefHrefPrefix)) {
@@ -73,6 +75,12 @@ export default function MarkdownLink({ href, children }: ComponentProps<"a">) {
if (proposal_id) return <ProposalRefChip proposal_id={proposal_id} />;
}
if (href?.startsWith(HookProposalRefHrefPrefix)) {
const proposal_id = Number(href.slice(HookProposalRefHrefPrefix.length));
if (proposal_id) return <HookProposalRefChip proposal_id={proposal_id} />;
}
const folder_ref = parseFolderRefHref({ href });
if (folder_ref) return <FolderRefChip spec={folder_ref.spec} path={folder_ref.path} />;
@@ -0,0 +1,21 @@
import type { HookProposalView } from "@/src/types";
import hookProposalTitle from "./hook-proposal-title";
type Params = {
view: HookProposalView;
};
/**
* What the chat tells the bot once the user decided on its hook proposal,
* so it carries on knowing what happened. Never the hook's URL: the
* outcome doesn't hold it.
*/
export default function hookProposalDecisionMessage({ view }: Params) {
const title = hookProposalTitle({ view });
if (view.status == "rejected") return `I rejected hook proposal #${view.id} (${title}). Nothing was saved.`;
if (view.status == "failed") return `I confirmed hook proposal #${view.id} (${title}), but it failed. ${view.outcome}`;
return `I confirmed hook proposal #${view.id} (${title}). ${view.outcome}`;
}
@@ -0,0 +1,13 @@
import type { HookProposalView } from "@/src/types";
type Params = {
view: Pick<HookProposalView, "action" | "title">;
};
/**
* What a hook proposal does, e.g. `Add hook GitHub pushes` or `Change
* hook GitHub pushes`.
*/
export default function hookProposalTitle({ view }: Params) {
return `${view.action == "change" ? "Change" : "Add"} hook ${view.title}`;
}