diff --git a/app/src/features/bots/AllowRules.tsx b/app/src/features/bots/AllowRules.tsx new file mode 100644 index 00000000..e49c718e --- /dev/null +++ b/app/src/features/bots/AllowRules.tsx @@ -0,0 +1,87 @@ +// What the bot may do without asking (spec 10.1), in its details: each +// "Allow always" the owner gave, with a button that makes the bot ask again. + +import { X } from "lucide-react"; +import { useEffect, useState } from "react"; +import { useT } from "../../i18n"; +import type { AllowRule, Bot } from "../../lib/protocol.gen"; +import { useApi } from "../../store/context"; +import { Button } from "../../ui/Button"; +import { attempt } from "../../ui/toast"; +import { toolTitle } from "../chat/toolNames"; + +/** The bot's rules, read when shown and kept current by `bot.rules`. */ +function useAllowRules(botId: Bot["id"]): AllowRule[] | null { + const api = useApi(); + const [rules, setRules] = useState(null); + useEffect(() => { + let alive = true; + setRules(null); + const stop = api.subscribe((event) => { + if (event.name === "bot.rules" && event.params.botId === botId) { + setRules(event.params.rules); + } + }); + api.call("rules.list", { botId }).then( + (listed) => alive && setRules((current) => current ?? listed), + // An older daemon has no rules: the list stays empty. + () => alive && setRules([]), + ); + return () => { + alive = false; + stop(); + }; + }, [api, botId]); + return rules; +} + +export function AllowRules({ bot }: { bot: Bot }) { + const t = useT(); + const words = t.bots.details; + const api = useApi(); + const rules = useAllowRules(bot.id); + return ( +
+
{words.always}
+
+ {rules !== null && rules.length === 0 && ( +

{words.alwaysNone(bot.name)}

+ )} + {rules !== null && rules.length > 0 && ( +
    + {rules.map((rule) => { + const title = toolTitle(rule.scope.toolName, t.tools); + const what = rule.scope.value ? `${title}: ${rule.scope.value}` : title; + return ( +
  • + + {title} + {rule.scope.value && ( + + {rule.scope.value} + + )} + +
  • + ); + })} +
+ )} +
+
+ ); +} diff --git a/app/src/features/bots/BotView.tsx b/app/src/features/bots/BotView.tsx index 0a5899fa..f7aa4754 100644 --- a/app/src/features/bots/BotView.tsx +++ b/app/src/features/bots/BotView.tsx @@ -23,6 +23,7 @@ import { BotRoutines } from "../routines/RoutineList"; import { ScreensPanel } from "../screens/ScreensPanel"; import { ShowScreen } from "../screens/showScreen"; import { useScreens } from "../screens/useScreens"; +import { AllowRules } from "./AllowRules"; import { BotHeader } from "./BotHeader"; import { stateView } from "./BotStateBadge"; import { useFollowBot } from "./useFollowBot"; @@ -287,6 +288,7 @@ function Details({ bot, onClose }: { bot: Bot; onClose(): void }) { {bot.instructions || words.noInstructions} + ); diff --git a/app/src/features/chat/ApprovalCard.tsx b/app/src/features/chat/ApprovalCard.tsx index f9e719c4..5af4c107 100644 --- a/app/src/features/chat/ApprovalCard.tsx +++ b/app/src/features/chat/ApprovalCard.tsx @@ -1,10 +1,10 @@ // A permission request (spec 10.1): what the bot wants to do, with Allow // and Deny. Once answered it shrinks to one line. -import { Ban, Check, Hand, TimerOff } from "lucide-react"; +import { Ban, Check, CheckCheck, Hand, TimerOff } from "lucide-react"; import { useId } from "react"; import { useT } from "../../i18n"; -import type { ApprovalItem, Bot } from "../../lib/protocol.gen"; +import type { AllowScope, ApprovalItem, Bot } from "../../lib/protocol.gen"; import { Button } from "../../ui/Button"; import { Details } from "../../ui/Details"; import { HELP_TOOL, HelpCard } from "../browser/HelpCard"; @@ -78,6 +78,23 @@ export function ApprovalCard({ approval, bot }: { approval: ApprovalItem; bot: B } } +/** "Allow always", by what it would cover. */ +function alwaysLabel( + scope: AllowScope, + words: ReturnType["chat"]["approval"]["always"], +): string { + switch (scope.kind) { + case "command": + return words.command; + case "site": + return words.site(scope.value); + case "file": + return words.file; + default: + return words.tool; + } +} + const BOX = "max-h-56 overflow-auto break-all rounded-lg border border-line bg-sunken px-2.5 py-1.5"; @@ -158,10 +175,20 @@ function ToolApproval({ approval, bot }: { approval: ApprovalItem; bot: Bot }) { placeholder={t.chat.approval.notePlaceholder} className="mt-3 h-9 w-full rounded-xl border border-line-strong bg-canvas px-3 text-sm outline-none placeholder:text-muted focus:border-muted" /> -
+
+ {approval.always && ( + + )} diff --git a/app/src/features/chat/allowAlways.test.tsx b/app/src/features/chat/allowAlways.test.tsx new file mode 100644 index 00000000..a5b5dfa3 --- /dev/null +++ b/app/src/features/chat/allowAlways.test.tsx @@ -0,0 +1,76 @@ +import { act, cleanup, fireEvent, screen, within } from "@testing-library/react"; +import { afterEach, describe, expect, test } from "vitest"; +import { FakeBotloft } from "../../lib/fake"; +import { crewOpened, openBot, renderApp } from "../../test/app"; + +afterEach(cleanup); + +async function openScout() { + const fake = new FakeBotloft(); + const ops = fake.addCrew("Ops"); + const scout = fake.addBot(ops.id, "Scout", "Finds sources"); + fake.setBotState(scout.id, "idle"); + renderApp(fake); + await crewOpened("Ops"); + openBot("Scout"); + await screen.findByRole("list", { name: "Messages" }); + return { fake, scout }; +} + +const details = () => screen.getByRole("complementary", { name: "About Scout" }); + +describe("Allow always", () => { + test("allows the request for good and lists it in the bot's details, where it can go", async () => { + const { fake, scout } = await openScout(); + act(() => { + fake.chat.ask(scout.id, "Bash", "git status", '{"command":"git status"}'); + }); + const card = screen.getByRole("region", { name: "Scout asks to run a command" }); + const always = within(card).getByRole("button", { name: "Always allow this command" }); + expect(always.getAttribute("title")).toBe( + "Scout will not ask for this again. You can undo it in Scout's details.", + ); + fireEvent.click(always); + expect(await screen.findByText("Allowed: run a command")).toBeDefined(); + expect(fake.calls.find((call) => call.method === "approvals.answer")?.params).toMatchObject({ + allow: true, + always: true, + }); + + fireEvent.click(screen.getByRole("button", { name: /^Show details/ })); + const rule = await within(details()).findByText("git status"); + expect(rule).toBeDefined(); + fireEvent.click( + within(details()).getByRole("button", { name: "Ask again: Run a command: git status" }), + ); + expect( + await within(details()).findByText( + 'Nothing yet. When Scout asks for something, "Always allow" puts it here.', + ), + ).toBeDefined(); + }); + + test("names the site, and is not offered for what cannot be allowed for good", async () => { + const { fake, scout } = await openScout(); + act(() => { + fake.chat.ask( + scout.id, + "WebFetch", + "https://www.example.com/a", + '{"url":"https://www.example.com/a"}', + ); + }); + const page = screen.getByRole("region", { name: /^Scout asks to/ }); + expect(within(page).getByRole("button", { name: "Always allow example.com" })).toBeDefined(); + fireEvent.click(within(page).getByRole("button", { name: "Allow" })); + await screen.findByText(/^Allowed:/); + // A plain Allow keeps no rule. + expect(fake.allow.rules).toEqual([]); + + act(() => { + fake.chat.ask(scout.id, "Glob", "*.md", '{"pattern":"*.md"}'); + }); + const glob = screen.getByRole("region", { name: /^Scout asks to/ }); + expect(within(glob).queryByRole("button", { name: /^Always allow/ })).toBeNull(); + }); +}); diff --git a/app/src/features/chat/useAnswer.ts b/app/src/features/chat/useAnswer.ts index 1f249343..a569f382 100644 --- a/app/src/features/chat/useAnswer.ts +++ b/app/src/features/chat/useAnswer.ts @@ -5,13 +5,14 @@ import { attempt } from "../../ui/toast"; /** * Answering a request; the note goes to the bot on a denial. `input` is a - * bot suggestion as the owner changed it (spec 10.2). + * bot suggestion as the owner changed it (spec 10.2); `always` allows the + * request for good (spec 10.1). */ export function useAnswer(approval: ApprovalItem, failed: { allow: string; deny: string }) { const api = useApi(); const [note, setNote] = useState(""); const [busy, setBusy] = useState(false); - const answer = async (allow: boolean, input?: string) => { + const answer = async (allow: boolean, input?: string, always = false) => { setBusy(true); const trimmed = note.trim(); await attempt(allow ? failed.allow : failed.deny, () => @@ -20,6 +21,7 @@ export function useAnswer(approval: ApprovalItem, failed: { allow: string; deny: allow, ...(allow || !trimmed ? {} : { note: trimmed }), ...(allow && input !== undefined ? { input } : {}), + ...(allow && always ? { always } : {}), }), ); setBusy(false); diff --git a/app/src/i18n/en/bots.ts b/app/src/i18n/en/bots.ts index 963fcb16..63a1d0ca 100644 --- a/app/src/i18n/en/bots.ts +++ b/app/src/i18n/en/bots.ts @@ -116,5 +116,10 @@ export const bots = { generation: (generation: number) => `generation ${generation}`, instructions: "Instructions", noInstructions: "None yet.", + always: "Allowed without asking", + alwaysNone: (bot: string) => + `Nothing yet. When ${bot} asks for something, "Always allow" puts it here.`, + alwaysRemove: (what: string) => `Ask again: ${what}`, + alwaysRemoveFailed: "Could not remove it", }, }; diff --git a/app/src/i18n/en/chat.ts b/app/src/i18n/en/chat.ts index f1be1055..35b3ce44 100644 --- a/app/src/i18n/en/chat.ts +++ b/app/src/i18n/en/chat.ts @@ -83,6 +83,15 @@ export const chat = { notePlaceholder: "Why not? Sent to the bot if you deny (optional)", allow: "Allow", deny: "Deny", + /** "Allow always" (spec 10.1), by what it covers. */ + always: { + command: "Always allow this command", + site: (site: string) => `Always allow ${site}`, + file: "Always allow this file", + tool: "Always allow", + hint: (bot: string) => + `${bot} will not ask for this again. You can undo it in ${bot}'s details.`, + }, allowFailed: "Could not allow it", denyFailed: "Could not deny it", allowed: (action: string) => `Allowed: ${action}`, diff --git a/app/src/i18n/es/bots.ts b/app/src/i18n/es/bots.ts index ab777a13..d89aeb49 100644 --- a/app/src/i18n/es/bots.ts +++ b/app/src/i18n/es/bots.ts @@ -114,5 +114,10 @@ export const bots: Messages["bots"] = { generation: (generation: number) => `generación ${generation}`, instructions: "Instrucciones", noInstructions: "Ninguna todavía.", + always: "Permitido sin preguntar", + alwaysNone: (bot: string) => + `Nada todavía. Cuando ${bot} pida algo, "Permitir siempre" lo pone aquí.`, + alwaysRemove: (what: string) => `Volver a preguntar: ${what}`, + alwaysRemoveFailed: "No se pudo quitar", }, }; diff --git a/app/src/i18n/es/chat.ts b/app/src/i18n/es/chat.ts index bcd7ee66..9ec6508d 100644 --- a/app/src/i18n/es/chat.ts +++ b/app/src/i18n/es/chat.ts @@ -80,6 +80,14 @@ export const chat: Messages["chat"] = { notePlaceholder: "¿Por qué no? Se envía al bot si deniegas (opcional)", allow: "Permitir", deny: "Denegar", + always: { + command: "Permitir siempre este comando", + site: (site: string) => `Permitir siempre en ${site}`, + file: "Permitir siempre este archivo", + tool: "Permitir siempre", + hint: (bot: string) => + `${bot} no volverá a pedir esto. Puedes deshacerlo en los detalles de ${bot}.`, + }, allowFailed: "No se pudo permitir", denyFailed: "No se pudo denegar", allowed: (action: string) => `Permitido: ${action}`, diff --git a/app/src/i18n/pt-BR/bots.ts b/app/src/i18n/pt-BR/bots.ts index 3fbf9f91..9b2d6ec0 100644 --- a/app/src/i18n/pt-BR/bots.ts +++ b/app/src/i18n/pt-BR/bots.ts @@ -113,5 +113,10 @@ export const bots: Messages["bots"] = { generation: (generation: number) => `geração ${generation}`, instructions: "Instruções", noInstructions: "Nenhuma ainda.", + always: "Permitido sem perguntar", + alwaysNone: (bot: string) => + `Nada ainda. Quando ${bot} pedir algo, "Permitir sempre" coloca aqui.`, + alwaysRemove: (what: string) => `Voltar a perguntar: ${what}`, + alwaysRemoveFailed: "Não foi possível remover", }, }; diff --git a/app/src/i18n/pt-BR/chat.ts b/app/src/i18n/pt-BR/chat.ts index 58bdd341..c153690b 100644 --- a/app/src/i18n/pt-BR/chat.ts +++ b/app/src/i18n/pt-BR/chat.ts @@ -79,6 +79,14 @@ export const chat: Messages["chat"] = { notePlaceholder: "Por que não? Vai para o bot se você negar (opcional)", allow: "Permitir", deny: "Negar", + always: { + command: "Permitir sempre este comando", + site: (site: string) => `Permitir sempre em ${site}`, + file: "Permitir sempre neste arquivo", + tool: "Permitir sempre", + hint: (bot: string) => + `${bot} não vai pedir isso de novo. Dá para desfazer nos detalhes de ${bot}.`, + }, allowFailed: "Não foi possível permitir", denyFailed: "Não foi possível negar", allowed: (action: string) => `Permitido: ${action}`, diff --git a/app/src/lib/fake.ts b/app/src/lib/fake.ts index 254c5d41..ce265fd7 100644 --- a/app/src/lib/fake.ts +++ b/app/src/lib/fake.ts @@ -3,6 +3,7 @@ // handles, archiving, and the notifications each change sends. import type { BotloftApi } from "./api"; +import { FakeAllow } from "./fakeAllow"; import { botHandlers } from "./fakeBots"; import { FakeBrowser } from "./fakeBrowser"; import { FakeChat } from "./fakeChat"; @@ -67,6 +68,7 @@ export class FakeBotloft implements BotloftApi { /** Every call, in order. */ readonly calls: { method: Method; params: unknown }[] = []; readonly chat = new FakeChat(this); + readonly allow = new FakeAllow(this); readonly conversation = new FakeConversation(this); readonly routines = new FakeRoutines(this); readonly files = new FakeFiles(this); @@ -249,6 +251,7 @@ export class FakeBotloft implements BotloftApi { ...crewHandlers(this), ...botHandlers(this), ...this.chat.handlers(), + ...this.allow.handlers(), ...this.conversation.handlers(), ...this.files.handlers(), ...this.browser.handlers(), diff --git a/app/src/lib/fakeAllow.ts b/app/src/lib/fakeAllow.ts new file mode 100644 index 00000000..1fbf8d4c --- /dev/null +++ b/app/src/lib/fakeAllow.ts @@ -0,0 +1,95 @@ +// The fake daemon's "Allow always" (spec 10.1): which requests can be +// allowed for good, the rules kept per bot, and `rules.*`. The real daemon +// also allows a request a rule covers without asking; nothing here asks on +// its own, so that part is not played. + +import type { FakeBotloft, Handlers } from "./fake"; +import { notFound } from "./fakeRules"; +import type { AllowRule, AllowScope, BotId } from "./protocol.gen"; + +type RuleMethods = Extract; + +/** What "Allow always" covers for a request, as the daemon words it. */ +export function scopeOf(toolName: string, input: string): AllowScope | null { + let parsed: Record; + try { + parsed = JSON.parse(input) as Record; + } catch { + return null; + } + const field = (name: string) => { + const value = parsed[name]; + return typeof value === "string" && value.trim() ? value.trim() : null; + }; + const scope = (kind: AllowScope["kind"], value: string | null): AllowScope | null => + value === null ? null : { toolName, kind, value }; + switch (toolName) { + case "Bash": + case "PowerShell": + return scope("command", field("command")); + case "WebFetch": { + const url = field("url"); + try { + return scope("site", url && new URL(url).hostname.replace(/^www\./, "")); + } catch { + return null; + } + } + case "Read": + case "Write": + case "Edit": + case "MultiEdit": + return scope("file", field("file_path")); + case "NotebookEdit": + return scope("file", field("notebook_path")); + case "WebSearch": + return scope("tool", ""); + default: + return null; + } +} + +export class FakeAllow { + readonly rules: AllowRule[] = []; + + constructor(private readonly fake: FakeBotloft) {} + + /** Keeps `scope` for the bot, once, and tells the app. */ + remember(botId: BotId, scope: AllowScope): void { + const same = this.rules.some( + (rule) => + rule.botId === botId && + rule.scope.toolName === scope.toolName && + rule.scope.kind === scope.kind && + rule.scope.value === scope.value, + ); + if (!same) { + this.rules.push({ id: this.fake.id("rul"), botId, scope, createdAt: this.fake.now }); + } + this.fake.emit({ name: "bot.rules", params: { botId, rules: this.of(botId) } }); + } + + private of(botId: BotId): AllowRule[] { + return this.rules.filter((rule) => rule.botId === botId); + } + + handlers(): Pick { + return { + "rules.list": ({ botId }) => { + this.fake.bot(botId); + return this.of(botId); + }, + "rules.delete": ({ ruleId }) => { + const at = this.rules.findIndex((rule) => rule.id === ruleId); + const rule = this.rules[at]; + if (!rule) { + throw notFound(`rule ${ruleId}`); + } + this.rules.splice(at, 1); + const left = { botId: rule.botId, rules: this.of(rule.botId) }; + this.fake.emit({ name: "bot.rules", params: left }); + return left; + }, + }; + } +} diff --git a/app/src/lib/fakeChat.ts b/app/src/lib/fakeChat.ts index 63421baf..fea077e3 100644 --- a/app/src/lib/fakeChat.ts +++ b/app/src/lib/fakeChat.ts @@ -2,6 +2,7 @@ // the bot: live text, replies, tool calls and permission requests. import type { FakeBotloft, Handlers } from "./fake"; +import { scopeOf } from "./fakeAllow"; import { conflict, notFound } from "./fakeRules"; import type { Activity, @@ -169,6 +170,7 @@ export class FakeChat { input, status: "pending", note: null, + always: scopeOf(toolName, input), }); } @@ -183,7 +185,7 @@ export class FakeChat { .reverse() .slice(0, limit ?? 50); }, - "approvals.answer": ({ approvalId, allow, note, input }) => { + "approvals.answer": ({ approvalId, allow, note, input, always }) => { const approval = this.approvals.get(approvalId); if (!approval) { throw notFound(`approval ${approvalId}`); @@ -249,6 +251,9 @@ export class FakeChat { ); if (item) { const body = item.body as { kind: "approval" } & ApprovalItem; + if (allow && always && body.always) { + this.fake.allow.remember(approval.botId, body.always); + } this.update(item.id, { ...body, input: approval.input, diff --git a/app/src/lib/protocol.gen.ts b/app/src/lib/protocol.gen.ts index 1be071f3..deabdf92 100644 --- a/app/src/lib/protocol.gen.ts +++ b/app/src/lib/protocol.gen.ts @@ -65,6 +65,11 @@ export type RoutineId = string; */ export type RoutineRunId = string; +/** + * Identifies a request a bot may make without asking (spec 10.1). + */ +export type RuleId = string; + /** * A group of bots that can message each other and share a folder. */ @@ -741,6 +746,44 @@ output: string | null, */ file: string | null, }; +/** + * What an allow rule covers, besides its tool. + */ +export type AllowKind = "command" | "site" | "file" | "tool"; + +/** + * What "Allow always" would cover for one request: the tool and, for a + * command, site or file, which one. + */ +export type AllowScope = { toolName: string, kind: AllowKind, +/** + * The command, the site or the file's path; empty for `tool`. + */ +value: string, }; + +/** + * A request the bot no longer asks for: the daemon allows it alone. + */ +export type AllowRule = { id: RuleId, botId: BotId, scope: AllowScope, +/** + * Unix time in milliseconds. + */ +createdAt: number, }; + +export type RulesListParams = { botId: BotId, }; + +export type RuleIdParams = { ruleId: RuleId, }; + +/** + * A bot's rules after one was added or removed: the result of + * `rules.delete` and the params of the `bot.rules` notification. + */ +export type BotRules = { botId: BotId, +/** + * Oldest first. + */ +rules: Array, }; + export type ApprovalItem = { approvalId: ApprovalId, toolName: string, summary: string, /** * What the bot says the command is for, as in `ToolItem`. The bot @@ -751,7 +794,13 @@ explanation: string | null, input: string, status: ApprovalStatus, /** * What the owner wrote with a denial. */ -note: string | null, }; +note: string | null, +/** + * What "Allow always" would cover (spec 10.1); `null` when the + * request cannot be allowed for good. Kept only here, like the + * explanation. + */ +always: AllowScope | null, }; /** * Tokens of one turn, as the `usage` of Claude Code's `result` event: the @@ -890,7 +939,12 @@ note?: string, * it, in the JSON of the tool's input (spec 10.2, 20.12). Ignored for * other requests. */ -input?: string, }; +input?: string, +/** + * With `allow`, the bot no longer asks for what the request's + * `always` covers (spec 10.1). Ignored for a request without one. + */ +always?: boolean, }; /** * The daemon's part of the app's Settings. @@ -1238,6 +1292,8 @@ export interface RpcMethods { "bots.setEffort": { params: BotsSetEffortParams; result: Bot }; "bots.compact": { params: BotIdParams; result: Bot }; "usage.tokens": { params: UsageTokensParams; result: Array }; + "rules.list": { params: RulesListParams; result: Array }; + "rules.delete": { params: RuleIdParams; result: BotRules }; "routines.list": { params: RoutinesListParams; result: Array }; "routines.create": { params: RoutinesCreateParams; result: Routine }; "routines.update": { params: RoutinesUpdateParams; result: Routine }; @@ -1275,6 +1331,7 @@ export interface RpcNotifications { "bot.context": BotContextChanged; "routine.changed": Routine; "routine.run": RoutineRun; + "bot.rules": BotRules; "browser.changed": BrowserState; "browser.action": BrowserAction; "browser.frame": BrowserFrame; diff --git a/crates/botloft-core/src/ids.rs b/crates/botloft-core/src/ids.rs index 82eaa5f4..9e1445ca 100644 --- a/crates/botloft-core/src/ids.rs +++ b/crates/botloft-core/src/ids.rs @@ -108,6 +108,10 @@ prefixed_id!( /// Identifies one run of a routine. RoutineRunId, "routine run", "rrn_" ); +prefixed_id!( + /// Identifies a request a bot may make without asking (spec 10.1). + RuleId, "rule", "rul_" +); /// A random version 4 UUID, for Claude Code session ids and the uuid of /// each message written to a bot (spec 7.3, 9.2). diff --git a/crates/botloft-core/src/protocol/chat.rs b/crates/botloft-core/src/protocol/chat.rs index 8eafdb47..49bb188f 100644 --- a/crates/botloft-core/src/protocol/chat.rs +++ b/crates/botloft-core/src/protocol/chat.rs @@ -2,7 +2,7 @@ use serde::{Deserialize, Serialize}; -use super::{Message, TokenUsage}; +use super::{AllowScope, Message, TokenUsage}; use crate::ids::{ApprovalId, BotId, ChatItemId}; text_enum!( @@ -153,6 +153,11 @@ pub struct ApprovalItem { pub status: ApprovalStatus, /// What the owner wrote with a denial. pub note: Option, + /// What "Allow always" would cover (spec 10.1); `null` when the + /// request cannot be allowed for good. Kept only here, like the + /// explanation. + #[serde(default)] + pub always: Option, } #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] @@ -249,6 +254,11 @@ pub struct ApprovalsAnswerParams { #[serde(default)] #[cfg_attr(test, ts(optional))] pub input: Option, + /// With `allow`, the bot no longer asks for what the request's + /// `always` covers (spec 10.1). Ignored for a request without one. + #[serde(default)] + #[cfg_attr(test, ts(optional))] + pub always: Option, } /// The last thing in a bot's chat, for the conversation list. diff --git a/crates/botloft-core/src/protocol/export.rs b/crates/botloft-core/src/protocol/export.rs index 53ef2426..b021acff 100644 --- a/crates/botloft-core/src/protocol/export.rs +++ b/crates/botloft-core/src/protocol/export.rs @@ -11,13 +11,15 @@ use super::*; use crate::avatar::PALETTE; use crate::ids::{ ApprovalId, AttachmentId, BotId, ChatItemId, CrewId, DeliveryId, MessageId, RoutineId, - RoutineRunId, TaskId, + RoutineRunId, RuleId, TaskId, }; #[path = "export_browser.rs"] mod browser; #[path = "export_routines.rs"] mod routines; +#[path = "export_rules.rs"] +mod rules; #[path = "export_session.rs"] mod session; #[path = "export_usage.rs"] @@ -97,6 +99,7 @@ fn export_bindings() { out.decl::(); out.decl::(); out.decl::(); + out.decl::(); out.decl::(); out.decl::(); out.decl::(); @@ -163,6 +166,7 @@ fn export_bindings() { out.decl::(); out.decl::(); out.decl::(); + rules::decls(&mut out); out.decl::(); usage::decls(&mut out); out.decl::(); @@ -322,6 +326,7 @@ fn export_bindings() { ); session::methods(&mut out); usage::methods(&mut out); + rules::methods(&mut out); routines::methods(&mut out); browser::methods(&mut out); out.text.push_str("}\n\n"); @@ -367,6 +372,7 @@ fn export_bindings() { let _ = writeln!(out.text, " \"{}\": {task};", notification::TASK_CHANGED); session::notifications(&mut out); routines::notifications(&mut out); + rules::notifications(&mut out); browser::notifications(&mut out); out.text.push_str("}\n\n"); diff --git a/crates/botloft-core/src/protocol/export_rules.rs b/crates/botloft-core/src/protocol/export_rules.rs new file mode 100644 index 00000000..4e5bcd45 --- /dev/null +++ b/crates/botloft-core/src/protocol/export_rules.rs @@ -0,0 +1,34 @@ +//! The "Allow always" part of the TypeScript bindings (spec 10.1). + +use std::fmt::Write as _; + +use super::super::*; +use super::Out; + +/// Before `ApprovalItem`, which holds `AllowScope`. +pub(super) fn decls(out: &mut Out) { + out.decl::(); + out.decl::(); + out.decl::(); + out.decl::(); + out.decl::(); + out.decl::(); +} + +pub(super) fn methods(out: &mut Out) { + out.method( + method::RULES_LIST, + &out.name::(), + &out.name::>(), + ); + out.method( + method::RULES_DELETE, + &out.name::(), + &out.name::(), + ); +} + +pub(super) fn notifications(out: &mut Out) { + let rules = out.name::(); + let _ = writeln!(out.text, " \"{}\": {rules};", notification::BOT_RULES); +} diff --git a/crates/botloft-core/src/protocol/mod.rs b/crates/botloft-core/src/protocol/mod.rs index ae0f55ba..f6b208a1 100644 --- a/crates/botloft-core/src/protocol/mod.rs +++ b/crates/botloft-core/src/protocol/mod.rs @@ -59,6 +59,7 @@ mod messaging; mod methods; mod model; mod routines; +mod rules; mod screens; mod session; mod settings; @@ -72,6 +73,7 @@ pub use messaging::*; pub use methods::*; pub use model::*; pub use routines::*; +pub use rules::*; pub use screens::*; pub use session::*; pub use settings::*; @@ -108,6 +110,8 @@ pub mod method { pub const BOTS_RESTART: &str = "bots.restart"; pub const CHAT_HISTORY: &str = "chat.history"; pub const APPROVALS_ANSWER: &str = "approvals.answer"; + pub const RULES_LIST: &str = "rules.list"; + pub const RULES_DELETE: &str = "rules.delete"; pub const MESSAGES_SEND: &str = "messages.send"; pub const MESSAGES_LIST: &str = "messages.list"; pub const ATTACHMENTS_READ: &str = "attachments.read"; @@ -149,6 +153,7 @@ pub mod notification { pub const FOLDER_RECYCLED: &str = "folder.recycled"; pub const BOT_STATE: &str = "bot.state"; pub const BOT_CONTEXT: &str = "bot.context"; + pub const BOT_RULES: &str = "bot.rules"; pub const CHAT_ITEM: &str = "chat.item"; pub const CHAT_DELTA: &str = "chat.delta"; pub const MESSAGE_CREATED: &str = "message.created"; diff --git a/crates/botloft-core/src/protocol/rules.rs b/crates/botloft-core/src/protocol/rules.rs new file mode 100644 index 00000000..82c7101b --- /dev/null +++ b/crates/botloft-core/src/protocol/rules.rs @@ -0,0 +1,91 @@ +//! "Allow always" (spec 10.1): what the owner let a bot do without asking +//! again, kept as rules of that bot. + +use serde::{Deserialize, Serialize}; + +use crate::ids::{BotId, RuleId}; + +/// What an allow rule covers, besides its tool. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +#[cfg_attr(test, derive(ts_rs::TS))] +pub enum AllowKind { + /// This exact command (`Bash`, `PowerShell`). + Command, + /// Pages of this site and its subdomains (`WebFetch`). + Site, + /// This file (`Read`, `Write`, `Edit`, `MultiEdit`, `NotebookEdit`). + File, + /// Every call of the tool (`WebSearch`). + Tool, +} + +impl AllowKind { + pub fn as_str(self) -> &'static str { + match self { + Self::Command => "command", + Self::Site => "site", + Self::File => "file", + Self::Tool => "tool", + } + } + + pub fn parse(text: &str) -> Option { + Some(match text { + "command" => Self::Command, + "site" => Self::Site, + "file" => Self::File, + "tool" => Self::Tool, + _ => return None, + }) + } +} + +/// What "Allow always" would cover for one request: the tool and, for a +/// command, site or file, which one. +#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +#[cfg_attr(test, derive(ts_rs::TS))] +pub struct AllowScope { + pub tool_name: String, + pub kind: AllowKind, + /// The command, the site or the file's path; empty for `tool`. + pub value: String, +} + +/// A request the bot no longer asks for: the daemon allows it alone. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +#[cfg_attr(test, derive(ts_rs::TS))] +pub struct AllowRule { + pub id: RuleId, + pub bot_id: BotId, + pub scope: AllowScope, + /// Unix time in milliseconds. + pub created_at: i64, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +#[cfg_attr(test, derive(ts_rs::TS))] +pub struct RulesListParams { + pub bot_id: BotId, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +#[cfg_attr(test, derive(ts_rs::TS))] +pub struct RuleIdParams { + pub rule_id: RuleId, +} + +/// A bot's rules after one was added or removed: the result of +/// `rules.delete` and the params of the `bot.rules` notification. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +#[cfg_attr(test, derive(ts_rs::TS))] +pub struct BotRules { + pub bot_id: BotId, + /// Oldest first. + pub rules: Vec, +} diff --git a/crates/botloft-store/migrations/0014_allow_rules.sql b/crates/botloft-store/migrations/0014_allow_rules.sql new file mode 100644 index 00000000..a56674fb --- /dev/null +++ b/crates/botloft-store/migrations/0014_allow_rules.sql @@ -0,0 +1,11 @@ +-- What the owner let each bot do without asking again (spec 10.1): a tool +-- and, for a command, site or file, which one (`value`, empty for a tool). +CREATE TABLE allow_rules ( + id TEXT PRIMARY KEY, + bot_id TEXT NOT NULL REFERENCES bots (id), + tool_name TEXT NOT NULL, + kind TEXT NOT NULL, + value TEXT NOT NULL, + created_at INTEGER NOT NULL, + UNIQUE (bot_id, tool_name, kind, value) +); diff --git a/crates/botloft-store/src/allow_rules.rs b/crates/botloft-store/src/allow_rules.rs new file mode 100644 index 00000000..f5655798 --- /dev/null +++ b/crates/botloft-store/src/allow_rules.rs @@ -0,0 +1,132 @@ +//! `allow_rules` table: what each bot may do without asking (spec 10.1). + +use botloft_core::ids::{BotId, RuleId}; +use botloft_core::protocol::{AllowKind, AllowRule, AllowScope}; +use rusqlite::types::Type; +use rusqlite::{OptionalExtension, Row, params}; + +use crate::{Result, Store, parse_column}; + +const COLUMNS: &str = "id, bot_id, tool_name, kind, value, created_at"; + +fn from_row(row: &Row<'_>) -> rusqlite::Result { + let kind: String = row.get(3)?; + let kind = AllowKind::parse(&kind).ok_or_else(|| { + rusqlite::Error::FromSqlConversionFailure(3, Type::Text, "unknown rule kind".into()) + })?; + Ok(AllowRule { + id: parse_column(row, 0)?, + bot_id: parse_column(row, 1)?, + scope: AllowScope { + tool_name: row.get(2)?, + kind, + value: row.get(4)?, + }, + created_at: row.get(5)?, + }) +} + +impl Store { + /// Keeps `scope` as a rule of `bot`. The same scope again keeps the + /// first rule; either way the rule is returned. + pub fn add_allow_rule(&self, bot: &BotId, scope: &AllowScope, now: i64) -> Result { + self.conn.execute( + "INSERT INTO allow_rules (id, bot_id, tool_name, kind, value, created_at) \ + VALUES (?1, ?2, ?3, ?4, ?5, ?6) \ + ON CONFLICT (bot_id, tool_name, kind, value) DO NOTHING", + params![ + RuleId::generate().as_str(), + bot.as_str(), + scope.tool_name, + scope.kind.as_str(), + scope.value, + now, + ], + )?; + Ok(self.conn.query_row( + &format!( + "SELECT {COLUMNS} FROM allow_rules \ + WHERE bot_id = ?1 AND tool_name = ?2 AND kind = ?3 AND value = ?4" + ), + params![ + bot.as_str(), + scope.tool_name, + scope.kind.as_str(), + scope.value + ], + from_row, + )?) + } + + /// The rules of `bot`, oldest first. + pub fn allow_rules(&self, bot: &BotId) -> Result> { + let mut statement = self.conn.prepare_cached(&format!( + "SELECT {COLUMNS} FROM allow_rules WHERE bot_id = ?1 ORDER BY created_at, rowid" + ))?; + let rules = statement + .query_map([bot.as_str()], from_row)? + .collect::>()?; + Ok(rules) + } + + /// Removes a rule; the bot it belonged to, or `None` if there was none. + pub fn delete_allow_rule(&self, rule: &RuleId) -> Result> { + Ok(self + .conn + .query_row( + "DELETE FROM allow_rules WHERE id = ?1 RETURNING bot_id", + [rule.as_str()], + |row| parse_column(row, 0), + ) + .optional()?) + } +} + +#[cfg(test)] +mod tests { + use botloft_core::protocol::{AllowKind, AllowScope}; + + use crate::tests::Fixture; + + fn command(text: &str) -> AllowScope { + AllowScope { + tool_name: "Bash".into(), + kind: AllowKind::Command, + value: text.into(), + } + } + + #[test] + fn rules_are_kept_per_bot_once_and_can_be_removed() { + let fx = Fixture::new(); + let (scout, writer) = (&fx.bots[0].id, &fx.bots[1].id); + let first = fx + .store + .add_allow_rule(scout, &command("git status"), 10) + .expect("add"); + fx.store + .add_allow_rule(scout, &command("npm test"), 20) + .expect("add"); + let again = fx + .store + .add_allow_rule(scout, &command("git status"), 30) + .expect("again"); + assert_eq!(again, first); + let rules = fx.store.allow_rules(scout).expect("rules"); + assert_eq!( + rules + .iter() + .map(|r| r.scope.value.as_str()) + .collect::>(), + ["git status", "npm test"] + ); + assert!(fx.store.allow_rules(writer).expect("rules").is_empty()); + + assert_eq!( + fx.store.delete_allow_rule(&first.id).expect("delete"), + Some(scout.clone()) + ); + assert_eq!(fx.store.delete_allow_rule(&first.id).expect("again"), None); + assert_eq!(fx.store.allow_rules(scout).expect("rules").len(), 1); + } +} diff --git a/crates/botloft-store/src/approvals.rs b/crates/botloft-store/src/approvals.rs index b7e69d14..c0a28002 100644 --- a/crates/botloft-store/src/approvals.rs +++ b/crates/botloft-store/src/approvals.rs @@ -148,6 +148,7 @@ mod tests { input: approval.input.clone(), status: ApprovalStatus::Pending, note: None, + always: None, }), created_at: 10, updated_at: 10, diff --git a/crates/botloft-store/src/delete.rs b/crates/botloft-store/src/delete.rs index a6d21b5a..66fa7c11 100644 --- a/crates/botloft-store/src/delete.rs +++ b/crates/botloft-store/src/delete.rs @@ -13,6 +13,7 @@ const DELETE_BOT: &[&str] = &[ "DELETE FROM approvals WHERE bot_id = ?1", "DELETE FROM chat_items WHERE bot_id = ?1", "DELETE FROM browser_sites WHERE bot_id = ?1", + "DELETE FROM allow_rules WHERE bot_id = ?1", "DELETE FROM routine_runs WHERE routine_id IN (SELECT id FROM routines WHERE bot_id = ?1)", // What the bot received goes with it. "UPDATE routine_runs SET message_id = NULL \ diff --git a/crates/botloft-store/src/delete/tests.rs b/crates/botloft-store/src/delete/tests.rs index 79569386..a8207ce6 100644 --- a/crates/botloft-store/src/delete/tests.rs +++ b/crates/botloft-store/src/delete/tests.rs @@ -92,6 +92,7 @@ fn fill(fx: &Fixture) -> Owned { input: approval.input.clone(), status: ApprovalStatus::Pending, note: None, + always: None, }), created_at: 10, updated_at: 10, @@ -106,6 +107,12 @@ fn fill(fx: &Fixture) -> Owned { fx.store .allow_browser_site(scout, "example.com", 10) .expect("site"); + let always = botloft_core::protocol::AllowScope { + tool_name: "Bash".into(), + kind: botloft_core::protocol::AllowKind::Command, + value: "git status".into(), + }; + fx.store.add_allow_rule(scout, &always, 10).expect("rule"); let routine = Routine { id: RoutineId::generate(), @@ -199,6 +206,7 @@ fn a_deleted_bot_takes_what_was_its_own() { .is_empty() ); assert!(fx.store.browser_sites(&scout).expect("sites").is_empty()); + assert!(fx.store.allow_rules(&scout).expect("rules").is_empty()); let crew = fx.store.crew(&fx.crew.id).expect("crew").expect("kept"); assert_eq!(crew.lead_bot_id, None); assert_eq!(dangling(&fx.store), 0); @@ -297,6 +305,7 @@ fn a_deleted_crew_takes_its_bots_and_leaves_other_crews_alone() { "routines", "routine_runs", "browser_sites", + "allow_rules", ] { assert_eq!(count(&fx.store, table), 0, "{table}"); } diff --git a/crates/botloft-store/src/lib.rs b/crates/botloft-store/src/lib.rs index ecfab963..df5a0dd2 100644 --- a/crates/botloft-store/src/lib.rs +++ b/crates/botloft-store/src/lib.rs @@ -4,6 +4,7 @@ //! One [`Store`] wraps one connection. The daemon keeps a single store behind //! a mutex; queries are short, so there is no connection pool. +mod allow_rules; mod approvals; mod bots; mod browser_sites; diff --git a/crates/botloft-store/src/migrate.rs b/crates/botloft-store/src/migrate.rs index 76be2cca..01170315 100644 --- a/crates/botloft-store/src/migrate.rs +++ b/crates/botloft-store/src/migrate.rs @@ -20,6 +20,7 @@ const MIGRATIONS: &[(u32, &str)] = &[ (11, include_str!("../migrations/0011_turn_tokens.sql")), (12, include_str!("../migrations/0012_perf_indexes.sql")), (13, include_str!("../migrations/0013_reply_index.sql")), + (14, include_str!("../migrations/0014_allow_rules.sql")), ]; /// Schema version after every migration has run. diff --git a/crates/botloftd/src/approvals/always.rs b/crates/botloftd/src/approvals/always.rs new file mode 100644 index 00000000..b3d5ffd6 --- /dev/null +++ b/crates/botloftd/src/approvals/always.rs @@ -0,0 +1,174 @@ +//! "Allow always" (spec 10.1): which requests the owner can allow for good, +//! and whether a rule of the bot already allows one. + +use botloft_core::ids::BotId; +use botloft_core::protocol::{AllowKind, AllowScope, BotRules, ChatBody}; +use botloft_store::ApprovalRecord; +use serde_json::Value; +use tracing::warn; + +use crate::browser::sites; +use crate::state::{Daemon, Event}; + +/// Longest command a rule keeps, in characters: a longer one is a script, +/// worth reading each time. +const COMMAND_MAX: usize = 2000; + +/// What "Allow always" would cover for this request; `None` when it cannot +/// be allowed for good. +pub(crate) fn scope_of(tool: &str, input: &Value) -> Option { + let field = |name: &str| { + input + .get(name) + .and_then(Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + }; + let (kind, value) = match tool { + "Bash" | "PowerShell" => ( + AllowKind::Command, + field("command") + .filter(|command| command.chars().count() <= COMMAND_MAX)? + .to_owned(), + ), + "WebFetch" => (AllowKind::Site, sites::site_of(field("url")?)?), + "Read" | "Write" | "Edit" | "MultiEdit" => { + (AllowKind::File, field("file_path")?.to_owned()) + } + "NotebookEdit" => (AllowKind::File, field("notebook_path")?.to_owned()), + "WebSearch" => (AllowKind::Tool, String::new()), + _ => return None, + }; + Some(AllowScope { + tool_name: tool.to_owned(), + kind, + value, + }) +} + +/// Whether a rule of `bot` allows this request. +pub(crate) fn allowed(daemon: &Daemon, bot: &BotId, tool: &str, input: &Value) -> bool { + let Some(asked) = scope_of(tool, input) else { + return false; + }; + match daemon.store().allow_rules(bot) { + Ok(rules) => rules.iter().any(|rule| covers(&rule.scope, &asked)), + Err(err) => { + warn!(bot = %bot, "could not read the bot's allow rules: {err}"); + false + } + } +} + +/// Keeps what the allowed request `record` covers as a rule of its bot, +/// when it can be allowed for good. The scope is read from the chat item: +/// the stored input may be cut short. +pub(super) fn remember(daemon: &Daemon, record: &ApprovalRecord) { + let bot = &record.approval.bot_id; + let store = daemon.store(); + let scope = match store.chat_item(&record.chat_item_id) { + Ok(Some(item)) => match item.body { + ChatBody::Approval(shown) => shown.always, + _ => None, + }, + _ => None, + }; + let Some(scope) = scope else { + return; + }; + let rules = store + .add_allow_rule(bot, &scope, daemon.clock.now_ms()) + .and_then(|_| store.allow_rules(bot)); + drop(store); + match rules { + Ok(rules) => daemon.emit(Event::BotRules(BotRules { + bot_id: bot.clone(), + rules, + })), + Err(err) => warn!(bot = %bot, "could not keep an allow rule: {err}"), + } +} + +/// Whether a rule for `rule` allows a request for `asked`. A site also +/// covers its subdomains, as in the browser (spec 21.5). +fn covers(rule: &AllowScope, asked: &AllowScope) -> bool { + rule.tool_name == asked.tool_name + && rule.kind == asked.kind + && match rule.kind { + AllowKind::Site => sites::covers(&rule.value, &asked.value), + _ => rule.value == asked.value, + } +} + +#[cfg(test)] +mod tests { + use serde_json::json; + + use super::*; + + #[test] + fn commands_sites_files_and_searches_can_be_allowed_for_good() { + let command = scope_of( + "Bash", + &json!({ "command": " git status ", "description": "x" }), + ); + assert_eq!( + command.map(|scope| (scope.kind, scope.value)), + Some((AllowKind::Command, "git status".to_owned())) + ); + let site = scope_of("WebFetch", &json!({ "url": "https://www.Example.com/a?b" })); + assert_eq!( + site.map(|scope| scope.value), + Some("example.com".to_owned()) + ); + let file = scope_of("NotebookEdit", &json!({ "notebook_path": r"C:\w\a.ipynb" })); + assert_eq!(file.map(|scope| scope.kind), Some(AllowKind::File)); + let search = scope_of("WebSearch", &json!({ "query": "rust" })); + assert_eq!(search.map(|scope| scope.value), Some(String::new())); + } + + #[test] + fn plans_suggestions_long_scripts_and_unknown_tools_cannot() { + assert_eq!(scope_of("ExitPlanMode", &json!({ "plan": "# Plan" })), None); + assert_eq!( + scope_of("mcp__botloft__suggest_bot", &json!({ "name": "A" })), + None + ); + assert_eq!(scope_of("Glob", &json!({ "pattern": "*" })), None); + assert_eq!(scope_of("Bash", &json!({ "command": " " })), None); + let script = "echo 1\n".repeat(400); + assert_eq!(scope_of("Bash", &json!({ "command": script })), None); + assert_eq!( + scope_of("WebFetch", &json!({ "url": "file:///C:/a" })), + None + ); + } + + #[test] + fn a_rule_covers_the_same_request_and_a_site_its_subdomains() { + let rule = |tool: &str, kind, value: &str| AllowScope { + tool_name: tool.into(), + kind, + value: value.into(), + }; + let git = rule("Bash", AllowKind::Command, "git status"); + assert!(covers(&git, &git)); + assert!(!covers( + &git, + &rule("Bash", AllowKind::Command, "git status --short") + )); + assert!(!covers( + &git, + &rule("PowerShell", AllowKind::Command, "git status") + )); + let site = rule("WebFetch", AllowKind::Site, "example.com"); + assert!(covers( + &site, + &rule("WebFetch", AllowKind::Site, "docs.example.com") + )); + assert!(!covers( + &site, + &rule("WebFetch", AllowKind::Site, "badexample.com") + )); + } +} diff --git a/crates/botloftd/src/approvals/mod.rs b/crates/botloftd/src/approvals/mod.rs index 20e19588..d63edd52 100644 --- a/crates/botloftd/src/approvals/mod.rs +++ b/crates/botloftd/src/approvals/mod.rs @@ -3,6 +3,7 @@ //! MCP call waits here until the owner answers, the request times out, or //! the bot's process ends. +mod always; mod prompt; use std::collections::HashMap; @@ -12,7 +13,7 @@ use botloft_core::chat::{ROUTINE_TOOL, SUGGEST_TOOL, clip, tool_input_max, tool_ use botloft_core::command::tool_explanation; use botloft_core::ids::{ApprovalId, BotId, ChatItemId}; use botloft_core::protocol::{ - Approval, ApprovalItem, ApprovalStatus, ApprovalsAnswerParams, ChatBody, + AllowScope, Approval, ApprovalItem, ApprovalStatus, ApprovalsAnswerParams, ChatBody, }; use botloft_store::ApprovalRecord; use serde_json::Value; @@ -166,6 +167,9 @@ pub fn answer(daemon: &Daemon, params: ApprovalsAnswerParams) -> ApiResult) -> ApprovalItem { +fn item_of( + approval: &Approval, + explanation: Option, + always: Option, +) -> ApprovalItem { ApprovalItem { approval_id: approval.id.clone(), tool_name: approval.tool_name.clone(), @@ -254,6 +266,7 @@ fn item_of(approval: &Approval, explanation: Option) -> ApprovalItem { input: approval.input.clone(), status: approval.status, note: approval.note.clone(), + always, } } @@ -285,13 +298,14 @@ fn settle( fn show(daemon: &Daemon, record: &ApprovalRecord) { let item: &ChatItemId = &record.chat_item_id; - // The bot's explanation is kept only in the chat item. + // The bot's explanation and what "Allow always" covers are kept only + // in the chat item. let shown = daemon.store().chat_item(item).ok().flatten(); - let explanation = match shown.map(|shown| shown.body) { - Some(ChatBody::Approval(shown)) => shown.explanation, - _ => None, + let (explanation, always) = match shown.map(|shown| shown.body) { + Some(ChatBody::Approval(shown)) => (shown.explanation, shown.always), + _ => (None, None), }; - let shown = item_of(&record.approval, explanation); + let shown = item_of(&record.approval, explanation, always); items::update(daemon, item, ChatBody::Approval(shown)); } diff --git a/crates/botloftd/src/approvals/prompt.rs b/crates/botloftd/src/approvals/prompt.rs index f9a9af59..d2a07a58 100644 --- a/crates/botloftd/src/approvals/prompt.rs +++ b/crates/botloftd/src/approvals/prompt.rs @@ -8,7 +8,7 @@ use botloft_core::protocol::{ChatBody, ToolStatus}; use serde_json::{Value, json}; use tracing::debug; -use super::{Answer, ask}; +use super::{Answer, always, ask}; use crate::state::Daemon; /// How long to wait for the `tool_use` event that the request is about; @@ -33,6 +33,11 @@ pub async fn prompt(daemon: &Daemon, bot: &BotId, generation: u64, args: PromptA debug!(bot = %bot, "permission request for no running tool; denied"); return deny("There is no pending tool call with that id."); } + // The owner said once that this one needs no asking. + if always::allowed(daemon, bot, &args.tool_name, &args.input) { + debug!(bot = %bot, tool = %args.tool_name, "allowed by a rule of the bot"); + return json!({ "behavior": "allow", "updatedInput": args.input }).to_string(); + } let asked = ask( daemon, bot, diff --git a/crates/botloftd/src/rpc/dispatch.rs b/crates/botloftd/src/rpc/dispatch.rs index cc614c41..4efbab10 100644 --- a/crates/botloftd/src/rpc/dispatch.rs +++ b/crates/botloftd/src/rpc/dispatch.rs @@ -9,7 +9,7 @@ use super::jsonrpc::{RpcError, empty_params}; use crate::approvals; use crate::service::{ self, ApiResult, archive, attachments, bots, chat, crews, delete, deliveries, files, lead, - messages, models, modes, routines, screens, settings, tasks, usage, + messages, models, modes, routines, rules, screens, settings, tasks, usage, }; use crate::state::Daemon; @@ -45,6 +45,8 @@ pub fn dispatch(daemon: &Daemon, name: &str, params: Option) -> Result reply(bots::restart(daemon, parse(params)?)), method::CHAT_HISTORY => reply(chat::history(daemon, parse(params)?)), method::APPROVALS_ANSWER => reply(approvals::answer(daemon, parse(params)?)), + method::RULES_LIST => reply(rules::list(daemon, parse(params)?)), + method::RULES_DELETE => reply(rules::delete(daemon, parse(params)?)), method::MESSAGES_SEND => reply(messages::send(daemon, parse(params)?)), method::MESSAGES_LIST => reply(messages::list(daemon, parse(params)?)), method::ATTACHMENTS_READ => reply(attachments::read(daemon, parse(params)?)), diff --git a/crates/botloftd/src/rpc/mod.rs b/crates/botloftd/src/rpc/mod.rs index f6888b10..f9be90d6 100644 --- a/crates/botloftd/src/rpc/mod.rs +++ b/crates/botloftd/src/rpc/mod.rs @@ -275,6 +275,7 @@ fn to_notification(event: &Event) -> String { Event::FolderRecycled(folder) => note(notification::FOLDER_RECYCLED, folder), Event::BotState(state) => note(notification::BOT_STATE, state), Event::BotContext(context) => note(notification::BOT_CONTEXT, context), + Event::BotRules(rules) => note(notification::BOT_RULES, rules), Event::ChatItem(item) => note(notification::CHAT_ITEM, item), Event::ChatDelta(delta) => note(notification::CHAT_DELTA, delta), Event::MessageCreated(message) => note(notification::MESSAGE_CREATED, message), diff --git a/crates/botloftd/src/rpc/watching.rs b/crates/botloftd/src/rpc/watching.rs index e9e665f5..b6da53ad 100644 --- a/crates/botloftd/src/rpc/watching.rs +++ b/crates/botloftd/src/rpc/watching.rs @@ -117,6 +117,7 @@ impl Watch { allow: true, note: None, input: None, + always: None, }; // It may have been answered in the chat a moment before. let _ = approvals::answer(daemon, done); diff --git a/crates/botloftd/src/service/mod.rs b/crates/botloftd/src/service/mod.rs index bfbd3113..8ffb2a7b 100644 --- a/crates/botloftd/src/service/mod.rs +++ b/crates/botloftd/src/service/mod.rs @@ -14,6 +14,7 @@ pub mod messages; pub mod models; pub mod modes; pub mod routines; +pub mod rules; pub mod screens; pub mod settings; pub mod tasks; diff --git a/crates/botloftd/src/service/rules.rs b/crates/botloftd/src/service/rules.rs new file mode 100644 index 00000000..fe19fd21 --- /dev/null +++ b/crates/botloftd/src/service/rules.rs @@ -0,0 +1,31 @@ +//! What each bot may do without asking (spec 10.1): `rules.list` and +//! `rules.delete`. Rules are added by answering a request with "Allow +//! always" (`approvals.answer`). + +use botloft_core::protocol::{AllowRule, BotRules, RuleIdParams, RulesListParams}; + +use super::{ApiError, ApiResult}; +use crate::state::{Daemon, Event}; + +pub fn list(daemon: &Daemon, params: RulesListParams) -> ApiResult> { + let store = daemon.store(); + if store.bot(¶ms.bot_id)?.is_none() { + return Err(ApiError::NotFound(format!("bot {}", params.bot_id))); + } + Ok(store.allow_rules(¶ms.bot_id)?) +} + +/// Removes a rule: the bot asks for that again. +pub fn delete(daemon: &Daemon, params: RuleIdParams) -> ApiResult { + let store = daemon.store(); + let bot_id = store + .delete_allow_rule(¶ms.rule_id)? + .ok_or_else(|| ApiError::NotFound(format!("rule {}", params.rule_id)))?; + let rules = BotRules { + rules: store.allow_rules(&bot_id)?, + bot_id, + }; + drop(store); + daemon.emit(Event::BotRules(rules.clone())); + Ok(rules) +} diff --git a/crates/botloftd/src/state.rs b/crates/botloftd/src/state.rs index a9fcaafa..db50c52a 100644 --- a/crates/botloftd/src/state.rs +++ b/crates/botloftd/src/state.rs @@ -5,9 +5,9 @@ use std::sync::{Arc, Mutex, MutexGuard, TryLockError}; use std::time::Instant; use botloft_core::protocol::{ - AccountUsage, Bot, BotContextChanged, BotDeleted, BotStateChanged, BrowserAction, BrowserState, - ChatDelta, ChatItemChanged, Crew, CrewDeleted, Delivery, FolderRecycled, Message, Routine, - RoutineRun, ScreenDraft, Task, + AccountUsage, Bot, BotContextChanged, BotDeleted, BotRules, BotStateChanged, BrowserAction, + BrowserState, ChatDelta, ChatItemChanged, Crew, CrewDeleted, Delivery, FolderRecycled, Message, + Routine, RoutineRun, ScreenDraft, Task, }; use botloft_store::Store; use tokio::runtime::{Handle, RuntimeFlavor}; @@ -40,6 +40,7 @@ pub enum Event { FolderRecycled(FolderRecycled), BotState(BotStateChanged), BotContext(BotContextChanged), + BotRules(BotRules), ChatItem(ChatItemChanged), ChatDelta(ChatDelta), MessageCreated(Message), diff --git a/crates/botloftd/tests/allow_always.rs b/crates/botloftd/tests/allow_always.rs new file mode 100644 index 00000000..9a909b9f --- /dev/null +++ b/crates/botloftd/tests/allow_always.rs @@ -0,0 +1,166 @@ +//! "Allow always" (spec 10.1): the owner allows a request for good, the bot +//! stops asking for that one, and removing the rule brings the question +//! back. + +mod common; + +use std::time::Duration; + +use common::bots::{Crew, two_bots}; +use common::mcp::Mcp; +use common::stream; +use serde_json::{Value, json}; + +/// The next request that waits for the owner. +async fn next_request(app: &mut common::Client) -> Value { + loop { + let changed = app.notification("chat.item").await; + let body = &changed["item"]["body"]; + if body["kind"] == "approval" && body["status"] == "pending" { + return changed; + } + } +} + +/// A crew whose @lead is in a turn, ready to use tools. +async fn working() -> (Crew, Value) { + let mut c = two_bots().await; + let lead = c.app.call("bots.list", json!({})).await.expect("bots")[0].clone(); + c.app + .call( + "messages.send", + json!({ "botId": lead["id"], "body": "Go" }), + ) + .await + .expect("send"); + let lines = c.lead_process.wait_lines(1).await; + let line = lines.last().expect("line"); + c.lead_process.emit(stream::replay(line)).await; + (c, lead) +} + +/// @lead calls `tool` with `input` and Claude Code asks for permission; the +/// decision comes out of the handle. +async fn ask(c: &Crew, id: &str, tool: &str, input: &Value) -> tokio::task::JoinHandle { + c.lead_process + .emit(stream::tool_use(id, tool, input.clone())) + .await; + let mut mcp = Mcp::new(c.lead.addr, c.lead.token.clone()); + let args = json!({ "tool_name": tool, "input": input, "tool_use_id": id }); + tokio::spawn(async move { mcp.tool("permission_prompt", args).await.expect("decision") }) +} + +/// The decision, which must come without the owner. +async fn by_itself(decision: tokio::task::JoinHandle) -> Value { + tokio::time::timeout(Duration::from_secs(10), decision) + .await + .expect("allowed without asking") + .expect("task") +} + +#[tokio::test] +async fn allowed_always_the_bot_stops_asking_until_the_rule_goes() { + let (mut c, lead) = working().await; + let status = json!({ "command": "git status", "description": "Shows what changed" }); + let decision = ask(&c, "toolu_1", "Bash", &status).await; + let pending = next_request(&mut c.app).await; + let body = &pending["item"]["body"]; + assert_eq!( + body["always"], + json!({ "toolName": "Bash", "kind": "command", "value": "git status" }) + ); + c.app + .call( + "approvals.answer", + json!({ "approvalId": body["approvalId"], "allow": true, "always": true }), + ) + .await + .expect("answer"); + assert_eq!(decision.await.expect("task")["behavior"], "allow"); + let rules = c.app.notification("bot.rules").await; + assert_eq!(rules["botId"], lead["id"]); + assert_eq!(rules["rules"][0]["scope"]["value"], "git status"); + + // The same command again: allowed without a request. + let again = ask(&c, "toolu_2", "Bash", &status).await; + assert_eq!( + by_itself(again).await, + json!({ "behavior": "allow", "updatedInput": status }) + ); + + // Another command still asks. + let other = json!({ "command": "git push" }); + let _pushing = ask(&c, "toolu_3", "Bash", &other).await; + let asked = next_request(&mut c.app).await; + assert_eq!(asked["item"]["body"]["summary"], "git push"); + + let listed = c + .app + .call("rules.list", json!({ "botId": lead["id"] })) + .await + .expect("list"); + assert_eq!(listed.as_array().map(Vec::len), Some(1)); + let left = c + .app + .call("rules.delete", json!({ "ruleId": listed[0]["id"] })) + .await + .expect("delete"); + assert_eq!(left, json!({ "botId": lead["id"], "rules": [] })); + + // Without the rule, it asks again. + let _status = ask(&c, "toolu_4", "Bash", &status).await; + loop { + let asked = next_request(&mut c.app).await; + if asked["item"]["body"]["summary"] == "git status" { + break; + } + } +} + +#[tokio::test] +async fn a_site_rule_covers_its_pages_and_a_plain_allow_keeps_none() { + let (mut c, lead) = working().await; + let page = json!({ "url": "https://docs.example.com/a", "prompt": "Read it" }); + let decision = ask(&c, "toolu_1", "WebFetch", &page).await; + let pending = next_request(&mut c.app).await; + let body = &pending["item"]["body"]; + assert_eq!(body["always"]["value"], "docs.example.com"); + c.app + .call( + "approvals.answer", + json!({ "approvalId": body["approvalId"], "allow": true, "always": true }), + ) + .await + .expect("answer"); + decision.await.expect("task"); + let other_page = json!({ "url": "https://docs.example.com/b?x=1", "prompt": "And this" }); + let again = ask(&c, "toolu_2", "WebFetch", &other_page).await; + assert_eq!(by_itself(again).await["behavior"], "allow"); + + // A search can be allowed for good too, but allowed once it is not. + let search = json!({ "query": "rust" }); + let decision = ask(&c, "toolu_3", "WebSearch", &search).await; + let pending = next_request(&mut c.app).await; + let body = &pending["item"]["body"]; + assert_eq!(body["always"]["kind"], "tool"); + c.app + .call( + "approvals.answer", + json!({ "approvalId": body["approvalId"], "allow": true }), + ) + .await + .expect("answer"); + decision.await.expect("task"); + let listed = c + .app + .call("rules.list", json!({ "botId": lead["id"] })) + .await + .expect("list"); + assert_eq!(listed.as_array().map(Vec::len), Some(1)); + + // A plan has no "Allow always". + let plan = json!({ "plan": "# Do it" }); + let _planning = ask(&c, "toolu_4", "ExitPlanMode", &plan).await; + let pending = next_request(&mut c.app).await; + assert_eq!(pending["item"]["body"]["always"], Value::Null); +} diff --git a/docs/spec.md b/docs/spec.md index da59c325..6a473ef0 100644 --- a/docs/spec.md +++ b/docs/spec.md @@ -548,7 +548,17 @@ Com `--permission-prompt-tool mcp__botloft__permission_prompt`, toda ferramenta 4. Se o processo do bot morrer ou o daemon reiniciar com a aprovação aberta, ela vira `expired`. 5. O bot volta a `busy` e o item do chat é atualizado. -"Permitir sempre" (gravar uma regra para o bot) fica para depois do MVP. +**Permitir sempre.** Um pedido que se repete (o mesmo comando, páginas do mesmo site) pode ser permitido de vez, e o bot para de perguntar por ele. A regra é do bot, não da crew, e fica em `allow_rules`. + +- **O que cobre.** O daemon decide pela ferramenta e pela entrada (`AllowScope {toolName, kind, value}`), e o item `approval` leva isso em `always` (`null` quando o pedido não pode ser permitido de vez): + - `Bash` e `PowerShell`: aquele comando exato, sem os espaços das pontas (`command`). Comando com mais de 2 000 caracteres é um script, que vale ler a cada vez: não tem "sempre". + - `WebFetch`: o site do endereço, como no navegador (21.5): o host em minúsculas sem `www.`, que cobre também os subdomínios (`site`). + - `Read`, `Write`, `Edit`, `MultiEdit` e `NotebookEdit`: aquele arquivo, pelo caminho (`file`). + - `WebSearch`: toda busca (`tool`, com `value` vazio). + - Nada mais: plano, sugestão de bot, pedido de rotina, navegador e ferramentas desconhecidas pedem sempre. +- **Gravar.** `approvals.answer` com `allow` e `always: true` grava a regra (a mesma de novo não duplica) e avisa com `bot.rules {botId, rules}`. O escopo vem do item do chat, não da entrada gravada, que pode estar cortada. +- **Usar.** Ao receber `permission_prompt`, depois de conferir que a ferramenta está em `running`, o daemon procura uma regra do bot que cubra o pedido. Achando, permite na hora, sem item `approval`, sem `needs_approval` e sem incomodar o dono; o item `tool` do chat mostra o que rodou, como sempre. +- **Desfazer.** `rules.list {botId}` lista as regras, da mais antiga à mais nova, e `rules.delete {ruleId}` tira uma, devolvendo as que ficaram (e avisando com `bot.rules`). Sem a regra, o bot volta a perguntar. Excluir o bot (7.6) leva as regras dele. **Explicação do comando.** Um pedido para rodar um comando mostrava só o comando, e quem não lê comandos permitia sem saber o que era. A entrada do `Bash` e do `PowerShell` traz, ao lado de `command`, um campo opcional `description`, escrito pelo modelo, que chega igual à tool de aprovação (19). O daemon o passa adiante como `explanation` do item `tool` e do item `approval` (8.2): uma linha, até 300 caracteres; ausente se o campo não veio, veio vazio ou só repete o comando. @@ -612,7 +622,9 @@ Endpoint: `ws://127.0.0.1:45710/rpc`. Mensagens seguem JSON-RPC 2.0: requests co | `bots.archive` | `botId` | `Bot` | | `bots.delete` | `botId, recycleFolder?` | `{botId, crewId}`; exclui o bot, ativo ou arquivado; com `recycleFolder`, a pasta dele vai depois para a Lixeira (7.6) | | `chat.history` | `botId, before?, limit?` | `ChatItem[]`, mais novo primeiro; `limit` de 1 a 200, 50 se ausente | -| `approvals.answer` | `approvalId, allow, note?, input?` (`input`: a sugestão de bot como o dono a deixou, 10.2) | `Approval` | +| `approvals.answer` | `approvalId, allow, note?, input?, always?` (`input`: a sugestão de bot como o dono a deixou, 10.2; `always`: com `allow`, grava o `always` do pedido como regra do bot, 10.1) | `Approval` | +| `rules.list` | `botId` | `AllowRule[]`: o que o bot faz sem perguntar, da regra mais antiga à mais nova (10.1) | +| `rules.delete` | `ruleId` | `{botId, rules}`: as regras que ficaram; o bot volta a perguntar pelo que saiu (10.1) | | `messages.send` | `botId, body, attachments?` (`[{name, mediaType, data}]`, data em base64) | `Message` | | `messages.list` | `crewId?, botId?, before?, limit?` | `Message[]` | | `attachments.read` | `attachmentId` | `{mediaType, data}`, data em base64 (9.5) | @@ -631,7 +643,7 @@ Endpoint: `ws://127.0.0.1:45710/rpc`. Mensagens seguem JSON-RPC 2.0: requests co ### 11.3 Notificações do servidor -`bot.state`, `bot.changed`, `bot.context` (8.6), `bot.deleted`, `crew.changed`, `crew.deleted`, `folder.recycled` (7.6), `chat.item`, `chat.delta`, `message.created`, `delivery.changed`, `task.changed`, das rotinas `routine.changed` e `routine.run` (20.8), e do navegador `browser.changed`, `browser.action` e, só para quem assiste, `browser.frame` (21.7), e das telas `screen.draft` (22.3). +`bot.state`, `bot.changed`, `bot.context` (8.6), `bot.rules` (10.1), `bot.deleted`, `crew.changed`, `crew.deleted`, `folder.recycled` (7.6), `chat.item`, `chat.delta`, `message.created`, `delivery.changed`, `task.changed`, das rotinas `routine.changed` e `routine.run` (20.8), e do navegador `browser.changed`, `browser.action` e, só para quem assiste, `browser.frame` (21.7), e das telas `screen.draft` (22.3). ### 11.4 Erros @@ -647,7 +659,7 @@ Além desses, os códigos padrão do JSON-RPC: `-32700` (JSON inválido), `-3260 ## 12. Dados (SQLite) -Pragmas: `journal_mode=WAL`, `synchronous=NORMAL`, `foreign_keys=ON`, `busy_timeout=5000`, `temp_store=MEMORY`, cache de 16 MB e `PRAGMA optimize` ao abrir. Com WAL, `NORMAL` não corrompe o banco; uma queda de energia pode perder os últimos commits, mas nenhum commit espera o disco (com `FULL`, cada item de chat pagava um flush no Windows). As consultas quentes passam pelo cache de statements do rusqlite, e toda coluna que aponta para outra tabela tem índice: as tabelas nunca encolhem, e sem índice a exclusão varre a tabela filha uma vez por linha apagada. Migrations numeradas em `botloft-store/migrations/NNNN_nome.sql`, versão em `PRAGMA user_version`. Tempo em milissegundos Unix (`INTEGER`). Arquivamento é lógico (`archived_at`). Exclusão (7.6) apaga as linhas: as chaves estrangeiras não têm `ON DELETE`, então o daemon apaga ou solta, na ordem e numa transação só, tudo que aponta para o bot (`approvals`, `chat_items`, `browser_sites`, `routine_runs`, `attachments`, `deliveries`, as `messages` recebidas, `routines`, `tasks`), e zera `messages.from_bot_id`, `messages.task_id`, `tasks.origin_task_id` e `crews.lead_bot_id` onde apontavam para o que saiu. Uma crew sai depois dos bots dela. +Pragmas: `journal_mode=WAL`, `synchronous=NORMAL`, `foreign_keys=ON`, `busy_timeout=5000`, `temp_store=MEMORY`, cache de 16 MB e `PRAGMA optimize` ao abrir. Com WAL, `NORMAL` não corrompe o banco; uma queda de energia pode perder os últimos commits, mas nenhum commit espera o disco (com `FULL`, cada item de chat pagava um flush no Windows). As consultas quentes passam pelo cache de statements do rusqlite, e toda coluna que aponta para outra tabela tem índice: as tabelas nunca encolhem, e sem índice a exclusão varre a tabela filha uma vez por linha apagada. Migrations numeradas em `botloft-store/migrations/NNNN_nome.sql`, versão em `PRAGMA user_version`. Tempo em milissegundos Unix (`INTEGER`). Arquivamento é lógico (`archived_at`). Exclusão (7.6) apaga as linhas: as chaves estrangeiras não têm `ON DELETE`, então o daemon apaga ou solta, na ordem e numa transação só, tudo que aponta para o bot (`approvals`, `chat_items`, `browser_sites`, `allow_rules`, `routine_runs`, `attachments`, `deliveries`, as `messages` recebidas, `routines`, `tasks`), e zera `messages.from_bot_id`, `messages.task_id`, `tasks.origin_task_id` e `crews.lead_bot_id` onde apontavam para o que saiu. Uma crew sai depois dos bots dela. | Tabela | Colunas principais | |---|---| @@ -662,6 +674,7 @@ Pragmas: `journal_mode=WAL`, `synchronous=NORMAL`, `foreign_keys=ON`, `busy_time | `settings` | `key, value` | | `routines`, `routine_runs` | seção 20.7; `messages` ganha `routine_id` | | `browser_sites` | `bot_id, host, allowed_at`: sites que o dono deixou o bot usar no navegador (21.5) | +| `allow_rules` | `id (rul_), bot_id, tool_name, kind, value, created_at`, única por `(bot_id, tool_name, kind, value)`: o que o dono permitiu de vez para o bot (10.1) | Índices mínimos: `deliveries(state, next_attempt_at)`, `messages(crew_id, created_at)`, `tasks(assignee_bot_id, status)`, `bots(crew_id)`, `chat_items(bot_id, id)`, `chat_items(created_at) WHERE kind = 'turn'` (8.7), `attachments(message_id)`. @@ -775,8 +788,8 @@ Layout, como um app de mensagens: - **Chefe:** uma coroa ao lado do nome na barra lateral e nos cartões da crew, e "Chefe" no cabeçalho do bot, com a dica "Lidera : planeja o trabalho e sugere bots novos". O menu do bot tem "Tornar chefe da equipe" ou "Deixar de ser chefe". - **Excluir** (7.6): "Excluir bot" é a última ação do menu do bot (no cabeçalho e no clique direito) e "Excluir equipe" a última do menu da crew, as duas em vermelho, depois de Arquivar. A confirmação leva o nome no título ("Excluir Scout?") e diz, em palavras simples, o que sai e o que fica: o bot para agora e sai do Botloft de vez, com a conversa, as rotinas e as tarefas de que fazia parte, e não dá para desfazer; se ele é o chefe, que a equipe fica sem chefe; e, num quadro à parte, que a pasta dele continua no computador, com o caminho para copiar. A da crew diz quantos bots param e saem com ela, e mostra a pasta de trabalho, lembrando que a pasta de cada bot também fica. No mesmo quadro, uma caixa desmarcada: "Mandar esta pasta para a Lixeira" (na crew, "Mandar as pastas da equipe para a Lixeira"). Marcada, o texto do quadro passa a dizer que a pasta vai para a Lixeira, de onde ainda dá para recuperar, e a crew mostra a pasta dela no Botloft em vez da `shared\`; se o dono escolheu a pasta de trabalho, o quadro diz que a pasta de cada bot vai e a escolhida fica onde está. O app manda `recycleFolder` só com a caixa marcada. Se a pasta não pôde ir (`folder.recycled` com `error`), um aviso diz que ela não foi para a Lixeira e continua lá, com o caminho e o motivo. Confirmado, o app tira o bot ou a crew da tela sem esperar o aviso do daemon; se falhar, um aviso diz que não foi possível e nada muda. Excluído o bot aberto, fica a página da crew; excluída a crew aberta, nenhuma fica escolhida (sem crews, a tela de boas-vindas). Uma message de um bot que saiu, arquivado ou excluído, aparece na conversa e na timeline como de "um bot que não está mais na equipe". - **Sugestão de bot** (10.2): um cartão no chat do chefe, " sugere um bot novo", com o porquê e os campos editáveis Nome, Modelo, Função e Instruções, a linha de que o bot começa na hora e recebe o trabalho do chefe, um campo para dizer ao chefe por que não, e os botões Criar bot e Agora não. Respondido, vira uma linha ("Você criou Designer", "Você recusou Designer") que abre o que foi sugerido. -- **Pedido de permissão** (10.1): um cartão no chat, " quer " ("Scout quer rodar um comando"), com Permitir, Negar e um campo para dizer ao bot por que não. Num comando, o texto principal é a explicação do bot, em letra comum; embaixo, menor, " escreveu isto. O que roda de verdade é o comando abaixo.", e "Ver o comando", uma seta que abre o comando inteiro, em letra de código e com as quebras de linha. Sem explicação, o cartão diz " não disse para que serve este comando. Na dúvida, negue e pergunte." e já vem com o comando aberto. Um comando grande demais para guardar aparece até onde coube, com o aviso de que está cortado. Nas outras ferramentas, o resumo (o arquivo, a URL) e a entrada inteira em "Detalhes". Respondido, o cartão vira uma linha ("Permitido: rodar um comando") com a explicação ou, sem ela, o comando. A linha da ferramenta acima do cartão segue a mesma ideia: "Rodar um comando" e a explicação do bot (sem ela, o comando), e um clique abre o comando e a saída; a lista de conversas mostra o mesmo texto. -- **Detalhes do bot** (pasta, instruções, sessão) ficam num painel, fora do caminho da conversa. Detalhes e arquivos são painéis laterais que o dono redimensiona arrastando a borda esquerda (ou com as setas do teclado, Shift anda mais; duplo clique volta ao padrão): 256 a 900 px, sempre deixando ao chat ao menos 22rem, e a largura de cada um fica no `localStorage` (`botloft.panel.details`, `botloft.panel.files`, `botloft.panel.browser`, `botloft.panel.screens`). O chat encolhe com eles e nunca passa por baixo nem por cima do painel. +- **Pedido de permissão** (10.1): um cartão no chat, " quer " ("Scout quer rodar um comando"), com Permitir, Negar e um campo para dizer ao bot por que não. Num comando, o texto principal é a explicação do bot, em letra comum; embaixo, menor, " escreveu isto. O que roda de verdade é o comando abaixo.", e "Ver o comando", uma seta que abre o comando inteiro, em letra de código e com as quebras de linha. Sem explicação, o cartão diz " não disse para que serve este comando. Na dúvida, negue e pergunte." e já vem com o comando aberto. Um comando grande demais para guardar aparece até onde coube, com o aviso de que está cortado. Nas outras ferramentas, o resumo (o arquivo, a URL) e a entrada inteira em "Detalhes". Quando o pedido tem `always` (10.1), entre Permitir e Negar fica um terceiro botão que diz o que cobre: "Permitir sempre este comando", "Permitir sempre em example.com", "Permitir sempre neste arquivo" ou só "Permitir sempre"; a dica dele avisa que o bot não vai pedir aquilo de novo e que dá para desfazer nos detalhes. Respondido, o cartão vira uma linha ("Permitido: rodar um comando") com a explicação ou, sem ela, o comando. A linha da ferramenta acima do cartão segue a mesma ideia: "Rodar um comando" e a explicação do bot (sem ela, o comando), e um clique abre o comando e a saída; a lista de conversas mostra o mesmo texto. +- **Detalhes do bot** (pasta, instruções, sessão) ficam num painel, fora do caminho da conversa. No fim dele, "Permitido sem perguntar" lista as regras do bot (10.1): a ação no idioma do dono ("Rodar um comando") e, embaixo, o comando, o site ou o arquivo, cada uma com um X que faz o bot voltar a perguntar. A lista é lida ao abrir o painel e segue `bot.rules`; vazia, diz que "Permitir sempre" coloca as coisas ali. Detalhes e arquivos são painéis laterais que o dono redimensiona arrastando a borda esquerda (ou com as setas do teclado, Shift anda mais; duplo clique volta ao padrão): 256 a 900 px, sempre deixando ao chat ao menos 22rem, e a largura de cada um fica no `localStorage` (`botloft.panel.details`, `botloft.panel.files`, `botloft.panel.browser`, `botloft.panel.screens`). O chat encolhe com eles e nunca passa por baixo nem por cima do painel. - **Arquivos do bot** (8.4): um botão "Mostrar arquivos" no cabeçalho abre, à direita do chat, um painel com o que o bot fez, do mais novo ao mais antigo (ícone e cor pelo tipo, nome, pasta, tamanho, "há 5 min"). Cada tipo tem seu ícone num quadrado da sua cor, para Word, planilha e PDF se distinguirem de longe: PDF vermelho, Word azul, planilha verde, apresentação laranja, imagem roxa, áudio e vídeo rosa, compactado âmbar, código azul-petróleo, texto e o resto cinza. O tipo vem primeiro da extensão (os `.doc`, `.xls`, `.ppt` e OpenDocument chegam como `application/octet-stream`) e depois do media type. Um clique mostra o arquivo no próprio painel: imagem, PDF, markdown e texto aparecem; o resto diz que não tem prévia. "Abrir" usa o programa que o Windows escolheu e "Mostrar na pasta" abre o Explorer com o arquivo marcado. A lista é lida ao abrir o bot, a cada 4 s enquanto ele trabalha com o painel aberto e a janela à vista, e de novo quando ele para: cada leitura percorre as pastas do bot, então ninguém paga por ela sem olhar a lista. Com o painel fechado, o contador abaixo se atualiza quando o turno acaba. Com o painel fechado, o botão ganha um contador dos arquivos que apareceram desde a última vez que o dono olhou; abertos, esses levam a etiqueta "Novo". No chat, a linha de uma ferramenta que mudou um arquivo (`Write`, `Edit`...; item `tool` com `file`, 8.2) e não falhou ganha o botão "Ver em arquivos", que abre o painel direto na prévia desse arquivo; se ele não existe mais, o painel diz. Detalhes, arquivos, o navegador (21.8) e as telas (22.5) dividem o mesmo lugar: abrir um fecha o outro. O painel abre deslizando da borda direita (460 ms, sem pressa no começo) e fecha voltando para ela (360 ms) antes de sair, com o conteúdo na largura final o tempo todo e o chat acompanhando; quem troca de painel começa da largura do anterior. Fechando, o painel já não recebe clique nem leitor de tela. Com movimento reduzido, abre e fecha de uma vez. O painel segue o que o bot começa a fazer, para o dono ver acontecer: o navegador abre quando o bot sobe o navegador ou pede uma mão (21.8), as telas quando ele começa a escrever uma (22.5), no lugar do painel que estiver aberto e uma vez por começo, a menos que o dono tenha desligado isso nas Configurações. Fechado pelo dono, fica fechado, com o ponto no botão. O navegador nas mãos do dono (21.10) nunca é trocado. O painel volta com o bot: o app guarda, por bot e até fechar (no store, não no `localStorage`), o painel que ficou ao lado do chat, aberto pelo dono ou pelo bot, e trocar de bot ou ir à página da crew e voltar o mostra de novo, já aberto, sem deslizar. Um painel que o dono fechou volta fechado. Só o painel é guardado: o arquivo ou a tela em foco dentro dele e o navegador nas mãos do dono, que é devolvido ao trocar de bot (21.10), não. Voltar conta como abrir o bot: um navegador que o bot abriu nesse meio-tempo fica atrás do botão, e o que abre sozinho ao abrir um bot (um pedido de mão ainda sem resposta, uma tela que ele ainda escreve) e o que ele começa dali em diante entram no lugar do painel que voltou. ### 15.2 Comandos Tauri @@ -965,7 +978,7 @@ M2 a M4 foram entregues com ConPTY, terminal com replay, inbox por named pipe e 1. Rotinas: horário semanal, intervalo ou cron, com fuso, sobreposição e horários perdidos. Desenho na seção 20. 2. Caixa de perguntas ao owner (bot pergunta, owner responde, resposta volta como mensagem). -3. "Permitir sempre" nas aprovações, gravado como regra do bot. +3. "Permitir sempre" nas aprovações, gravado como regra do bot. Feito (10.1). 4. Busca FTS5 em mensagens e no chat. 5. Histórico de versões das instruções do bot. 6. Acesso remoto com token por dispositivo (Tailscale).