@clawling/clawchat-plugin-openclaw 2026.10.9-2 → 2026.10.9-4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -1
- package/dist/index.js +4 -0
- package/dist/src/clawchat-memory.js +6 -1
- package/dist/src/config.js +6 -0
- package/dist/src/host-tools-guard.js +165 -0
- package/dist/src/host-version.js +35 -0
- package/dist/src/memory-scope.js +166 -0
- package/dist/src/outbound.js +28 -2
- package/dist/src/owner-identity.js +29 -0
- package/dist/src/reply-dispatcher.js +26 -13
- package/dist/src/runtime.js +73 -12
- package/dist/src/storage.js +153 -15
- package/dist/src/terminal-send.js +69 -12
- package/dist/src/tools.js +324 -36
- package/dist/src/turn-boundary.js +68 -0
- package/index.ts +4 -0
- package/openclaw.plugin.json +4 -0
- package/package.json +3 -3
- package/src/clawchat-memory.ts +13 -0
- package/src/config.ts +11 -0
- package/src/host-tools-guard.ts +189 -0
- package/src/host-version.ts +35 -0
- package/src/memory-scope.ts +191 -0
- package/src/outbound.ts +39 -3
- package/src/owner-identity.ts +49 -0
- package/src/reply-dispatcher.ts +31 -13
- package/src/runtime.ts +81 -13
- package/src/storage.ts +176 -16
- package/src/terminal-send.ts +97 -13
- package/src/tools.ts +347 -40
- package/src/turn-boundary.ts +79 -0
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
// Who started this turn? The owner's turns are exactly the ones in the owner's
|
|
2
|
+
// activation direct chat (whoever the sender is — plugin-started turns there
|
|
3
|
+
// count). Every group turn and every other direct chat is someone else's.
|
|
4
|
+
// Anything we cannot place is treated as someone else's (fail closed).
|
|
5
|
+
import { CHANNEL_ID, normalizeOpenclawClawlingAccountId } from "./config.js";
|
|
6
|
+
/** Tools a turn the owner did not start may still see and call. */
|
|
7
|
+
export const NON_OWNER_TOOL_ALLOWLIST = Object.freeze([
|
|
8
|
+
"clawchat_memory_search", "clawchat_memory_read", "clawchat_memory_write", "clawchat_memory_edit",
|
|
9
|
+
"clawchat_mention_message", "clawchat_react_message", "clawchat_get_user_profile", "clawchat_metadata_sync",
|
|
10
|
+
"web_search",
|
|
11
|
+
]);
|
|
12
|
+
const norm = (v) => (typeof v === "string" ? v.trim().toLowerCase() : "");
|
|
13
|
+
// `<channel>:` then `direct:`/`group:`, each optional and stripped at most once.
|
|
14
|
+
const TOOL_CHAT_ID_TARGET_PREFIX = new RegExp(`^(?:${CHANNEL_ID}:)?(?:(?:direct|group):)?`, "i");
|
|
15
|
+
/**
|
|
16
|
+
* The bare ClawChat chat id a tool argument names. The host shows the model
|
|
17
|
+
* this chat's `chat_id` as the channel-prefixed origin target
|
|
18
|
+
* (`clawchat-plugin-openclaw:cnv_…` / `clawchat-plugin-openclaw:group:cnv_…`)
|
|
19
|
+
* and a group's label as `group:cnv_…`; models copy either form into tool
|
|
20
|
+
* arguments. Only that form is stripped; case is kept, and whatever remains is
|
|
21
|
+
* still validated by the send path (`isValidChatId`) or the server. Every tool
|
|
22
|
+
* that takes a ClawChat chat id, and the non-owner current-chat check, use
|
|
23
|
+
* this one helper so they agree on which chat an argument names.
|
|
24
|
+
*/
|
|
25
|
+
export function normalizeToolChatId(value) {
|
|
26
|
+
if (typeof value !== "string")
|
|
27
|
+
return "";
|
|
28
|
+
return value.trim().replace(TOOL_CHAT_ID_TARGET_PREFIX, "").trim();
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* Host session keys (dmScope per-account-channel-peer):
|
|
32
|
+
* agent:<agent>:<channel>:<account>:direct:<chat>
|
|
33
|
+
* agent:<agent>:<channel>:group:<chat>
|
|
34
|
+
*/
|
|
35
|
+
export function parseClawChatSessionKey(sessionKey) {
|
|
36
|
+
const parts = norm(sessionKey).split(":");
|
|
37
|
+
if (parts.length < 5 || parts[0] !== "agent" || parts[2] !== CHANNEL_ID)
|
|
38
|
+
return null;
|
|
39
|
+
if (parts[3] === "group" && parts.length === 5 && parts[4])
|
|
40
|
+
return { kind: "group", chatId: parts[4] };
|
|
41
|
+
if (parts[4] === "direct" && parts.length === 6 && parts[5])
|
|
42
|
+
return { kind: "direct", chatId: parts[5] };
|
|
43
|
+
return null;
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* Account id carried by a ClawChat direct session key, normalized exactly once
|
|
47
|
+
* with the plugin's account-id normalizer. Group keys carry no account
|
|
48
|
+
* segment, and a blank segment is missing (never silently "default").
|
|
49
|
+
*/
|
|
50
|
+
export function clawChatSessionAccountId(sessionKey) {
|
|
51
|
+
if (parseClawChatSessionKey(sessionKey)?.kind !== "direct")
|
|
52
|
+
return undefined;
|
|
53
|
+
const segment = (sessionKey ?? "").trim().split(":")[3]?.trim();
|
|
54
|
+
return segment ? normalizeOpenclawClawlingAccountId(segment) : undefined;
|
|
55
|
+
}
|
|
56
|
+
export function classifyChat(input) {
|
|
57
|
+
const owner = norm(input.ownerDirectConversationId);
|
|
58
|
+
const chat = norm(input.chatId);
|
|
59
|
+
if (input.kind !== "direct" || !owner || !chat)
|
|
60
|
+
return "non-owner";
|
|
61
|
+
return chat === owner ? "owner" : "non-owner";
|
|
62
|
+
}
|
|
63
|
+
export function classifyTurn(input) {
|
|
64
|
+
const parsed = parseClawChatSessionKey(input.sessionKey);
|
|
65
|
+
if (!parsed)
|
|
66
|
+
return "non-owner";
|
|
67
|
+
return classifyChat({ ...parsed, ownerDirectConversationId: input.ownerDirectConversationId });
|
|
68
|
+
}
|
package/index.ts
CHANGED
|
@@ -3,6 +3,7 @@ import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
|
|
|
3
3
|
import { openclawClawlingPlugin } from "./src/channel.ts";
|
|
4
4
|
import { registerOpenclawClawlingCommands } from "./src/commands.ts";
|
|
5
5
|
import { openclawClawlingConfigSchema } from "./src/config.ts";
|
|
6
|
+
import { registerHostToolsGuard } from "./src/host-tools-guard.ts";
|
|
6
7
|
import {
|
|
7
8
|
registerClawChatPromptInjection,
|
|
8
9
|
type ClawChatPromptInjectionApi,
|
|
@@ -20,6 +21,9 @@ const channelEntry = defineChannelPluginEntry({
|
|
|
20
21
|
registerFull(api) {
|
|
21
22
|
registerClawChatPromptInjection(api as unknown as ClawChatPromptInjectionApi);
|
|
22
23
|
registerOpenclawClawlingTools(api);
|
|
24
|
+
// Fail-closed host-tool guard for turns the owner did not start. After the
|
|
25
|
+
// tools so their names are recorded; not gated by conversation access.
|
|
26
|
+
registerHostToolsGuard(api);
|
|
23
27
|
// NOTE: the legacy-rename config migration is intentionally NOT registered
|
|
24
28
|
// here. The host's setup-migration runner only loads a plugin's SETUP source
|
|
25
29
|
// (`openclaw.setupEntry`/`runtimeSetupEntry` → setup-entry.ts) and calls its
|
package/openclaw.plugin.json
CHANGED
|
@@ -132,6 +132,7 @@
|
|
|
132
132
|
"note-cap-group": { "type": "integer" },
|
|
133
133
|
"note-cap-turn": { "type": "integer" },
|
|
134
134
|
"delta-budget-chars": { "type": "integer" },
|
|
135
|
+
"non-owner-host-tools": { "type": "string", "enum": ["off", "approve"] },
|
|
135
136
|
"reconnect": {
|
|
136
137
|
"type": "object",
|
|
137
138
|
"additionalProperties": false,
|
|
@@ -210,6 +211,7 @@
|
|
|
210
211
|
"note-cap-group": { "type": "integer" },
|
|
211
212
|
"note-cap-turn": { "type": "integer" },
|
|
212
213
|
"delta-budget-chars": { "type": "integer" },
|
|
214
|
+
"non-owner-host-tools": { "type": "string", "enum": ["off", "approve"] },
|
|
213
215
|
"reconnect": {
|
|
214
216
|
"type": "object",
|
|
215
217
|
"additionalProperties": false,
|
|
@@ -294,6 +296,7 @@
|
|
|
294
296
|
"note-cap-group": { "type": "integer" },
|
|
295
297
|
"note-cap-turn": { "type": "integer" },
|
|
296
298
|
"delta-budget-chars": { "type": "integer" },
|
|
299
|
+
"non-owner-host-tools": { "type": "string", "enum": ["off", "approve"] },
|
|
297
300
|
"reconnect": {
|
|
298
301
|
"type": "object",
|
|
299
302
|
"additionalProperties": false,
|
|
@@ -372,6 +375,7 @@
|
|
|
372
375
|
"note-cap-group": { "type": "integer" },
|
|
373
376
|
"note-cap-turn": { "type": "integer" },
|
|
374
377
|
"delta-budget-chars": { "type": "integer" },
|
|
378
|
+
"non-owner-host-tools": { "type": "string", "enum": ["off", "approve"] },
|
|
375
379
|
"reconnect": {
|
|
376
380
|
"type": "object",
|
|
377
381
|
"additionalProperties": false,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@clawling/clawchat-plugin-openclaw",
|
|
3
|
-
"version": "2026.10.9-
|
|
3
|
+
"version": "2026.10.9-4",
|
|
4
4
|
"description": "OpenClaw ClawChat channel plugin",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "CLAWLING PTE. LTD.",
|
|
@@ -52,7 +52,7 @@
|
|
|
52
52
|
"@sinclair/typebox": "0.34.48"
|
|
53
53
|
},
|
|
54
54
|
"peerDependencies": {
|
|
55
|
-
"openclaw": ">=2026.
|
|
55
|
+
"openclaw": ">=2026.8.1"
|
|
56
56
|
},
|
|
57
57
|
"peerDependenciesMeta": {
|
|
58
58
|
"openclaw": {
|
|
@@ -93,7 +93,7 @@
|
|
|
93
93
|
"install": {
|
|
94
94
|
"npmSpec": "@clawling/clawchat-plugin-openclaw",
|
|
95
95
|
"defaultChoice": "npm",
|
|
96
|
-
"minHostVersion": ">=2026.
|
|
96
|
+
"minHostVersion": ">=2026.8.1"
|
|
97
97
|
}
|
|
98
98
|
}
|
|
99
99
|
}
|
package/src/clawchat-memory.ts
CHANGED
|
@@ -34,6 +34,13 @@ export interface ClawChatMemorySearchResult {
|
|
|
34
34
|
query: string;
|
|
35
35
|
matches: ClawChatMemorySearchMatch[];
|
|
36
36
|
truncated: boolean;
|
|
37
|
+
/** Set by callers whose conversation scope left notes out (never a count). */
|
|
38
|
+
scopeNote?: string;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export interface ClawChatMemorySearchOptions {
|
|
42
|
+
/** Targets this caller may see; others are skipped before any content read. */
|
|
43
|
+
readable?: (target: ClawChatMemoryTarget) => boolean;
|
|
37
44
|
}
|
|
38
45
|
|
|
39
46
|
const metadataStart = "<!-- clawchat:metadata:start -->";
|
|
@@ -434,6 +441,7 @@ function buildSearchMatch(
|
|
|
434
441
|
export async function searchClawChatMemory(
|
|
435
442
|
root: string,
|
|
436
443
|
params: ClawChatMemorySearchParams,
|
|
444
|
+
options: ClawChatMemorySearchOptions = {},
|
|
437
445
|
): Promise<ClawChatMemorySearchResult> {
|
|
438
446
|
const { query, queryLower, targetTypes, maxResults } = normalizeSearchParams(params);
|
|
439
447
|
const rootPath = path.resolve(root);
|
|
@@ -443,6 +451,11 @@ export async function searchClawChatMemory(
|
|
|
443
451
|
for (const targetType of targetTypes) {
|
|
444
452
|
const targets = await listSafeMemoryFiles(rootPath, rootRealPath, targetType);
|
|
445
453
|
for (const target of targets) {
|
|
454
|
+
// Filter before reading, matching and counting so unreadable notes
|
|
455
|
+
// leave no trace in matches or `truncated`.
|
|
456
|
+
if (options.readable && !options.readable(target)) {
|
|
457
|
+
continue;
|
|
458
|
+
}
|
|
446
459
|
const file = await readClawChatMemoryFile(rootPath, target);
|
|
447
460
|
if (!file.exists) {
|
|
448
461
|
continue;
|
package/src/config.ts
CHANGED
|
@@ -125,6 +125,8 @@ export type OpenclawClawlingAccountConfig = {
|
|
|
125
125
|
"note-cap-group"?: number;
|
|
126
126
|
"note-cap-turn"?: number;
|
|
127
127
|
"delta-budget-chars"?: number;
|
|
128
|
+
/** Host tools in turns the owner did not start: `off` | `approve` (approve currently behaves as off). */
|
|
129
|
+
"non-owner-host-tools"?: string;
|
|
128
130
|
reconnect?: OpenclawClawlingReconnectConfig;
|
|
129
131
|
heartbeat?: OpenclawClawlingHeartbeatConfig;
|
|
130
132
|
ack?: OpenclawClawlingAckConfig;
|
|
@@ -190,6 +192,7 @@ export const openclawClawlingAccountConfigSchema = {
|
|
|
190
192
|
"note-cap-group": { type: "integer" },
|
|
191
193
|
"note-cap-turn": { type: "integer" },
|
|
192
194
|
"delta-budget-chars": { type: "integer" },
|
|
195
|
+
"non-owner-host-tools": { type: "string", enum: ["off", "approve"] },
|
|
193
196
|
reconnect: {
|
|
194
197
|
type: "object",
|
|
195
198
|
additionalProperties: false,
|
|
@@ -337,6 +340,8 @@ export type ResolvedOpenclawClawlingAccount = {
|
|
|
337
340
|
noteCapTurn: number;
|
|
338
341
|
/** `delta-budget-chars`: characters of unseen group messages an @-mention adds. */
|
|
339
342
|
deltaBudgetChars: number;
|
|
343
|
+
/** `non-owner-host-tools`: parsed mode; `approve` is reserved and currently behaves like `off`. */
|
|
344
|
+
nonOwnerHostTools: "off" | "approve";
|
|
340
345
|
allowFrom: string[];
|
|
341
346
|
reconnect: Required<OpenclawClawlingReconnectConfig>;
|
|
342
347
|
heartbeat: Required<OpenclawClawlingHeartbeatConfig>;
|
|
@@ -597,6 +602,11 @@ export const SESSION_INT_KEYS = {
|
|
|
597
602
|
"delta-budget-chars": [8000, 2000, 32000],
|
|
598
603
|
} as const satisfies Record<string, readonly [number, number, number]>;
|
|
599
604
|
|
|
605
|
+
/** `approve` only when explicitly set; any other value (typo, `on`, boolean) means `off`. */
|
|
606
|
+
export function parseNonOwnerHostTools(raw: unknown): "off" | "approve" {
|
|
607
|
+
return typeof raw === "string" && raw.trim().toLowerCase() === "approve" ? "approve" : "off";
|
|
608
|
+
}
|
|
609
|
+
|
|
600
610
|
function readSessionInt(channel: Record<string, unknown>, key: keyof typeof SESSION_INT_KEYS): number {
|
|
601
611
|
const [factory, minimum, maximum] = SESSION_INT_KEYS[key];
|
|
602
612
|
const raw = channel[key];
|
|
@@ -739,6 +749,7 @@ export function resolveOpenclawClawlingAccount(
|
|
|
739
749
|
noteCapGroup: readSessionInt(channel, "note-cap-group"),
|
|
740
750
|
noteCapTurn: readSessionInt(channel, "note-cap-turn"),
|
|
741
751
|
deltaBudgetChars: readSessionInt(channel, "delta-budget-chars"),
|
|
752
|
+
nonOwnerHostTools: parseNonOwnerHostTools(channel["non-owner-host-tools"]),
|
|
742
753
|
allowFrom: [],
|
|
743
754
|
reconnect: readReconnect(channel.reconnect),
|
|
744
755
|
heartbeat: readHeartbeat(channel.heartbeat),
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
// Layer 2 of the non-owner turn boundary: a fail-closed `before_tool_call`
|
|
2
|
+
// hook. Dispatch (layer 1) already narrows the offered tools for turns the
|
|
3
|
+
// owner did not start; this hook blocks anything else that still reaches
|
|
4
|
+
// execution. Only calls whose session key carries the ClawChat prefix are
|
|
5
|
+
// in scope (D8); everything else passes untouched. Tool arguments are never
|
|
6
|
+
// logged.
|
|
7
|
+
import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
|
|
8
|
+
import { CHANNEL_ID, normalizeOpenclawClawlingAccountId, resolveOpenclawClawlingAccount } from "./config.ts";
|
|
9
|
+
import {
|
|
10
|
+
classifyChat,
|
|
11
|
+
clawChatSessionAccountId,
|
|
12
|
+
NON_OWNER_TOOL_ALLOWLIST,
|
|
13
|
+
normalizeToolChatId,
|
|
14
|
+
parseClawChatSessionKey,
|
|
15
|
+
} from "./turn-boundary.ts";
|
|
16
|
+
import { clawChatDbPathForStateDir, getClawChatStore } from "./storage.ts";
|
|
17
|
+
import { resolveOwnerIdentity } from "./owner-identity.ts";
|
|
18
|
+
import { getRegisteredClawChatToolNames } from "./tools.ts";
|
|
19
|
+
|
|
20
|
+
export const BLOCK_MESSAGE =
|
|
21
|
+
"This tool is not available here: only your owner's direct chat may use host tools on this machine. Do not try to reach them another way; answer without them, or tell the person to ask your owner.";
|
|
22
|
+
|
|
23
|
+
export type HostToolDecision = { block: true; blockReason: string } | undefined;
|
|
24
|
+
export type HostToolInput = {
|
|
25
|
+
toolName: string;
|
|
26
|
+
params: Record<string, unknown>;
|
|
27
|
+
sessionKey?: string;
|
|
28
|
+
ownerDirectConversationId?: string | null;
|
|
29
|
+
registeredTools: ReadonlySet<string>;
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Host tool-search controls (OpenClaw 2026.9.6+). The host adds them to every
|
|
34
|
+
* run on its own and builds their catalog from the run's allowed tools, so in
|
|
35
|
+
* a non-owner turn they reach only the allowlisted tools; the host fires this
|
|
36
|
+
* hook again for the inner tool a `tool_call` runs, and that inner call gets
|
|
37
|
+
* every check below. They are not part of the dispatch allowlist.
|
|
38
|
+
*/
|
|
39
|
+
export const HOST_TOOL_SEARCH_CONTROLS: readonly string[] = Object.freeze(["tool_search", "tool_describe", "tool_call"]);
|
|
40
|
+
const ACCOUNT_SCOPED_TOOLS: readonly string[] = ["clawchat_mention_message", "clawchat_react_message"];
|
|
41
|
+
|
|
42
|
+
const block = (): HostToolDecision => ({ block: true, blockReason: BLOCK_MESSAGE });
|
|
43
|
+
const CLAWCHAT_SESSION_PREFIX = new RegExp(`^agent:[^:]+:${CHANNEL_ID}:`, "i");
|
|
44
|
+
const PATH_FIELD = /^(?:filePath|media|iconPath|path|attachments)$/i;
|
|
45
|
+
|
|
46
|
+
/** D8: only the ClawChat session-key prefix ties a call to ClawChat; requester channel alone never does. */
|
|
47
|
+
export function isClawChatToolCall(input: Pick<HostToolInput, "sessionKey">): boolean {
|
|
48
|
+
return CLAWCHAT_SESSION_PREFIX.test(input.sessionKey?.trim() ?? "");
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** True when any argument looks like a local file path; only http(s) URLs are allowed. */
|
|
52
|
+
export function hasLocalPathArgument(value: unknown, field = ""): boolean {
|
|
53
|
+
if (typeof value === "string") {
|
|
54
|
+
const text = value.trim();
|
|
55
|
+
if (/^https?:\/\//i.test(text)) return false;
|
|
56
|
+
if (/^(?:\/|[a-z]:[\\/]|\\\\|file:|~|\.\.?[\\/])/i.test(text)) return true;
|
|
57
|
+
return PATH_FIELD.test(field) && text.length > 0;
|
|
58
|
+
}
|
|
59
|
+
if (Array.isArray(value)) return value.some(item => hasLocalPathArgument(item, field));
|
|
60
|
+
return Boolean(value && typeof value === "object" && Object.entries(value).some(
|
|
61
|
+
([key, item]) => hasLocalPathArgument(item, PATH_FIELD.test(field) ? field : key),
|
|
62
|
+
));
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** Pure decision for one tool call. `undefined` lets the call run. */
|
|
66
|
+
export function decideHostToolCall(input: HostToolInput): HostToolDecision {
|
|
67
|
+
if (!isClawChatToolCall(input)) return undefined;
|
|
68
|
+
const parsed = parseClawChatSessionKey(input.sessionKey);
|
|
69
|
+
if (!parsed) return block();
|
|
70
|
+
if (classifyChat({ ...parsed, ownerDirectConversationId: input.ownerDirectConversationId }) === "owner") {
|
|
71
|
+
return undefined;
|
|
72
|
+
}
|
|
73
|
+
// Outer pre-check only. The inner tool call is checked again and that check
|
|
74
|
+
// is authoritative: the host also accepts dotted/flattened argument keys
|
|
75
|
+
// that this outer check may not recognise.
|
|
76
|
+
if (hasLocalPathArgument(input.params)) return block();
|
|
77
|
+
if (HOST_TOOL_SEARCH_CONTROLS.includes(input.toolName)) return undefined;
|
|
78
|
+
if (ACCOUNT_SCOPED_TOOLS.includes(input.toolName)) {
|
|
79
|
+
// D9: a non-owner turn may only act on its own chat, named explicitly.
|
|
80
|
+
// The tool resolves the same argument with the same helper, so the chat
|
|
81
|
+
// checked here is the chat the call reaches.
|
|
82
|
+
const target = normalizeToolChatId(input.params.chatId).toLowerCase();
|
|
83
|
+
if (!target || target !== parsed.chatId) {
|
|
84
|
+
return {
|
|
85
|
+
block: true,
|
|
86
|
+
blockReason: input.toolName === "clawchat_react_message"
|
|
87
|
+
? "Pass this chat's chatId to react here."
|
|
88
|
+
: "Pass this chat's chatId to mention here.",
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
if (NON_OWNER_TOOL_ALLOWLIST.includes(input.toolName)) {
|
|
93
|
+
return input.toolName.startsWith("clawchat_") && !input.registeredTools.has(input.toolName)
|
|
94
|
+
? block()
|
|
95
|
+
: undefined;
|
|
96
|
+
}
|
|
97
|
+
return block(); // `off` and the reserved `approve` both block in this release.
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
type RequesterContext = { channel?: string; accountId?: string };
|
|
101
|
+
|
|
102
|
+
export function registerHostToolsGuard(
|
|
103
|
+
api: OpenClawPluginApi,
|
|
104
|
+
options: { ownerConversation?: (accountId: string) => string | null } = {},
|
|
105
|
+
): void {
|
|
106
|
+
const warned = new Set<string>();
|
|
107
|
+
const warn = (failure: string) => {
|
|
108
|
+
if (warned.has(failure)) return;
|
|
109
|
+
warned.add(failure);
|
|
110
|
+
try {
|
|
111
|
+
api.logger.warn(`clawchat-plugin-openclaw: host-tool guard ${failure}; enforcing non-owner policy`);
|
|
112
|
+
} catch {
|
|
113
|
+
// Logging cannot unlock a call.
|
|
114
|
+
}
|
|
115
|
+
};
|
|
116
|
+
const lookupOwnerConversation = (accountId: string): string | null => {
|
|
117
|
+
if (options.ownerConversation) return options.ownerConversation(accountId);
|
|
118
|
+
const account = resolveOpenclawClawlingAccount(api.config, accountId);
|
|
119
|
+
const stateDir = api.runtime.state?.resolveStateDir?.();
|
|
120
|
+
const store = getClawChatStore({
|
|
121
|
+
...(stateDir ? { dbPath: clawChatDbPathForStateDir(stateDir) } : {}),
|
|
122
|
+
log: { error: message => api.logger.error?.(message) },
|
|
123
|
+
});
|
|
124
|
+
return resolveOwnerIdentity(store, accountId, account.ownerUserId).conversationId;
|
|
125
|
+
};
|
|
126
|
+
|
|
127
|
+
api.on("before_tool_call", (event, sdkCtx) => {
|
|
128
|
+
// `requester` is host 2026.8.1+ (the floor); the development SDK types predate it.
|
|
129
|
+
const ctx = sdkCtx as (typeof sdkCtx & { requester?: RequesterContext }) | undefined;
|
|
130
|
+
try {
|
|
131
|
+
if (!ctx) {
|
|
132
|
+
warn("missing-context");
|
|
133
|
+
return block();
|
|
134
|
+
}
|
|
135
|
+
const sessionKey = ctx.sessionKey;
|
|
136
|
+
if (!isClawChatToolCall({ sessionKey })) return undefined;
|
|
137
|
+
const requester = ctx.requester;
|
|
138
|
+
const parsed = parseClawChatSessionKey(sessionKey);
|
|
139
|
+
if (!parsed || (requester?.channel && requester.channel !== CHANNEL_ID)) {
|
|
140
|
+
warn("unknown-session");
|
|
141
|
+
return block();
|
|
142
|
+
}
|
|
143
|
+
// Direct keys carry their account; group keys do not. A group turn is
|
|
144
|
+
// non-owner whoever speaks, so a group call without the requester
|
|
145
|
+
// account (host tool-search controls and the inner calls they run get
|
|
146
|
+
// a run-level ctx without one) still gets the non-owner policy; only
|
|
147
|
+
// the tools that act as an account need it. Conflicting ids block.
|
|
148
|
+
const keyAccountId = clawChatSessionAccountId(sessionKey);
|
|
149
|
+
const requesterAccountId = requester?.accountId?.trim()
|
|
150
|
+
? normalizeOpenclawClawlingAccountId(requester.accountId)
|
|
151
|
+
: undefined;
|
|
152
|
+
const accountId = requesterAccountId ?? keyAccountId;
|
|
153
|
+
if (keyAccountId && keyAccountId !== accountId) {
|
|
154
|
+
warn("account-identity");
|
|
155
|
+
return block();
|
|
156
|
+
}
|
|
157
|
+
if (!accountId) {
|
|
158
|
+
if (parsed.kind !== "group" || ACCOUNT_SCOPED_TOOLS.includes(event.toolName)) {
|
|
159
|
+
warn("account-identity");
|
|
160
|
+
return block();
|
|
161
|
+
}
|
|
162
|
+
return decideHostToolCall({
|
|
163
|
+
toolName: event.toolName,
|
|
164
|
+
params: event.params ?? {},
|
|
165
|
+
sessionKey,
|
|
166
|
+
ownerDirectConversationId: null,
|
|
167
|
+
registeredTools: getRegisteredClawChatToolNames(),
|
|
168
|
+
});
|
|
169
|
+
}
|
|
170
|
+
let ownerDirectConversationId: string | null = null;
|
|
171
|
+
try {
|
|
172
|
+
ownerDirectConversationId = lookupOwnerConversation(accountId);
|
|
173
|
+
} catch {
|
|
174
|
+
// Treated as missing below; the allowlist still applies.
|
|
175
|
+
}
|
|
176
|
+
if (!ownerDirectConversationId?.trim()) warn("missing-owner-conversation");
|
|
177
|
+
return decideHostToolCall({
|
|
178
|
+
toolName: event.toolName,
|
|
179
|
+
params: event.params ?? {},
|
|
180
|
+
sessionKey,
|
|
181
|
+
ownerDirectConversationId,
|
|
182
|
+
registeredTools: getRegisteredClawChatToolNames(),
|
|
183
|
+
});
|
|
184
|
+
} catch {
|
|
185
|
+
warn("handler-exception");
|
|
186
|
+
return block();
|
|
187
|
+
}
|
|
188
|
+
});
|
|
189
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
export const HOST_FLOOR = "2026.8.1";
|
|
2
|
+
|
|
3
|
+
function parse(v: string): [number, number, number] | null {
|
|
4
|
+
const m = /^(\d+)\.(\d+)\.(\d+)/.exec(v.trim());
|
|
5
|
+
return m ? [Number(m[1]), Number(m[2]), Number(m[3])] : null;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
/** Prerelease suffixes count as their base version (10.1-beta.2 ≥ 8.1). Unparseable ⇒ false. */
|
|
9
|
+
export function hostAtLeast(version: string | undefined, min: string): boolean {
|
|
10
|
+
const a = version ? parse(version) : null;
|
|
11
|
+
const b = parse(min);
|
|
12
|
+
if (!a || !b) return false;
|
|
13
|
+
for (let i = 0; i < 3; i++) if (a[i] !== b[i]) return a[i] > b[i];
|
|
14
|
+
return true;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** First host release where tool search is on unless `tools.toolSearch` turns it off. */
|
|
18
|
+
export const HOST_TOOL_SEARCH_DEFAULT_ON = "2026.9.6";
|
|
19
|
+
|
|
20
|
+
const isRecord = (v: unknown): v is Record<string, unknown> =>
|
|
21
|
+
typeof v === "object" && v !== null && !Array.isArray(v);
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Whether the host's `tools.toolSearch` setting leaves tool search on, read the
|
|
25
|
+
* way the host reads it (2026.9.6+): unset or `true` ⇒ on; `false` ⇒ off; an
|
|
26
|
+
* object ⇒ its `enabled` flag, else on only when it sets other options.
|
|
27
|
+
*/
|
|
28
|
+
export function hostToolSearchEnabled(config: unknown): boolean {
|
|
29
|
+
const tools = isRecord(config) ? config.tools : undefined;
|
|
30
|
+
const raw = isRecord(tools) ? tools.toolSearch : undefined;
|
|
31
|
+
if (raw === undefined || raw === true) return true;
|
|
32
|
+
if (!isRecord(raw)) return false;
|
|
33
|
+
if (typeof raw.enabled === "boolean") return raw.enabled;
|
|
34
|
+
return Object.keys(raw).some(key => key !== "enabled");
|
|
35
|
+
}
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
// Which ClawChat notes the conversation behind a memory / metadata tool call
|
|
2
|
+
// may read. A tool result stays in the calling session's history, and a group
|
|
3
|
+
// is one session for everyone in it, so a note read there can surface to
|
|
4
|
+
// anyone present later. Scope therefore follows the conversation (tool
|
|
5
|
+
// factory ctx), never a parameter or the process environment:
|
|
6
|
+
//
|
|
7
|
+
// owner DM (incl. plugin-started turns there) → every note
|
|
8
|
+
// friend DM → only users/<requesterSenderId>.md
|
|
9
|
+
// group → groups/<this chat>.md + users/<id>.md of present members
|
|
10
|
+
// anything else (unknown session, missing store / peer, contradictions,
|
|
11
|
+
// ambiguous chat-id case, resolution errors) → nothing
|
|
12
|
+
//
|
|
13
|
+
// Chat ids compare case-insensitively (the host lowercases session keys);
|
|
14
|
+
// the original case is recovered before any group file / SQL lookup. Member
|
|
15
|
+
// user ids stay case-sensitive.
|
|
16
|
+
import fs from "node:fs/promises";
|
|
17
|
+
import path from "node:path";
|
|
18
|
+
import { readClawChatMemoryFile, type ClawChatMemoryTarget } from "./clawchat-memory.ts";
|
|
19
|
+
import { resolveOwnerIdentity } from "./owner-identity.ts";
|
|
20
|
+
import type { ClawChatStore } from "./storage.ts";
|
|
21
|
+
import { classifyTurn, parseClawChatSessionKey } from "./turn-boundary.ts";
|
|
22
|
+
|
|
23
|
+
export type MemoryToolContext = {
|
|
24
|
+
sessionKey?: string;
|
|
25
|
+
requesterSenderId?: string | null;
|
|
26
|
+
deliveryContext?: { to?: string };
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
export type MemoryScope = {
|
|
30
|
+
kind: "owner" | "direct" | "group" | "unknown";
|
|
31
|
+
chatId: string;
|
|
32
|
+
members: ReadonlySet<string>;
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
export type MemoryScopeStore = Partial<Pick<ClawChatStore, "lookupActivationIdentity" | "listRecentGroupMessages">>;
|
|
36
|
+
|
|
37
|
+
/** Rows scanned for recent speakers when a group note has no participant list. */
|
|
38
|
+
const SPEAKER_FALLBACK_ROWS = 200;
|
|
39
|
+
|
|
40
|
+
const nothing = (): MemoryScope => ({ kind: "unknown", chatId: "", members: new Set() });
|
|
41
|
+
|
|
42
|
+
async function groupNoteIds(root: string): Promise<string[]> {
|
|
43
|
+
try {
|
|
44
|
+
return (await fs.readdir(path.join(root, "groups")))
|
|
45
|
+
.filter((name) => name.endsWith(".md"))
|
|
46
|
+
.map((name) => name.slice(0, -3));
|
|
47
|
+
} catch (error) {
|
|
48
|
+
if ((error as NodeJS.ErrnoException).code === "ENOENT") return [];
|
|
49
|
+
throw error;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function participantIds(metadata: Record<string, string>): Set<string> {
|
|
54
|
+
return new Set((metadata.participant_ids ?? "").split(",").map((id) => id.trim()).filter(Boolean));
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function inboundSenderId(rawJson: string | null): string | null {
|
|
58
|
+
if (!rawJson) return null;
|
|
59
|
+
const sender = (JSON.parse(rawJson) as { sender?: { id?: unknown } } | null)?.sender?.id;
|
|
60
|
+
return typeof sender === "string" && sender.trim() ? sender.trim() : null;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export async function resolveMemoryScope(input: {
|
|
64
|
+
root: string;
|
|
65
|
+
ctx: MemoryToolContext;
|
|
66
|
+
accountId: string;
|
|
67
|
+
configOwnerUserId?: string | null;
|
|
68
|
+
store: MemoryScopeStore | null;
|
|
69
|
+
warn?: (failureClass: string) => void;
|
|
70
|
+
}): Promise<MemoryScope> {
|
|
71
|
+
const { root, ctx, accountId, store, warn } = input;
|
|
72
|
+
try {
|
|
73
|
+
const parsed = parseClawChatSessionKey(ctx.sessionKey);
|
|
74
|
+
if (!parsed) return nothing();
|
|
75
|
+
if (!store) {
|
|
76
|
+
warn?.("store-unavailable");
|
|
77
|
+
return nothing();
|
|
78
|
+
}
|
|
79
|
+
const lowered = parsed.chatId; // the parser lowercases
|
|
80
|
+
let chatId = lowered;
|
|
81
|
+
let caseKnown = false;
|
|
82
|
+
|
|
83
|
+
// 1. The trusted delivery route usually carries the original-case id.
|
|
84
|
+
const destination = ctx.deliveryContext?.to?.trim()
|
|
85
|
+
.replace(/^(?:clawchat-plugin-openclaw:)?(?:group|direct):/i, "");
|
|
86
|
+
if (destination && destination.toLowerCase() === lowered) {
|
|
87
|
+
chatId = destination;
|
|
88
|
+
caseKnown = true;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// 2. A group note filename, which wins: it is the file that will be read.
|
|
92
|
+
// Two notes differing only in case are ambiguous.
|
|
93
|
+
const fileMatches = (await groupNoteIds(root)).filter((id) => id.toLowerCase() === lowered);
|
|
94
|
+
if (fileMatches.length > 1) {
|
|
95
|
+
warn?.("ambiguous-chat-case");
|
|
96
|
+
return nothing();
|
|
97
|
+
}
|
|
98
|
+
const groupFileId = fileMatches[0];
|
|
99
|
+
if (groupFileId !== undefined) {
|
|
100
|
+
chatId = groupFileId;
|
|
101
|
+
caseKnown = true;
|
|
102
|
+
}
|
|
103
|
+
const members = groupFileId === undefined
|
|
104
|
+
? new Set<string>()
|
|
105
|
+
: participantIds((await readClawChatMemoryFile(root, { targetType: "group", targetId: groupFileId })).metadata);
|
|
106
|
+
|
|
107
|
+
if (parsed.kind === "direct") {
|
|
108
|
+
// A chat the plugin knows as a group can never be a direct chat.
|
|
109
|
+
if (members.size) {
|
|
110
|
+
warn?.("contradictory-chat-kind");
|
|
111
|
+
return nothing();
|
|
112
|
+
}
|
|
113
|
+
const ownerConversationId = resolveOwnerIdentity(store, accountId, input.configOwnerUserId, warn).conversationId;
|
|
114
|
+
if (classifyTurn({ sessionKey: ctx.sessionKey, ownerDirectConversationId: ownerConversationId }) === "owner") {
|
|
115
|
+
return { kind: "owner", chatId: ownerConversationId!, members: new Set() };
|
|
116
|
+
}
|
|
117
|
+
const peer = typeof ctx.requesterSenderId === "string" ? ctx.requesterSenderId.trim() : "";
|
|
118
|
+
if (!peer) warn?.("missing-dm-peer");
|
|
119
|
+
return { kind: "direct", chatId: parsed.chatId, members: new Set(peer ? [peer] : []) };
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// 3. No cached participant list: recent inbound speakers. When neither the
|
|
123
|
+
// route nor a filename supplied the case, the case-insensitive query is
|
|
124
|
+
// the recovery path and its stored chat_id supplies the original case.
|
|
125
|
+
if (!members.size) {
|
|
126
|
+
if (!store.listRecentGroupMessages) {
|
|
127
|
+
warn?.("store-unavailable");
|
|
128
|
+
return nothing();
|
|
129
|
+
}
|
|
130
|
+
const rows = store.listRecentGroupMessages("openclaw", accountId, chatId, SPEAKER_FALLBACK_ROWS, {
|
|
131
|
+
ignoreCase: !caseKnown,
|
|
132
|
+
});
|
|
133
|
+
if (!caseKnown) {
|
|
134
|
+
const storedIds = new Set(rows.map((row) => row.chat_id)
|
|
135
|
+
.filter((id): id is string => typeof id === "string" && id.toLowerCase() === lowered));
|
|
136
|
+
if (storedIds.size > 1) {
|
|
137
|
+
warn?.("ambiguous-chat-case");
|
|
138
|
+
return nothing();
|
|
139
|
+
}
|
|
140
|
+
if (storedIds.size === 1) chatId = [...storedIds][0]!;
|
|
141
|
+
}
|
|
142
|
+
for (const row of rows) {
|
|
143
|
+
if (row.direction !== "inbound") continue;
|
|
144
|
+
try {
|
|
145
|
+
const sender = inboundSenderId(row.raw_json);
|
|
146
|
+
if (sender) members.add(sender);
|
|
147
|
+
} catch {
|
|
148
|
+
warn?.("malformed-group-row");
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
return { kind: "group", chatId, members };
|
|
153
|
+
} catch {
|
|
154
|
+
warn?.("scope-resolution");
|
|
155
|
+
return nothing();
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
export function canReadMemory(scope: MemoryScope, target: ClawChatMemoryTarget): boolean {
|
|
160
|
+
if (scope.kind === "owner") return true;
|
|
161
|
+
if (scope.kind === "unknown" || target.targetType === "owner") return false;
|
|
162
|
+
if (target.targetType === "user") return scope.members.has(target.targetId);
|
|
163
|
+
return scope.kind === "group" && target.targetType === "group" && target.targetId === scope.chatId;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
export function memoryScopeRefusal(scope: MemoryScope, target: ClawChatMemoryTarget): {
|
|
167
|
+
error: "not_readable_here";
|
|
168
|
+
code: "memory_scope";
|
|
169
|
+
message: string;
|
|
170
|
+
} {
|
|
171
|
+
const message = target.targetType === "owner"
|
|
172
|
+
? "owner.md can only be read in your owner's direct chat. Tool results stay in this conversation's history. Do not try to get the owner's notes another way."
|
|
173
|
+
: scope.kind === "direct"
|
|
174
|
+
? "In this direct chat you can only read the note about the person you are talking to."
|
|
175
|
+
: scope.kind === "group"
|
|
176
|
+
? "In a group you can only read this group's note and its members' notes."
|
|
177
|
+
: "This conversation could not be identified; no ClawChat notes are readable here.";
|
|
178
|
+
return { error: "not_readable_here", code: "memory_scope", message };
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/** What a restricted search left out, for the model (never counts). */
|
|
182
|
+
export function memoryScopeNote(scope: MemoryScope): string | undefined {
|
|
183
|
+
if (scope.kind === "direct") {
|
|
184
|
+
return "In this direct chat only the note about the person you are talking to is searched; owner.md, other people's notes and group notes are left out.";
|
|
185
|
+
}
|
|
186
|
+
if (scope.kind === "group") {
|
|
187
|
+
return "In a group only this group's note and its members' notes are searched; owner.md, other groups' notes and notes about non-members are left out.";
|
|
188
|
+
}
|
|
189
|
+
if (scope.kind === "unknown") return "This conversation could not be identified; no ClawChat notes were searched.";
|
|
190
|
+
return undefined;
|
|
191
|
+
}
|