From e7c9b9a749523d258a5129c674e3ab4298ed8503 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sat, 3 Jan 2026 23:44:38 +0100 Subject: [PATCH] feat: add sessions tools and send policy --- docs/configuration.md | 12 +- docs/session-tool.md | 109 ++++++ docs/session.md | 26 ++ docs/tools.md | 13 + src/agents/clawdis-tools.sessions.test.ts | 159 ++++++++ src/agents/clawdis-tools.ts | 452 +++++++++++++++++++++- src/agents/tool-display.json | 15 + src/auto-reply/reply.triggers.test.ts | 33 ++ src/auto-reply/reply.ts | 51 +++ src/auto-reply/send-policy.ts | 29 ++ src/commands/agent.ts | 14 + src/config/config.ts | 40 ++ src/config/sessions.ts | 2 + src/cron/isolated-agent.ts | 1 + src/gateway/protocol/schema.ts | 7 + src/gateway/server-bridge.ts | 23 ++ src/gateway/server-methods.ts | 67 +++- src/gateway/server.chat.test.ts | 90 +++++ src/gateway/server.sessions.test.ts | 9 + src/gateway/session-utils.ts | 8 + src/gateway/test-helpers.ts | 8 +- src/sessions/send-policy.test.ts | 53 +++ src/sessions/send-policy.ts | 79 ++++ src/tui/gateway-chat.ts | 8 + 24 files changed, 1304 insertions(+), 4 deletions(-) create mode 100644 docs/session-tool.md create mode 100644 src/agents/clawdis-tools.sessions.test.ts create mode 100644 src/auto-reply/send-policy.ts create mode 100644 src/sessions/send-policy.test.ts create mode 100644 src/sessions/send-policy.ts diff --git a/docs/configuration.md b/docs/configuration.md index ed1d32484..831a2a494 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -620,11 +620,21 @@ Controls session scoping, idle expiry, reset triggers, and where the session sto idleMinutes: 60, resetTriggers: ["/new", "/reset"], store: "~/.clawdis/sessions/sessions.json", - mainKey: "main" + mainKey: "main", + sendPolicy: { + rules: [ + { action: "deny", match: { surface: "discord", chatType: "group" } } + ], + default: "allow" + } } } ``` +Fields: +- `sendPolicy.default`: `allow` or `deny` fallback when no rule matches. +- `sendPolicy.rules[]`: match by `surface` (provider), `chatType` (`direct|group|room`), or `keyPrefix` (e.g. `cron:`). First deny wins; otherwise allow. + ### `skills` (skills config) Controls bundled allowlist, install preferences, extra skill folders, and per-skill diff --git a/docs/session-tool.md b/docs/session-tool.md new file mode 100644 index 000000000..f48b33fd7 --- /dev/null +++ b/docs/session-tool.md @@ -0,0 +1,109 @@ +--- +summary: "Agent session tools for listing sessions, fetching history, and sending cross-session messages" +read_when: + - Adding or modifying session tools +--- + +# Session Tools + +Goal: small, hard-to-misuse tool surface so agents can list sessions, fetch history, and send to another session. + +## Tool Names +- `sessions_list` +- `sessions_history` +- `sessions_send` + +## Key Model +- Main direct chat bucket is always the literal key `"main"`. +- Group chats use `surface:group:` or `surface:channel:`. +- Cron jobs use `cron:`. +- Hooks use `hook:` unless explicitly set. +- Node bridge uses `node-` unless explicitly set. + +`global` and `unknown` are internal-only and never listed. If `session.scope = "global"`, we alias it to `main` for all tools so callers never see `global`. + +## sessions_list +List sessions as an array of rows. + +Parameters: +- `kinds?: string[]` filter: any of `"main" | "group" | "cron" | "hook" | "node" | "other"` +- `limit?: number` max rows (default: server default, clamp e.g. 200) +- `activeMinutes?: number` only sessions updated within N minutes +- `messageLimit?: number` 0 = no messages (default 0); >0 = include last N messages + +Behavior: +- `messageLimit > 0` fetches `chat.history` per session and includes the last N messages. +- Tool results are filtered out in list output; use `sessions_history` for tool messages. + +Row shape (JSON): +- `key`: session key (string) +- `kind`: `main | group | cron | hook | node | other` +- `provider`: `whatsapp | telegram | discord | signal | imessage | webchat | internal | unknown` +- `displayName` (group display label if available) +- `updatedAt` (ms) +- `sessionId` +- `model`, `contextTokens`, `totalTokens` +- `thinkingLevel`, `verboseLevel`, `systemSent`, `abortedLastRun` +- `sendPolicy` (session override if set) +- `lastChannel`, `lastTo` +- `transcriptPath` (best-effort path derived from store dir + sessionId) +- `messages?` (only when `messageLimit > 0`) + +## sessions_history +Fetch transcript for one session. + +Parameters: +- `sessionKey` (required) +- `limit?: number` max messages (server clamps) +- `includeTools?: boolean` (default false) + +Behavior: +- `includeTools=false` filters `role: "toolResult"` messages. +- Returns messages array in the raw transcript format. + +## sessions_send +Send a message into another session. + +Parameters: +- `sessionKey` (required) +- `message` (required) +- `timeoutSeconds?: number` (default >0; 0 = fire-and-forget) + +Behavior: +- `timeoutSeconds = 0`: enqueue and return `{ runId, status: "accepted" }`. +- `timeoutSeconds > 0`: wait up to N seconds for completion, then return `{ runId, status: "ok", reply }`. +- If wait times out: `{ runId, status: "timeout", error }`. Run continues; call `sessions_history` later. +- If the run fails: `{ runId, status: "error", error }`. + +## Provider Field +- For groups, `provider` is the `surface` recorded on the session entry. +- For direct chats, `provider` maps from `lastChannel`. +- For cron/hook/node, `provider` is `internal`. +- If missing, `provider` is `unknown`. + +## Security / Send Policy +Policy-based blocking by surface/chat type (not per session id). + +```json +{ + "session": { + "sendPolicy": { + "rules": [ + { + "match": { "surface": "discord", "chatType": "group" }, + "action": "deny" + } + ], + "default": "allow" + } + } +} +``` + +Runtime override (per session entry): +- `sendPolicy: "allow" | "deny"` (unset = inherit config) +- Settable via `sessions.patch` or owner-only `/send on|off|inherit`. + +Enforcement points: +- `chat.send` / `agent` (gateway) +- auto-reply delivery logic diff --git a/docs/session.md b/docs/session.md index 8430819fa..6e71e3ef1 100644 --- a/docs/session.md +++ b/docs/session.md @@ -26,12 +26,38 @@ All session state is **owned by the gateway** (the “master” Clawdis). UI cli - Multiple phone numbers can map to that same key; they act as transports into the same conversation. - Group chats isolate state with `surface:group:` keys (rooms/channels use `surface:channel:`); do not reuse the primary key for groups. (Discord display names show `discord:#`.) - Legacy `group::` and `group:` keys are still recognized. +- Other sources: + - Cron jobs: `cron:` + - Webhooks: `hook:` (unless explicitly set by the hook) + - Node bridge runs: `node-` ## Lifecyle - Idle expiry: `session.idleMinutes` (default 60). After the timeout a new `sessionId` is minted on the next message. - Reset triggers: exact `/new` or `/reset` (plus any extras in `resetTriggers`) start a fresh session id and pass the remainder of the message through. If `/new` or `/reset` is sent alone, Clawdis runs a short “hello” greeting turn to confirm the reset. - Manual reset: delete specific keys from the store or remove the JSONL transcript; the next message recreates them. +## Send policy (optional) +Block delivery for specific session types without listing individual ids. + +```json5 +{ + session: { + sendPolicy: { + rules: [ + { action: "deny", match: { surface: "discord", chatType: "group" } }, + { action: "deny", match: { keyPrefix: "cron:" } } + ], + default: "allow" + } + } +} +``` + +Runtime override (owner only): +- `/send on` → allow for this session +- `/send off` → deny for this session +- `/send inherit` → clear override and use config rules + ## Configuration (optional rename example) ```json5 // ~/.clawdis/clawdis.json diff --git a/docs/tools.md b/docs/tools.md index 99e866a04..25157e3b9 100644 --- a/docs/tools.md +++ b/docs/tools.md @@ -105,6 +105,19 @@ Core actions: Notes: - Use `delayMs` (defaults to 2000) to avoid interrupting an in-flight reply. +### `sessions_list` / `sessions_history` / `sessions_send` +List sessions, inspect transcript history, or send to another session. + +Core parameters: +- `sessions_list`: `kinds?`, `limit?`, `activeMinutes?`, `messageLimit?` (0 = none) +- `sessions_history`: `sessionKey`, `limit?`, `includeTools?` +- `sessions_send`: `sessionKey`, `message`, `timeoutSeconds?` (0 = fire-and-forget) + +Notes: +- `main` is the canonical direct-chat key; global/unknown are hidden. +- `messageLimit > 0` fetches last N messages per session (tool messages filtered). +- `sessions_send` waits for final completion when `timeoutSeconds > 0`. + ### `discord` Send Discord reactions, stickers, or polls. diff --git a/src/agents/clawdis-tools.sessions.test.ts b/src/agents/clawdis-tools.sessions.test.ts new file mode 100644 index 000000000..bdfa9df8b --- /dev/null +++ b/src/agents/clawdis-tools.sessions.test.ts @@ -0,0 +1,159 @@ +import { describe, expect, it, vi } from "vitest"; + +const callGatewayMock = vi.fn(); + +vi.mock("../gateway/call.js", () => ({ + callGateway: (opts: unknown) => callGatewayMock(opts), +})); + +vi.mock("../config/config.js", () => ({ + loadConfig: () => ({ + session: { mainKey: "main", scope: "per-sender" }, + }), + resolveGatewayPort: () => 18789, +})); + +import { createClawdisTools } from "./clawdis-tools.js"; + +describe("sessions tools", () => { + it("sessions_list filters kinds and includes messages", async () => { + callGatewayMock.mockImplementation(async (opts: any) => { + if (opts.method === "sessions.list") { + return { + path: "/tmp/sessions.json", + sessions: [ + { + key: "main", + kind: "direct", + sessionId: "s-main", + updatedAt: 10, + lastChannel: "whatsapp", + }, + { + key: "discord:group:dev", + kind: "group", + sessionId: "s-group", + updatedAt: 11, + surface: "discord", + displayName: "discord:g-dev", + }, + { + key: "cron:job-1", + kind: "direct", + sessionId: "s-cron", + updatedAt: 9, + }, + { key: "global", kind: "global" }, + { key: "unknown", kind: "unknown" }, + ], + }; + } + if (opts.method === "chat.history") { + return { + messages: [ + { role: "toolResult", content: [] }, + { + role: "assistant", + content: [{ type: "text", text: "hi" }], + }, + ], + }; + } + return {}; + }); + + const tool = createClawdisTools().find( + (candidate) => candidate.name === "sessions_list", + ); + expect(tool).toBeDefined(); + if (!tool) throw new Error("missing sessions_list tool"); + + const result = await tool.execute("call1", { messageLimit: 1 }); + const details = result.details as { sessions?: any[] }; + expect(details.sessions).toHaveLength(3); + const main = details.sessions?.find((s) => s.key === "main"); + expect(main?.provider).toBe("whatsapp"); + expect(main?.messages?.length).toBe(1); + expect(main?.messages?.[0]?.role).toBe("assistant"); + + const cronOnly = await tool.execute("call2", { kinds: ["cron"] }); + const cronDetails = cronOnly.details as { sessions?: any[] }; + expect(cronDetails.sessions).toHaveLength(1); + expect(cronDetails.sessions?.[0]?.kind).toBe("cron"); + }); + + it("sessions_history filters tool messages by default", async () => { + callGatewayMock.mockImplementation(async (opts: any) => { + if (opts.method === "chat.history") { + return { + messages: [ + { role: "toolResult", content: [] }, + { role: "assistant", content: [{ type: "text", text: "ok" }] }, + ], + }; + } + return {}; + }); + + const tool = createClawdisTools().find( + (candidate) => candidate.name === "sessions_history", + ); + expect(tool).toBeDefined(); + if (!tool) throw new Error("missing sessions_history tool"); + + const result = await tool.execute("call3", { sessionKey: "main" }); + const details = result.details as { messages?: any[] }; + expect(details.messages).toHaveLength(1); + expect(details.messages?.[0]?.role).toBe("assistant"); + + const withTools = await tool.execute("call4", { + sessionKey: "main", + includeTools: true, + }); + const withToolsDetails = withTools.details as { messages?: any[] }; + expect(withToolsDetails.messages).toHaveLength(2); + }); + + it("sessions_send supports fire-and-forget and wait", async () => { + callGatewayMock.mockImplementation(async (opts: any) => { + if (opts.method === "agent") { + return opts.expectFinal + ? { runId: "run-1", status: "ok" } + : { runId: "run-1", status: "accepted" }; + } + if (opts.method === "chat.history") { + return { + messages: [ + { role: "assistant", content: [{ type: "text", text: "done" }] }, + ], + }; + } + return {}; + }); + + const tool = createClawdisTools().find( + (candidate) => candidate.name === "sessions_send", + ); + expect(tool).toBeDefined(); + if (!tool) throw new Error("missing sessions_send tool"); + + const fire = await tool.execute("call5", { + sessionKey: "main", + message: "ping", + timeoutSeconds: 0, + }); + expect(fire.details).toMatchObject({ status: "accepted", runId: "run-1" }); + + const waitPromise = tool.execute("call6", { + sessionKey: "main", + message: "wait", + timeoutSeconds: 5, + }); + const waited = await waitPromise; + expect(waited.details).toMatchObject({ + status: "ok", + runId: "run-1", + reply: "done", + }); + }); +}); diff --git a/src/agents/clawdis-tools.ts b/src/agents/clawdis-tools.ts index 15b333270..e42eed5ce 100644 --- a/src/agents/clawdis-tools.ts +++ b/src/agents/clawdis-tools.ts @@ -1,5 +1,6 @@ import crypto from "node:crypto"; import fs from "node:fs/promises"; +import path from "node:path"; import type { AgentTool, AgentToolResult } from "@mariozechner/pi-agent-core"; import { Type } from "@sinclair/typebox"; @@ -40,7 +41,11 @@ import { writeScreenRecordToFile, } from "../cli/nodes-screen.js"; import { parseDurationMs } from "../cli/parse-duration.js"; -import { type DiscordActionConfig, loadConfig } from "../config/config.js"; +import { + type ClawdisConfig, + type DiscordActionConfig, + loadConfig, +} from "../config/config.js"; import { addRoleDiscord, banMemberDiscord, @@ -208,6 +213,126 @@ function jsonResult(payload: unknown): AgentToolResult { }; } +type SessionKind = "main" | "group" | "cron" | "hook" | "node" | "other"; +type SessionListRow = { + key: string; + kind: SessionKind; + provider: string; + displayName?: string; + updatedAt?: number | null; + sessionId?: string; + model?: string; + contextTokens?: number | null; + totalTokens?: number | null; + thinkingLevel?: string; + verboseLevel?: string; + systemSent?: boolean; + abortedLastRun?: boolean; + sendPolicy?: string; + lastChannel?: string; + lastTo?: string; + transcriptPath?: string; + messages?: unknown[]; +}; + +function normalizeKey(value?: string) { + const trimmed = value?.trim(); + return trimmed ? trimmed : undefined; +} + +function resolveMainSessionAlias(cfg: ClawdisConfig) { + const mainKey = normalizeKey(cfg.session?.mainKey) ?? "main"; + const scope = cfg.session?.scope ?? "per-sender"; + const alias = scope === "global" ? "global" : mainKey; + return { mainKey, alias, scope }; +} + +function resolveDisplaySessionKey(params: { + key: string; + alias: string; + mainKey: string; +}) { + if (params.key === params.alias) return "main"; + if (params.key === params.mainKey) return "main"; + return params.key; +} + +function resolveInternalSessionKey(params: { + key: string; + alias: string; + mainKey: string; +}) { + if (params.key === "main") return params.alias; + return params.key; +} + +function classifySessionKind(params: { + key: string; + gatewayKind?: string | null; + alias: string; + mainKey: string; +}): SessionKind { + const key = params.key; + if (key === params.alias || key === params.mainKey) return "main"; + if (key.startsWith("cron:")) return "cron"; + if (key.startsWith("hook:")) return "hook"; + if (key.startsWith("node-") || key.startsWith("node:")) return "node"; + if (params.gatewayKind === "group") return "group"; + if ( + key.startsWith("group:") || + key.includes(":group:") || + key.includes(":channel:") + ) { + return "group"; + } + return "other"; +} + +function deriveProvider(params: { + key: string; + kind: SessionKind; + surface?: string | null; + lastChannel?: string | null; +}): string { + if (params.kind === "cron" || params.kind === "hook" || params.kind === "node") + return "internal"; + const surface = normalizeKey(params.surface ?? undefined); + if (surface) return surface; + const lastChannel = normalizeKey(params.lastChannel ?? undefined); + if (lastChannel) return lastChannel; + const parts = params.key.split(":").filter(Boolean); + if (parts.length >= 3 && (parts[1] === "group" || parts[1] === "channel")) { + return parts[0]; + } + return "unknown"; +} + +function stripToolMessages(messages: unknown[]): unknown[] { + return messages.filter((msg) => { + if (!msg || typeof msg !== "object") return true; + const role = (msg as { role?: unknown }).role; + return role !== "toolResult"; + }); +} + +function extractAssistantText(message: unknown): string | undefined { + if (!message || typeof message !== "object") return undefined; + if ((message as { role?: unknown }).role !== "assistant") return undefined; + const content = (message as { content?: unknown }).content; + if (!Array.isArray(content)) return undefined; + const chunks: string[] = []; + for (const block of content) { + if (!block || typeof block !== "object") continue; + if ((block as { type?: unknown }).type !== "text") continue; + const text = (block as { text?: unknown }).text; + if (typeof text === "string" && text.trim()) { + chunks.push(text); + } + } + const joined = chunks.join("").trim(); + return joined ? joined : undefined; +} + async function imageResult(params: { label: string; path: string; @@ -2308,6 +2433,328 @@ function createGatewayTool(): AnyAgentTool { }; } +const SessionsListToolSchema = Type.Object({ + kinds: Type.Optional(Type.Array(Type.String())), + limit: Type.Optional(Type.Integer({ minimum: 1 })), + activeMinutes: Type.Optional(Type.Integer({ minimum: 1 })), + messageLimit: Type.Optional(Type.Integer({ minimum: 0 })), +}); + +const SessionsHistoryToolSchema = Type.Object({ + sessionKey: Type.String(), + limit: Type.Optional(Type.Integer({ minimum: 1 })), + includeTools: Type.Optional(Type.Boolean()), +}); + +const SessionsSendToolSchema = Type.Object({ + sessionKey: Type.String(), + message: Type.String(), + timeoutSeconds: Type.Optional(Type.Integer({ minimum: 0 })), +}); + +function createSessionsListTool(): AnyAgentTool { + return { + label: "Sessions", + name: "sessions_list", + description: "List sessions with optional filters and last messages.", + parameters: SessionsListToolSchema, + execute: async (_toolCallId, args) => { + const params = args as Record; + const cfg = loadConfig(); + const { mainKey, alias } = resolveMainSessionAlias(cfg); + + const kindsRaw = readStringArrayParam(params, "kinds")?.map((value) => + value.trim().toLowerCase(), + ); + const allowedKindsList = (kindsRaw ?? []).filter((value) => + ["main", "group", "cron", "hook", "node", "other"].includes(value), + ); + const allowedKinds = allowedKindsList.length + ? new Set(allowedKindsList) + : undefined; + + const limit = + typeof params.limit === "number" && Number.isFinite(params.limit) + ? Math.max(1, Math.floor(params.limit)) + : undefined; + const activeMinutes = + typeof params.activeMinutes === "number" && + Number.isFinite(params.activeMinutes) + ? Math.max(1, Math.floor(params.activeMinutes)) + : undefined; + const messageLimitRaw = + typeof params.messageLimit === "number" && + Number.isFinite(params.messageLimit) + ? Math.max(0, Math.floor(params.messageLimit)) + : 0; + const messageLimit = Math.min(messageLimitRaw, 20); + + const list = (await callGateway({ + method: "sessions.list", + params: { + limit, + activeMinutes, + includeGlobal: true, + includeUnknown: true, + }, + })) as { + path?: string; + sessions?: Array>; + }; + + const sessions = Array.isArray(list?.sessions) ? list.sessions : []; + const storePath = + typeof list?.path === "string" ? list.path : undefined; + const rows: SessionListRow[] = []; + + for (const entry of sessions) { + if (!entry || typeof entry !== "object") continue; + const key = typeof entry.key === "string" ? entry.key : ""; + if (!key) continue; + if (key === "unknown") continue; + if (key === "global" && alias !== "global") continue; + + const gatewayKind = + typeof entry.kind === "string" ? entry.kind : undefined; + const kind = classifySessionKind({ key, gatewayKind, alias, mainKey }); + if (allowedKinds && !allowedKinds.has(kind)) continue; + + const displayKey = resolveDisplaySessionKey({ + key, + alias, + mainKey, + }); + + const surface = + typeof entry.surface === "string" ? entry.surface : undefined; + const lastChannel = + typeof entry.lastChannel === "string" ? entry.lastChannel : undefined; + const provider = deriveProvider({ + key, + kind, + surface, + lastChannel, + }); + + const sessionId = + typeof entry.sessionId === "string" ? entry.sessionId : undefined; + const transcriptPath = + sessionId && storePath + ? path.join(path.dirname(storePath), `${sessionId}.jsonl`) + : undefined; + + const row: SessionListRow = { + key: displayKey, + kind, + provider, + displayName: + typeof entry.displayName === "string" + ? entry.displayName + : undefined, + updatedAt: + typeof entry.updatedAt === "number" ? entry.updatedAt : undefined, + sessionId, + model: typeof entry.model === "string" ? entry.model : undefined, + contextTokens: + typeof entry.contextTokens === "number" + ? entry.contextTokens + : undefined, + totalTokens: + typeof entry.totalTokens === "number" + ? entry.totalTokens + : undefined, + thinkingLevel: + typeof entry.thinkingLevel === "string" + ? entry.thinkingLevel + : undefined, + verboseLevel: + typeof entry.verboseLevel === "string" + ? entry.verboseLevel + : undefined, + systemSent: + typeof entry.systemSent === "boolean" ? entry.systemSent : undefined, + abortedLastRun: + typeof entry.abortedLastRun === "boolean" + ? entry.abortedLastRun + : undefined, + sendPolicy: + typeof entry.sendPolicy === "string" ? entry.sendPolicy : undefined, + lastChannel, + lastTo: typeof entry.lastTo === "string" ? entry.lastTo : undefined, + transcriptPath, + }; + + if (messageLimit > 0) { + const resolvedKey = resolveInternalSessionKey({ + key: displayKey, + alias, + mainKey, + }); + const history = (await callGateway({ + method: "chat.history", + params: { sessionKey: resolvedKey, limit: messageLimit }, + })) as { messages?: unknown[] }; + const rawMessages = Array.isArray(history?.messages) + ? history.messages + : []; + const filtered = stripToolMessages(rawMessages); + row.messages = + filtered.length > messageLimit + ? filtered.slice(-messageLimit) + : filtered; + } + + rows.push(row); + } + + return jsonResult({ + count: rows.length, + sessions: rows, + }); + }, + }; +} + +function createSessionsHistoryTool(): AnyAgentTool { + return { + label: "Session History", + name: "sessions_history", + description: "Fetch message history for a session.", + parameters: SessionsHistoryToolSchema, + execute: async (_toolCallId, args) => { + const params = args as Record; + const sessionKey = readStringParam(params, "sessionKey", { + required: true, + }); + const cfg = loadConfig(); + const { mainKey, alias } = resolveMainSessionAlias(cfg); + const resolvedKey = resolveInternalSessionKey({ + key: sessionKey, + alias, + mainKey, + }); + const limit = + typeof params.limit === "number" && Number.isFinite(params.limit) + ? Math.max(1, Math.floor(params.limit)) + : undefined; + const includeTools = Boolean(params.includeTools); + const result = (await callGateway({ + method: "chat.history", + params: { sessionKey: resolvedKey, limit }, + })) as { messages?: unknown[] }; + const rawMessages = Array.isArray(result?.messages) + ? result.messages + : []; + const messages = includeTools + ? rawMessages + : stripToolMessages(rawMessages); + return jsonResult({ + sessionKey: resolveDisplaySessionKey({ + key: sessionKey, + alias, + mainKey, + }), + messages, + }); + }, + }; +} + +function createSessionsSendTool(): AnyAgentTool { + return { + label: "Session Send", + name: "sessions_send", + description: "Send a message into another session.", + parameters: SessionsSendToolSchema, + execute: async (_toolCallId, args) => { + const params = args as Record; + const sessionKey = readStringParam(params, "sessionKey", { + required: true, + }); + const message = readStringParam(params, "message", { required: true }); + const cfg = loadConfig(); + const { mainKey, alias } = resolveMainSessionAlias(cfg); + const resolvedKey = resolveInternalSessionKey({ + key: sessionKey, + alias, + mainKey, + }); + const timeoutSeconds = + typeof params.timeoutSeconds === "number" && + Number.isFinite(params.timeoutSeconds) + ? Math.max(0, Math.floor(params.timeoutSeconds)) + : 30; + const idempotencyKey = crypto.randomUUID(); + try { + const response = (await callGateway({ + method: "agent", + params: { + message, + sessionKey: resolvedKey, + idempotencyKey, + deliver: false, + }, + expectFinal: timeoutSeconds > 0, + timeoutMs: timeoutSeconds > 0 ? timeoutSeconds * 1000 : undefined, + })) as { runId?: string; status?: string }; + + const runId = + typeof response?.runId === "string" && response.runId + ? response.runId + : idempotencyKey; + + if (timeoutSeconds === 0) { + return jsonResult({ + runId, + status: "accepted", + sessionKey: resolveDisplaySessionKey({ + key: sessionKey, + alias, + mainKey, + }), + }); + } + + const history = (await callGateway({ + method: "chat.history", + params: { sessionKey: resolvedKey, limit: 50 }, + })) as { messages?: unknown[] }; + const filtered = stripToolMessages( + Array.isArray(history?.messages) ? history.messages : [], + ); + const last = + filtered.length > 0 ? filtered[filtered.length - 1] : undefined; + const reply = last ? extractAssistantText(last) : undefined; + + return jsonResult({ + runId, + status: "ok", + reply, + sessionKey: resolveDisplaySessionKey({ + key: sessionKey, + alias, + mainKey, + }), + }); + } catch (err) { + const message = + err instanceof Error ? err.message : String(err ?? "error"); + const isTimeout = message.toLowerCase().includes("timeout"); + return jsonResult({ + runId: idempotencyKey, + status: isTimeout ? "timeout" : "error", + error: message, + sessionKey: resolveDisplaySessionKey({ + key: sessionKey, + alias, + mainKey, + }), + }); + } + }, + }; +} + export function createClawdisTools(options?: { browserControlUrl?: string; }): AnyAgentTool[] { @@ -2318,5 +2765,8 @@ export function createClawdisTools(options?: { createCronTool(), createDiscordTool(), createGatewayTool(), + createSessionsListTool(), + createSessionsHistoryTool(), + createSessionsSendTool(), ]; } diff --git a/src/agents/tool-display.json b/src/agents/tool-display.json index b6a28f60f..a3834ef2f 100644 --- a/src/agents/tool-display.json +++ b/src/agents/tool-display.json @@ -150,6 +150,21 @@ "restart": { "label": "restart", "detailKeys": ["reason", "delayMs"] } } }, + "sessions_list": { + "emoji": "🗂️", + "title": "Sessions", + "detailKeys": ["kinds", "limit", "activeMinutes", "messageLimit"] + }, + "sessions_history": { + "emoji": "🧾", + "title": "Session History", + "detailKeys": ["sessionKey", "limit"] + }, + "sessions_send": { + "emoji": "📨", + "title": "Session Send", + "detailKeys": ["sessionKey", "timeoutSeconds"] + }, "whatsapp_login": { "emoji": "🟢", "title": "WhatsApp Login", diff --git a/src/auto-reply/reply.triggers.test.ts b/src/auto-reply/reply.triggers.test.ts index ef85f92fd..5cdcd2a3d 100644 --- a/src/auto-reply/reply.triggers.test.ts +++ b/src/auto-reply/reply.triggers.test.ts @@ -107,6 +107,39 @@ describe("trigger handling", () => { }); }); + it("allows owner to set send policy", async () => { + await withTempHome(async (home) => { + const cfg = { + agent: { + model: "anthropic/claude-opus-4-5", + workspace: join(home, "clawd"), + }, + whatsapp: { + allowFrom: ["+1000"], + }, + session: { store: join(home, "sessions.json") }, + }; + + const res = await getReplyFromConfig( + { + Body: "/send off", + From: "+1000", + To: "+2000", + Surface: "whatsapp", + SenderE164: "+1000", + }, + {}, + cfg, + ); + const text = Array.isArray(res) ? res[0]?.text : res?.text; + expect(text).toContain("Send policy set to off"); + + const storeRaw = await fs.readFile(cfg.session.store, "utf-8"); + const store = JSON.parse(storeRaw) as Record; + expect(store.main?.sendPolicy).toBe("deny"); + }); + }); + it("returns a context overflow fallback when the embedded agent throws", async () => { await withTempHome(async (home) => { vi.mocked(runEmbeddedPiAgent).mockRejectedValue( diff --git a/src/auto-reply/reply.ts b/src/auto-reply/reply.ts index ebd840e4f..21325aabc 100644 --- a/src/auto-reply/reply.ts +++ b/src/auto-reply/reply.ts @@ -55,6 +55,7 @@ import { } from "../infra/system-events.js"; import { clearCommandLane, getQueueSize } from "../process/command-queue.js"; import { defaultRuntime } from "../runtime.js"; +import { resolveSendPolicy } from "../sessions/send-policy.js"; import { normalizeE164 } from "../utils.js"; import { resolveHeartbeatSeconds } from "../web/reconnect.js"; import { getWebAuthAgeMs, webAuthExists } from "../web/session.js"; @@ -63,6 +64,7 @@ import { normalizeGroupActivation, parseActivationCommand, } from "./group-activation.js"; +import { parseSendPolicyCommand } from "./send-policy.js"; import { stripHeartbeatToken } from "./heartbeat.js"; import { extractModelDirective } from "./model.js"; import { buildStatusMessage } from "./status.js"; @@ -986,6 +988,7 @@ export async function getReplyFromConfig( verboseLevel: persistedVerbose ?? baseEntry?.verboseLevel, modelOverride: persistedModelOverride ?? baseEntry?.modelOverride, providerOverride: persistedProviderOverride ?? baseEntry?.providerOverride, + sendPolicy: baseEntry?.sendPolicy, queueMode: baseEntry?.queueMode, queueDebounceMs: baseEntry?.queueDebounceMs, queueCap: baseEntry?.queueCap, @@ -1587,6 +1590,7 @@ export async function getReplyFromConfig( ? stripMentions(rawBodyNormalized, ctx, cfg) : rawBodyNormalized; const activationCommand = parseActivationCommand(commandBodyNormalized); + const sendPolicyCommand = parseSendPolicyCommand(commandBodyNormalized); const senderE164 = normalizeE164(ctx.SenderE164 ?? ""); const ownerCandidates = isWhatsAppSurface ? (allowFrom ?? []).filter((entry) => entry && entry !== "*") @@ -1633,6 +1637,38 @@ export async function getReplyFromConfig( }; } + if (sendPolicyCommand.hasCommand) { + if (!isOwnerSender) { + logVerbose( + `Ignoring /send from non-owner: ${senderE164 || ""}`, + ); + cleanupTyping(); + return undefined; + } + if (!sendPolicyCommand.mode) { + cleanupTyping(); + return { text: "⚙️ Usage: /send on|off|inherit" }; + } + if (sessionEntry && sessionStore && sessionKey) { + if (sendPolicyCommand.mode === "inherit") { + delete sessionEntry.sendPolicy; + } else { + sessionEntry.sendPolicy = sendPolicyCommand.mode; + } + sessionEntry.updatedAt = Date.now(); + sessionStore[sessionKey] = sessionEntry; + await saveSessionStore(storePath, sessionStore); + } + cleanupTyping(); + const label = + sendPolicyCommand.mode === "inherit" + ? "inherit" + : sendPolicyCommand.mode === "allow" + ? "on" + : "off"; + return { text: `⚙️ Send policy set to ${label}.` }; + } + if ( commandBodyNormalized === "/restart" || commandBodyNormalized === "restart" || @@ -1710,6 +1746,21 @@ export async function getReplyFromConfig( return { text: "⚙️ Agent was aborted." }; } + const sendPolicy = resolveSendPolicy({ + cfg, + entry: sessionEntry, + sessionKey, + surface: sessionEntry?.surface ?? surface, + chatType: sessionEntry?.chatType, + }); + if (sendPolicy === "deny") { + logVerbose( + `Send blocked by policy for session ${sessionKey ?? "unknown"}`, + ); + cleanupTyping(); + return undefined; + } + const isFirstTurnInSession = isNewSession || !systemSent; const isGroupChat = sessionCtx.ChatType === "group"; const wasMentioned = ctx.WasMentioned === true; diff --git a/src/auto-reply/send-policy.ts b/src/auto-reply/send-policy.ts new file mode 100644 index 000000000..4b4ad6dbe --- /dev/null +++ b/src/auto-reply/send-policy.ts @@ -0,0 +1,29 @@ +export type SendPolicyOverride = "allow" | "deny"; + +export function normalizeSendPolicyOverride( + raw?: string | null, +): SendPolicyOverride | undefined { + const value = raw?.trim().toLowerCase(); + if (!value) return undefined; + if (value === "allow" || value === "on") return "allow"; + if (value === "deny" || value === "off") return "deny"; + return undefined; +} + +export function parseSendPolicyCommand(raw?: string): { + hasCommand: boolean; + mode?: SendPolicyOverride | "inherit"; +} { + if (!raw) return { hasCommand: false }; + const trimmed = raw.trim(); + if (!trimmed) return { hasCommand: false }; + const match = trimmed.match(/^\/?send\b(?:\s+([a-zA-Z]+))?/i); + if (!match) return { hasCommand: false }; + const token = match[1]?.trim().toLowerCase(); + if (!token) return { hasCommand: true }; + if (token === "inherit" || token === "default" || token === "reset") { + return { hasCommand: true, mode: "inherit" }; + } + const mode = normalizeSendPolicyOverride(token); + return { hasCommand: true, mode }; +} diff --git a/src/commands/agent.ts b/src/commands/agent.ts index e05e45a64..1300f493b 100644 --- a/src/commands/agent.ts +++ b/src/commands/agent.ts @@ -42,6 +42,7 @@ import { registerAgentRunContext, } from "../infra/agent-events.js"; import { defaultRuntime, type RuntimeEnv } from "../runtime.js"; +import { resolveSendPolicy } from "../sessions/send-policy.js"; import { resolveTelegramToken } from "../telegram/token.js"; import { normalizeE164 } from "../utils.js"; @@ -212,6 +213,19 @@ export async function agentCommand( registerAgentRunContext(sessionId, { sessionKey }); } + if (opts.deliver === true) { + const sendPolicy = resolveSendPolicy({ + cfg, + entry: sessionEntry, + sessionKey, + surface: sessionEntry?.surface, + chatType: sessionEntry?.chatType, + }); + if (sendPolicy === "deny") { + throw new Error("send blocked by session policy"); + } + } + let resolvedThinkLevel = thinkOnce ?? thinkOverride ?? diff --git a/src/config/config.ts b/src/config/config.ts index 81f4cf10e..de8ddb5ce 100644 --- a/src/config/config.ts +++ b/src/config/config.ts @@ -19,6 +19,20 @@ export const isNixMode = process.env.CLAWDIS_NIX_MODE === "1"; export type ReplyMode = "text" | "command"; export type SessionScope = "per-sender" | "global"; export type ReplyToMode = "off" | "first" | "all"; +export type SessionSendPolicyAction = "allow" | "deny"; +export type SessionSendPolicyMatch = { + surface?: string; + chatType?: "direct" | "group" | "room"; + keyPrefix?: string; +}; +export type SessionSendPolicyRule = { + action: SessionSendPolicyAction; + match?: SessionSendPolicyMatch; +}; +export type SessionSendPolicyConfig = { + default?: SessionSendPolicyAction; + rules?: SessionSendPolicyRule[]; +}; export type SessionConfig = { scope?: SessionScope; @@ -28,6 +42,7 @@ export type SessionConfig = { store?: string; typingIntervalSeconds?: number; mainKey?: string; + sendPolicy?: SessionSendPolicyConfig; }; export type LoggingConfig = { @@ -853,6 +868,31 @@ const SessionSchema = z store: z.string().optional(), typingIntervalSeconds: z.number().int().positive().optional(), mainKey: z.string().optional(), + sendPolicy: z + .object({ + default: z.union([z.literal("allow"), z.literal("deny")]).optional(), + rules: z + .array( + z.object({ + action: z.union([z.literal("allow"), z.literal("deny")]), + match: z + .object({ + surface: z.string().optional(), + chatType: z + .union([ + z.literal("direct"), + z.literal("group"), + z.literal("room"), + ]) + .optional(), + keyPrefix: z.string().optional(), + }) + .optional(), + }), + ) + .optional(), + }) + .optional(), }) .optional(); diff --git a/src/config/sessions.ts b/src/config/sessions.ts index fdc742663..357220a23 100644 --- a/src/config/sessions.ts +++ b/src/config/sessions.ts @@ -34,6 +34,7 @@ export type SessionEntry = { modelOverride?: string; groupActivation?: "mention" | "always"; groupActivationNeedsSystemIntro?: boolean; + sendPolicy?: "allow" | "deny"; queueMode?: | "steer" | "followup" @@ -320,6 +321,7 @@ export async function updateLastRoute(params: { verboseLevel: existing?.verboseLevel, providerOverride: existing?.providerOverride, modelOverride: existing?.modelOverride, + sendPolicy: existing?.sendPolicy, queueMode: existing?.queueMode, inputTokens: existing?.inputTokens, outputTokens: existing?.outputTokens, diff --git a/src/cron/isolated-agent.ts b/src/cron/isolated-agent.ts index 23ec478a5..7b9fabbf2 100644 --- a/src/cron/isolated-agent.ts +++ b/src/cron/isolated-agent.ts @@ -150,6 +150,7 @@ function resolveCronSession(params: { verboseLevel: entry?.verboseLevel, model: entry?.model, contextTokens: entry?.contextTokens, + sendPolicy: entry?.sendPolicy, lastChannel: entry?.lastChannel, lastTo: entry?.lastTo, }; diff --git a/src/gateway/protocol/schema.ts b/src/gateway/protocol/schema.ts index 2db637eef..465a6a61c 100644 --- a/src/gateway/protocol/schema.ts +++ b/src/gateway/protocol/schema.ts @@ -298,6 +298,13 @@ export const SessionsPatchParamsSchema = Type.Object( thinkingLevel: Type.Optional(Type.Union([NonEmptyString, Type.Null()])), verboseLevel: Type.Optional(Type.Union([NonEmptyString, Type.Null()])), model: Type.Optional(Type.Union([NonEmptyString, Type.Null()])), + sendPolicy: Type.Optional( + Type.Union([ + Type.Literal("allow"), + Type.Literal("deny"), + Type.Null(), + ]), + ), groupActivation: Type.Optional( Type.Union([ Type.Literal("mention"), diff --git a/src/gateway/server-bridge.ts b/src/gateway/server-bridge.ts index fb8623a8f..6bcc53468 100644 --- a/src/gateway/server-bridge.ts +++ b/src/gateway/server-bridge.ts @@ -33,6 +33,7 @@ import { type SessionEntry, saveSessionStore, } from "../config/sessions.js"; +import { normalizeSendPolicy } from "../sessions/send-policy.js"; import { loadVoiceWakeConfig, setVoiceWakeTriggers, @@ -443,6 +444,25 @@ export function createBridgeHandlers(ctx: BridgeHandlersContext) { } } + if ("sendPolicy" in p) { + const raw = p.sendPolicy; + if (raw === null) { + delete next.sendPolicy; + } else if (raw !== undefined) { + const normalized = normalizeSendPolicy(String(raw)); + if (!normalized) { + return { + ok: false, + error: { + code: ErrorCodes.INVALID_REQUEST, + message: 'invalid sendPolicy (use "allow"|"deny")', + }, + }; + } + next.sendPolicy = normalized; + } + } + if ("groupActivation" in p) { const raw = p.groupActivation; if (raw === null) { @@ -507,6 +527,7 @@ export function createBridgeHandlers(ctx: BridgeHandlersContext) { verboseLevel: entry?.verboseLevel, model: entry?.model, contextTokens: entry?.contextTokens, + sendPolicy: entry?.sendPolicy, displayName: entry?.displayName, chatType: entry?.chatType, surface: entry?.surface, @@ -999,6 +1020,7 @@ export function createBridgeHandlers(ctx: BridgeHandlersContext) { thinkingLevel: entry?.thinkingLevel, verboseLevel: entry?.verboseLevel, systemSent: entry?.systemSent, + sendPolicy: entry?.sendPolicy, lastChannel: entry?.lastChannel, lastTo: entry?.lastTo, }; @@ -1080,6 +1102,7 @@ export function createBridgeHandlers(ctx: BridgeHandlersContext) { thinkingLevel: entry?.thinkingLevel, verboseLevel: entry?.verboseLevel, systemSent: entry?.systemSent, + sendPolicy: entry?.sendPolicy, lastChannel: entry?.lastChannel, lastTo: entry?.lastTo, }; diff --git a/src/gateway/server-methods.ts b/src/gateway/server-methods.ts index 08ff66b9f..bf0b9bbf9 100644 --- a/src/gateway/server-methods.ts +++ b/src/gateway/server-methods.ts @@ -75,6 +75,10 @@ import { } from "../infra/voicewake.js"; import { webAuthExists } from "../providers/web/index.js"; import { defaultRuntime } from "../runtime.js"; +import { + normalizeSendPolicy, + resolveSendPolicy, +} from "../sessions/send-policy.js"; import { sendMessageSignal } from "../signal/index.js"; import { probeSignal, type SignalProbe } from "../signal/probe.js"; import { probeTelegram, type TelegramProbe } from "../telegram/probe.js"; @@ -701,7 +705,7 @@ export async function handleGatewayRequest( break; } } - const { storePath, store, entry } = loadSessionEntry(p.sessionKey); + const { cfg, storePath, store, entry } = loadSessionEntry(p.sessionKey); const now = Date.now(); const sessionId = entry?.sessionId ?? randomUUID(); const sessionEntry: SessionEntry = { @@ -710,11 +714,31 @@ export async function handleGatewayRequest( thinkingLevel: entry?.thinkingLevel, verboseLevel: entry?.verboseLevel, systemSent: entry?.systemSent, + sendPolicy: entry?.sendPolicy, lastChannel: entry?.lastChannel, lastTo: entry?.lastTo, }; const clientRunId = p.idempotencyKey; + const sendPolicy = resolveSendPolicy({ + cfg, + entry, + sessionKey: p.sessionKey, + surface: entry?.surface, + chatType: entry?.chatType, + }); + if (sendPolicy === "deny") { + respond( + false, + undefined, + errorShape( + ErrorCodes.INVALID_REQUEST, + "send blocked by session policy", + ), + ); + break; + } + const cached = dedupe.get(`chat:${clientRunId}`); if (cached) { respond(cached.ok, cached.payload, cached.error, { @@ -1677,6 +1701,27 @@ export async function handleGatewayRequest( } } + if ("sendPolicy" in p) { + const raw = p.sendPolicy; + if (raw === null) { + delete next.sendPolicy; + } else if (raw !== undefined) { + const normalized = normalizeSendPolicy(String(raw)); + if (!normalized) { + respond( + false, + undefined, + errorShape( + ErrorCodes.INVALID_REQUEST, + 'invalid sendPolicy (use "allow"|"deny")', + ), + ); + break; + } + next.sendPolicy = normalized; + } + } + if ("groupActivation" in p) { const raw = p.groupActivation; if (raw === null) { @@ -1744,6 +1789,7 @@ export async function handleGatewayRequest( verboseLevel: entry?.verboseLevel, model: entry?.model, contextTokens: entry?.contextTokens, + sendPolicy: entry?.sendPolicy, lastChannel: entry?.lastChannel, lastTo: entry?.lastTo, skillsSnapshot: entry?.skillsSnapshot, @@ -2739,10 +2785,29 @@ export async function handleGatewayRequest( thinkingLevel: entry?.thinkingLevel, verboseLevel: entry?.verboseLevel, systemSent: entry?.systemSent, + sendPolicy: entry?.sendPolicy, skillsSnapshot: entry?.skillsSnapshot, lastChannel: entry?.lastChannel, lastTo: entry?.lastTo, }; + const sendPolicy = resolveSendPolicy({ + cfg, + entry, + sessionKey: requestedSessionKey, + surface: entry?.surface, + chatType: entry?.chatType, + }); + if (sendPolicy === "deny") { + respond( + false, + undefined, + errorShape( + ErrorCodes.INVALID_REQUEST, + "send blocked by session policy", + ), + ); + break; + } if (store) { store[requestedSessionKey] = sessionEntry; if (storePath) { diff --git a/src/gateway/server.chat.test.ts b/src/gateway/server.chat.test.ts index 4ee60b2b4..67757450d 100644 --- a/src/gateway/server.chat.test.ts +++ b/src/gateway/server.chat.test.ts @@ -18,6 +18,96 @@ import { installGatewayTestHooks(); describe("gateway server chat", () => { + test("chat.send blocked by send policy", async () => { + const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdis-gw-")); + testState.sessionStorePath = path.join(dir, "sessions.json"); + testState.sessionConfig = { + sendPolicy: { + default: "allow", + rules: [ + { + action: "deny", + match: { surface: "discord", chatType: "group" }, + }, + ], + }, + }; + + await fs.writeFile( + testState.sessionStorePath, + JSON.stringify( + { + "discord:group:dev": { + sessionId: "sess-discord", + updatedAt: Date.now(), + chatType: "group", + surface: "discord", + }, + }, + null, + 2, + ), + "utf-8", + ); + + const { server, ws } = await startServerWithClient(); + await connectOk(ws); + + const res = await rpcReq(ws, "chat.send", { + sessionKey: "discord:group:dev", + message: "hello", + idempotencyKey: "idem-1", + }); + expect(res.ok).toBe(false); + expect( + (res.error as { message?: string } | undefined)?.message ?? "", + ).toMatch(/send blocked/i); + + ws.close(); + await server.close(); + }); + + test("agent blocked by send policy for sessionKey", async () => { + const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdis-gw-")); + testState.sessionStorePath = path.join(dir, "sessions.json"); + testState.sessionConfig = { + sendPolicy: { + default: "allow", + rules: [{ action: "deny", match: { keyPrefix: "cron:" } }], + }, + }; + + await fs.writeFile( + testState.sessionStorePath, + JSON.stringify( + { + "cron:job-1": { + sessionId: "sess-cron", + updatedAt: Date.now(), + }, + }, + null, + 2, + ), + "utf-8", + ); + + const { server, ws } = await startServerWithClient(); + await connectOk(ws); + + const res = await rpcReq(ws, "agent", { + sessionKey: "cron:job-1", + message: "hi", + idempotencyKey: "idem-2", + }); + expect(res.ok).toBe(false); + expect( + (res.error as { message?: string } | undefined)?.message ?? "", + ).toMatch(/send blocked/i); + + ws.close(); + await server.close(); + }); test("chat.send accepts image attachment", { timeout: 12000 }, async () => { const { server, ws } = await startServerWithClient(); await connectOk(ws); diff --git a/src/gateway/server.sessions.test.ts b/src/gateway/server.sessions.test.ts index 800a7e3d7..64c498131 100644 --- a/src/gateway/server.sessions.test.ts +++ b/src/gateway/server.sessions.test.ts @@ -126,17 +126,26 @@ describe("gateway server sessions", () => { expect(patched.payload?.ok).toBe(true); expect(patched.payload?.key).toBe("main"); + const sendPolicyPatched = await rpcReq<{ + ok: true; + entry: { sendPolicy?: string }; + }>(ws, "sessions.patch", { key: "main", sendPolicy: "deny" }); + expect(sendPolicyPatched.ok).toBe(true); + expect(sendPolicyPatched.payload?.entry.sendPolicy).toBe("deny"); + const list2 = await rpcReq<{ sessions: Array<{ key: string; thinkingLevel?: string; verboseLevel?: string; + sendPolicy?: string; }>; }>(ws, "sessions.list", {}); expect(list2.ok).toBe(true); const main2 = list2.payload?.sessions.find((s) => s.key === "main"); expect(main2?.thinkingLevel).toBe("medium"); expect(main2?.verboseLevel).toBeUndefined(); + expect(main2?.sendPolicy).toBe("deny"); piSdkMock.enabled = true; piSdkMock.models = [{ id: "gpt-test-a", name: "A", provider: "openai" }]; diff --git a/src/gateway/session-utils.ts b/src/gateway/session-utils.ts index cc8c51716..20f22d39d 100644 --- a/src/gateway/session-utils.ts +++ b/src/gateway/session-utils.ts @@ -29,17 +29,21 @@ export type GatewaySessionRow = { subject?: string; room?: string; space?: string; + chatType?: "direct" | "group" | "room"; updatedAt: number | null; sessionId?: string; systemSent?: boolean; abortedLastRun?: boolean; thinkingLevel?: string; verboseLevel?: string; + sendPolicy?: "allow" | "deny"; inputTokens?: number; outputTokens?: number; totalTokens?: number; model?: string; contextTokens?: number; + lastChannel?: SessionEntry["lastChannel"]; + lastTo?: string; }; export type SessionsListResult = { @@ -265,17 +269,21 @@ export function listSessionsFromStore(params: { subject, room, space, + chatType: entry?.chatType, updatedAt, sessionId: entry?.sessionId, systemSent: entry?.systemSent, abortedLastRun: entry?.abortedLastRun, thinkingLevel: entry?.thinkingLevel, verboseLevel: entry?.verboseLevel, + sendPolicy: entry?.sendPolicy, inputTokens: entry?.inputTokens, outputTokens: entry?.outputTokens, totalTokens: total, model: entry?.model, contextTokens: entry?.contextTokens, + lastChannel: entry?.lastChannel, + lastTo: entry?.lastTo, } satisfies GatewaySessionRow; }) .sort((a, b) => (b.updatedAt ?? 0) - (a.updatedAt ?? 0)); diff --git a/src/gateway/test-helpers.ts b/src/gateway/test-helpers.ts index 652e015b8..17a152215 100644 --- a/src/gateway/test-helpers.ts +++ b/src/gateway/test-helpers.ts @@ -79,6 +79,7 @@ export const agentCommand = hoisted.agentCommand; export const testState = { sessionStorePath: undefined as string | undefined, + sessionConfig: undefined as Record | undefined, allowFrom: undefined as string[] | undefined, cronStorePath: undefined as string | undefined, cronEnabled: false as boolean | undefined, @@ -239,7 +240,11 @@ vi.mock("../config/config.js", async () => { whatsapp: { allowFrom: testState.allowFrom, }, - session: { mainKey: "main", store: testState.sessionStorePath }, + session: { + mainKey: "main", + store: testState.sessionStorePath, + ...(testState.sessionConfig ?? {}), + }, gateway: (() => { const gateway: Record = {}; if (testState.gatewayBind) gateway.bind = testState.gatewayBind; @@ -318,6 +323,7 @@ export function installGatewayTestHooks() { testState.migrationChanges = []; testState.cronEnabled = false; testState.cronStorePath = undefined; + testState.sessionConfig = undefined; testState.sessionStorePath = undefined; testState.allowFrom = undefined; testIsNixMode.value = false; diff --git a/src/sessions/send-policy.test.ts b/src/sessions/send-policy.test.ts new file mode 100644 index 000000000..744c05dff --- /dev/null +++ b/src/sessions/send-policy.test.ts @@ -0,0 +1,53 @@ +import { describe, expect, it } from "vitest"; +import type { ClawdisConfig } from "../config/config.js"; +import type { SessionEntry } from "../config/sessions.js"; +import { resolveSendPolicy } from "./send-policy.js"; + +describe("resolveSendPolicy", () => { + it("defaults to allow", () => { + const cfg = {} as ClawdisConfig; + expect(resolveSendPolicy({ cfg })).toBe("allow"); + }); + + it("entry override wins", () => { + const cfg = { + session: { sendPolicy: { default: "allow" } }, + } as ClawdisConfig; + const entry: SessionEntry = { sessionId: "s", updatedAt: 0, sendPolicy: "deny" }; + expect(resolveSendPolicy({ cfg, entry })).toBe("deny"); + }); + + it("rule match by surface + chatType", () => { + const cfg = { + session: { + sendPolicy: { + default: "allow", + rules: [ + { action: "deny", match: { surface: "discord", chatType: "group" } }, + ], + }, + }, + } as ClawdisConfig; + const entry: SessionEntry = { + sessionId: "s", + updatedAt: 0, + surface: "discord", + chatType: "group", + }; + expect(resolveSendPolicy({ cfg, entry, sessionKey: "discord:group:dev" })).toBe( + "deny", + ); + }); + + it("rule match by keyPrefix", () => { + const cfg = { + session: { + sendPolicy: { + default: "allow", + rules: [{ action: "deny", match: { keyPrefix: "cron:" } }], + }, + }, + } as ClawdisConfig; + expect(resolveSendPolicy({ cfg, sessionKey: "cron:job-1" })).toBe("deny"); + }); +}); diff --git a/src/sessions/send-policy.ts b/src/sessions/send-policy.ts new file mode 100644 index 000000000..1000c87d0 --- /dev/null +++ b/src/sessions/send-policy.ts @@ -0,0 +1,79 @@ +import type { ClawdisConfig } from "../config/config.js"; +import type { SessionEntry, SessionChatType } from "../config/sessions.js"; + +export type SessionSendPolicyDecision = "allow" | "deny"; + +export function normalizeSendPolicy( + raw?: string | null, +): SessionSendPolicyDecision | undefined { + const value = raw?.trim().toLowerCase(); + if (value === "allow") return "allow"; + if (value === "deny") return "deny"; + return undefined; +} + +function normalizeMatchValue(raw?: string | null) { + const value = raw?.trim().toLowerCase(); + return value ? value : undefined; +} + +function deriveSurfaceFromKey(key?: string) { + if (!key) return undefined; + const parts = key.split(":").filter(Boolean); + if (parts.length >= 3 && (parts[1] === "group" || parts[1] === "channel")) { + return normalizeMatchValue(parts[0]); + } + return undefined; +} + +function deriveChatTypeFromKey(key?: string): SessionChatType | undefined { + if (!key) return undefined; + if (key.startsWith("group:") || key.includes(":group:")) return "group"; + if (key.includes(":channel:")) return "room"; + return undefined; +} + +export function resolveSendPolicy(params: { + cfg: ClawdisConfig; + entry?: SessionEntry; + sessionKey?: string; + surface?: string; + chatType?: SessionChatType; +}): SessionSendPolicyDecision { + const override = normalizeSendPolicy(params.entry?.sendPolicy); + if (override) return override; + + const policy = params.cfg.session?.sendPolicy; + if (!policy) return "allow"; + + const surface = + normalizeMatchValue(params.surface) ?? + normalizeMatchValue(params.entry?.surface) ?? + normalizeMatchValue(params.entry?.lastChannel) ?? + deriveSurfaceFromKey(params.sessionKey); + const chatType = + normalizeMatchValue(params.chatType ?? params.entry?.chatType) ?? + normalizeMatchValue(deriveChatTypeFromKey(params.sessionKey)); + const sessionKey = params.sessionKey ?? ""; + + let allowedMatch = false; + for (const rule of policy.rules ?? []) { + if (!rule) continue; + const action = normalizeSendPolicy(rule.action) ?? "allow"; + const match = rule.match ?? {}; + const matchSurface = normalizeMatchValue(match.surface); + const matchChatType = normalizeMatchValue(match.chatType); + const matchPrefix = normalizeMatchValue(match.keyPrefix); + + if (matchSurface && matchSurface !== surface) continue; + if (matchChatType && matchChatType !== chatType) continue; + if (matchPrefix && !sessionKey.startsWith(matchPrefix)) continue; + if (action === "deny") return "deny"; + allowedMatch = true; + } + + if (allowedMatch) return "allow"; + + const fallback = normalizeSendPolicy(policy.default); + return fallback ?? "allow"; +} diff --git a/src/tui/gateway-chat.ts b/src/tui/gateway-chat.ts index 1483d2320..219cd1fd2 100644 --- a/src/tui/gateway-chat.ts +++ b/src/tui/gateway-chat.ts @@ -40,10 +40,18 @@ export type GatewaySessionList = { updatedAt?: number | null; thinkingLevel?: string; verboseLevel?: string; + sendPolicy?: string; model?: string; contextTokens?: number | null; totalTokens?: number | null; displayName?: string; + surface?: string; + room?: string; + space?: string; + subject?: string; + chatType?: string; + lastChannel?: string; + lastTo?: string; }>; };