feat: add sessions tools and send policy

This commit is contained in:
Peter Steinberger
2026-01-03 23:44:38 +01:00
parent 919d5d1dbb
commit e7c9b9a749
24 changed files with 1304 additions and 4 deletions

View File

@@ -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

109
docs/session-tool.md Normal file
View File

@@ -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:<id>` or `surface:channel:<id>`.
- Cron jobs use `cron:<job.id>`.
- Hooks use `hook:<uuid>` unless explicitly set.
- Node bridge uses `node-<nodeId>` 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

View File

@@ -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:<id>` keys (rooms/channels use `surface:channel:<id>`); do not reuse the primary key for groups. (Discord display names show `discord:<guildSlug>#<channelSlug>`.)
- Legacy `group:<surface>:<id>` and `group:<id>` keys are still recognized.
- Other sources:
- Cron jobs: `cron:<job.id>`
- Webhooks: `hook:<uuid>` (unless explicitly set by the hook)
- Node bridge runs: `node-<nodeId>`
## 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

View File

@@ -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.

View File

@@ -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",
});
});
});

View File

@@ -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<unknown> {
};
}
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<string, unknown>;
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<Record<string, unknown>>;
};
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<string, unknown>;
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<string, unknown>;
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(),
];
}

View File

@@ -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",

View File

@@ -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<string, { sendPolicy?: string }>;
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(

View File

@@ -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 || "<unknown>"}`,
);
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;

View File

@@ -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 };
}

View File

@@ -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 ??

View File

@@ -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();

View File

@@ -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,

View File

@@ -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,
};

View File

@@ -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"),

View File

@@ -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,
};

View File

@@ -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) {

View File

@@ -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);

View File

@@ -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" }];

View File

@@ -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));

View File

@@ -79,6 +79,7 @@ export const agentCommand = hoisted.agentCommand;
export const testState = {
sessionStorePath: undefined as string | undefined,
sessionConfig: undefined as Record<string, unknown> | 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<string, unknown> = {};
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;

View File

@@ -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");
});
});

View File

@@ -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";
}

View File

@@ -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;
}>;
};