@clawling/clawchat-plugin-openclaw 2026.10.11-3 → 2026.10.19-1

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.
Files changed (51) hide show
  1. package/dist/src/api-client.js +1 -0
  2. package/dist/src/approval-route.js +43 -9
  3. package/dist/src/channel.setup.js +1 -0
  4. package/dist/src/clawchat-memory.js +256 -30
  5. package/dist/src/clawchat-metadata.js +11 -7
  6. package/dist/src/client-ownership.js +147 -0
  7. package/dist/src/config.js +4 -0
  8. package/dist/src/host-tools-guard.js +277 -87
  9. package/dist/src/inbound.js +9 -6
  10. package/dist/src/memory-scope.js +33 -30
  11. package/dist/src/note-injection.js +10 -12
  12. package/dist/src/refresh-manager.js +23 -22
  13. package/dist/src/reply-dispatcher.js +61 -11
  14. package/dist/src/runtime.js +1110 -297
  15. package/dist/src/storage.js +451 -0
  16. package/dist/src/tool-capabilities.js +525 -0
  17. package/dist/src/tool-grants.js +406 -0
  18. package/dist/src/tool-requests.js +116 -0
  19. package/dist/src/tools-schema.js +29 -0
  20. package/dist/src/tools.js +445 -21
  21. package/dist/src/turn-artifacts.js +210 -0
  22. package/dist/src/turn-boundary.js +14 -4
  23. package/dist/src/turn-grants.js +238 -0
  24. package/dist/src/ws-client.js +21 -0
  25. package/openclaw.plugin.json +12 -0
  26. package/package.json +1 -1
  27. package/prompts/platform.md +1 -1
  28. package/src/api-client.ts +1 -0
  29. package/src/approval-route.ts +38 -10
  30. package/src/channel.setup.ts +1 -0
  31. package/src/clawchat-memory.ts +246 -35
  32. package/src/clawchat-metadata.ts +9 -6
  33. package/src/client-ownership.ts +172 -0
  34. package/src/config.ts +8 -0
  35. package/src/host-tools-guard.ts +261 -90
  36. package/src/inbound.ts +18 -14
  37. package/src/memory-scope.ts +28 -28
  38. package/src/note-injection.ts +12 -11
  39. package/src/refresh-manager.ts +30 -25
  40. package/src/reply-dispatcher.ts +54 -13
  41. package/src/runtime.ts +1060 -312
  42. package/src/storage.ts +410 -0
  43. package/src/tool-capabilities.ts +461 -0
  44. package/src/tool-grants.ts +401 -0
  45. package/src/tool-requests.ts +124 -0
  46. package/src/tools-schema.ts +35 -0
  47. package/src/tools.ts +375 -19
  48. package/src/turn-artifacts.ts +202 -0
  49. package/src/turn-boundary.ts +16 -4
  50. package/src/turn-grants.ts +235 -0
  51. package/src/ws-client.ts +18 -0
@@ -1,9 +1,23 @@
1
- import { CHANNEL_ID, normalizeOpenclawClawlingAccountId, resolveOpenclawClawlingAccount } from "./config.js";
2
- import { classifyChat, clawChatSessionAccountId, NON_OWNER_TOOL_ALLOWLIST, normalizeToolChatId, parseClawChatSessionKey, } from "./turn-boundary.js";
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. With `non-owner-host-tools: approve` and a ready approval route
5
+ // (approval-route.ts), a group turn's other tools ask the owner instead:
6
+ // the hook returns `requireApproval` and the host waits for the owner's
7
+ // decision; anything but an approval blocks. Only calls whose session key
8
+ // carries the ClawChat prefix are in scope (D8); grant-management tools
9
+ // enforce their owner boundary on every channel. Tool arguments are never logged.
10
+ import { HOST_FLOOR, hostAtLeast } from "./host-version.js";
11
+ import { CHANNEL_ID, listOpenclawClawlingAccountIds, normalizeOpenclawClawlingAccountId, resolveOpenclawClawlingAccount } from "./config.js";
12
+ import { classifyChat, clawChatSessionAccountId, nonOwnerToolAllowlist, parseClawChatSessionKey, } from "./turn-boundary.js";
3
13
  import { clawChatDbPathForStateDir, getClawChatStore } from "./storage.js";
4
14
  import { resolveOwnerIdentity } from "./owner-identity.js";
5
15
  import { getRegisteredClawChatToolNames } from "./tools.js";
6
16
  import { APPROVAL_ROUTE_WARNINGS, listActiveAccountIds, resolveApprovalRoute, storedIdentityLookup, } from "./approval-route.js";
17
+ import { bindAwaitingTurnRun, grantsForRun, readTurnGrantRun, isOwnerSideRun, isNonOwnerSideSession, hasUnattributedTurn, requestOriginForRun, bindToolRequestCall } from "./turn-grants.js";
18
+ import { GRANT_BLOCK_MESSAGE, GRANT_INTERSECTION_BLOCK_MESSAGE, GRANT_ASK_DENIED_MESSAGE, GRANT_DENIED_MESSAGE, GRANT_ASK_UNAVAILABLE_MESSAGE, GRANT_NOT_AVAILABLE_MESSAGE, GRANT_CROSS_SESSION_MESSAGE, evaluateToolGrant, isCompiledGrants, } from "./tool-grants.js";
19
+ import { AUTHORIZATION_TOOLS, TOOL_CAPABILITIES, canonicalToolName, hostToolTargetsCurrentSession, isCrossChatCall, resolveCrossChatGate } from "./tool-capabilities.js";
20
+ import { recordTurnArtifactCall, registerTurnArtifacts } from "./turn-artifacts.js";
7
21
  export const BLOCK_MESSAGE = "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.";
8
22
  /** Told to the model when the owner denies, or does not answer in time. */
9
23
  export const APPROVAL_TIMEOUT_MESSAGE = "Your owner did not approve this tool call in time, so it did not run. Do not call it again for this request; tell the group your owner did not approve it.";
@@ -19,7 +33,9 @@ const APPROVAL_DESCRIPTION_MAX = 500;
19
33
  * hook again for the inner tool a `tool_call` runs, and that inner call gets
20
34
  * every check below. They are not part of the dispatch allowlist.
21
35
  */
22
- export const HOST_TOOL_SEARCH_CONTROLS = Object.freeze(["tool_search", "tool_describe", "tool_call"]);
36
+ // Current host name: tmp/openclaw/src/agents/tool-search-types.ts,
37
+ // TOOL_CALL_RAW_TOOL_NAME (2026.10.3); retain the older tool_call name too.
38
+ export const HOST_TOOL_SEARCH_CONTROLS = Object.freeze(["tool_search", "tool_describe", "tool_call", "dispatch_action"]);
23
39
  const ACCOUNT_SCOPED_TOOLS = ["clawchat_mention_message", "clawchat_react_message"];
24
40
  const block = () => ({ block: true, blockReason: BLOCK_MESSAGE });
25
41
  const CLAWCHAT_SESSION_PREFIX = new RegExp(`^agent:[^:]+:${CHANNEL_ID}:`, "i");
@@ -44,49 +60,124 @@ export function hasLocalPathArgument(value, field = "") {
44
60
  }
45
61
  /** Pure decision for one tool call. `undefined` lets the call run. */
46
62
  export function decideHostToolCall(input) {
63
+ if (input.toolName === "clawchat_tool_request") {
64
+ const origin = input.sessionKey ? requestOriginForRun(input.sessionKey, input.runId) : undefined;
65
+ const parsed = parseClawChatSessionKey(input.sessionKey);
66
+ return input.toolGrants === "on" && origin && parsed && parsed.kind === origin.sourceChatType
67
+ && parsed.chatId === origin.sourceChatId.trim().toLowerCase()
68
+ && origin.sourceSessionKey.trim().toLowerCase() === input.sessionKey?.trim().toLowerCase()
69
+ && input.senderId === origin.requesterUserId
70
+ && input.ownerUserId && origin.requesterUserId !== input.ownerUserId
71
+ && input.registeredTools.has(input.toolName) ? undefined : block();
72
+ }
73
+ if (AUTHORIZATION_TOOLS.includes(input.toolName)) {
74
+ const parsed = parseClawChatSessionKey(input.sessionKey);
75
+ return parsed && clawChatSessionAccountId(input.sessionKey)
76
+ && classifyChat({ ...parsed, ownerDirectConversationId: input.ownerDirectConversationId }) === "owner"
77
+ && input.toolGrants === "on" && Boolean(input.ownerUserId?.trim())
78
+ && input.senderId === input.ownerUserId ? undefined : block();
79
+ }
47
80
  if (!isClawChatToolCall(input))
48
81
  return undefined;
49
82
  const parsed = parseClawChatSessionKey(input.sessionKey);
50
83
  if (!parsed)
51
84
  return block();
52
- if (classifyChat({ ...parsed, ownerDirectConversationId: input.ownerDirectConversationId }) === "owner") {
85
+ const ownerTurn = classifyChat({ ...parsed, ownerDirectConversationId: input.ownerDirectConversationId }) === "owner";
86
+ // Owner DMs without a plugin snapshot keep owner access: non-owner calls
87
+ // cannot start local turns in other sessions (cross-session). Under that
88
+ // audited premise, only the owner can initiate a snapshot-free turn here.
89
+ // A new host tool missing from the audit could break it; keep it current.
90
+ if ((ownerTurn && !isNonOwnerSideSession(input.sessionKey))
91
+ || (parsed.kind === "group" && isOwnerSideRun(input.sessionKey, input.runId)))
53
92
  return undefined;
93
+ const publicTools = nonOwnerToolAllowlist(input.publicTools);
94
+ // Missing plugin registration takes precedence over missing caller access.
95
+ if ((input.toolGrants === "on" || publicTools.includes(input.toolName))
96
+ && input.toolName.startsWith("clawchat_") && !input.registeredTools.has(input.toolName)) {
97
+ return input.toolGrants === "on"
98
+ ? { block: true, reasonCode: "not-available", blockReason: GRANT_NOT_AVAILABLE_MESSAGE } : block();
54
99
  }
55
100
  // Approve mode: only group turns, and only once the route to the owner's
56
- // direct chat is live. Friend direct chats never ask; they block below.
57
- const approve = parsed.kind === "group" && input.approvalRoute?.ready === true;
58
- const allowlisted = NON_OWNER_TOOL_ALLOWLIST.includes(input.toolName);
101
+ // direct chat is live. Friend direct chats ask only for an ask grant.
102
+ const approve = parsed.kind === "group" && input.nonOwnerHostTools !== "off"
103
+ && input.approvalRoute?.ready === true;
104
+ const run = readTurnGrantRun(input.sessionKey, input.runId);
105
+ if (run.kind === "unavailable")
106
+ return block();
107
+ const details = run.kind === "bound" ? run.details : { speakers: [], anyGrantedTools: new Set() };
108
+ const hasGrantCaller = input.toolGrants === "on" && Boolean(input.senderId)
109
+ && details.speakers.some(speaker => speaker.id === input.senderId);
110
+ // Catalog execution omits requester. A bound run must still restrict public
111
+ // defaults with its intersected ask/deny rules; it is not an unknown caller.
112
+ // Other tools retain their requirement for a matching requester identity.
113
+ const hasGrantContext = hasGrantCaller || (input.toolGrants === "on" && run.kind === "bound"
114
+ && !input.senderId?.trim() && publicTools.includes(input.toolName));
115
+ const grants = hasGrantContext && run.kind === "bound" ? run.grants : new Map();
116
+ const anyGrantedTools = hasGrantContext ? details.anyGrantedTools : new Set();
117
+ const context = { chatId: parsed.chatId, sessionKey: input.sessionKey, accountId: input.accountId };
118
+ const grant = evaluateToolGrant(grants, input.toolName, input.params, context);
119
+ // A host-started run without a bound speaker cannot use approve fallback,
120
+ // even when per-person grants are off.
121
+ const effectiveApprove = approve && !hasUnattributedTurn(input.sessionKey)
122
+ && (input.toolGrants === "on" ? hasGrantContext : details.speakers.length > 0);
123
+ const multipleCallers = (details.nonOwnerSpeakerIds?.size
124
+ ?? details.speakers.filter(speaker => speaker.id !== input.ownerUserId).length) > 1;
125
+ const grantBlock = (denied = false) => ({ block: true,
126
+ reasonCode: multipleCallers ? "batch-intersection" : denied ? "denied" : "not-granted",
127
+ blockReason: multipleCallers ? GRANT_INTERSECTION_BLOCK_MESSAGE : denied ? GRANT_DENIED_MESSAGE : GRANT_BLOCK_MESSAGE,
128
+ });
129
+ // Forwarded arguments belong to the inner call, whose hook is authoritative.
130
+ if (grant === "deny")
131
+ return grantBlock(true);
132
+ if (HOST_TOOL_SEARCH_CONTROLS.includes(input.toolName) && (grants.size > 0 || grants.get("")))
133
+ return undefined;
134
+ const ask = grant === "ask" && input.approvalRoute?.ready === true;
135
+ const allowlisted = publicTools.includes(input.toolName);
59
136
  // Outer pre-check only. The inner tool call is checked again and that check
60
137
  // is authoritative: the host also accepts dotted/flattened argument keys
61
138
  // that this outer check may not recognise. Under approve, tools the owner
62
139
  // approves one by one (and the tool-search controls that reach them) show
63
140
  // the owner their arguments instead; the allowlisted tools, which run
64
141
  // without asking, keep the check.
65
- if ((!approve || allowlisted) && hasLocalPathArgument(input.params))
142
+ const directLocalTool = grant === "direct" && [TOOL_CAPABILITIES.files, TOOL_CAPABILITIES.terminal, TOOL_CAPABILITIES.code].some(cap => cap.tools.includes(canonicalToolName(input.toolName)));
143
+ if (!directLocalTool && (grant === "direct" || (grant !== "ask" && (allowlisted || !effectiveApprove))) && hasLocalPathArgument(input.params))
66
144
  return block();
67
145
  if (HOST_TOOL_SEARCH_CONTROLS.includes(input.toolName))
68
146
  return undefined;
69
- if (ACCOUNT_SCOPED_TOOLS.includes(input.toolName)) {
70
- // D9: a non-owner turn may only act on its own chat, named explicitly.
71
- // The tool resolves the same argument with the same helper, so the chat
72
- // checked here is the chat the call reaches.
73
- const target = normalizeToolChatId(input.params.chatId).toLowerCase();
74
- if (!target || target !== parsed.chatId) {
75
- return {
76
- block: true,
77
- blockReason: input.toolName === "clawchat_react_message"
78
- ? "Pass this chat's chatId to react here."
79
- : "Pass this chat's chatId to mention here.",
80
- };
81
- }
147
+ // Check only audited cross-session host tools before direct/ask/approve exits.
148
+ // Other tools' business data is opaque here; D9 remains a separate check.
149
+ if ((grant === "direct" || grant === "ask" || allowlisted || effectiveApprove)
150
+ && !hostToolTargetsCurrentSession(input.toolName, input.params, input.sessionKey)) {
151
+ return { block: true, reasonCode: "cross-session", blockReason: GRANT_CROSS_SESSION_MESSAGE };
82
152
  }
83
- if (allowlisted) {
84
- return input.toolName.startsWith("clawchat_") && !input.registeredTools.has(input.toolName)
85
- ? block()
86
- : undefined;
153
+ // D9 follows authorization. Missing/opaque targets are cross-chat, never
154
+ // implicitly this chat. Defaults and approve fallback cannot open that gate.
155
+ const crossChatGate = resolveCrossChatGate({ crossChat: isCrossChatCall(input.toolName, input.params, context) }, grant);
156
+ if (crossChatGate === "block")
157
+ return grantBlock();
158
+ if (hasGrantContext && isCompiledGrants(grants) && !grant)
159
+ return grantBlock();
160
+ if (allowlisted && grant !== "ask")
161
+ return undefined;
162
+ if (grant === "direct")
163
+ return undefined;
164
+ if (grant === "ask") {
165
+ if (!ask)
166
+ return { block: true, reasonCode: "ask-unavailable", blockReason: GRANT_ASK_UNAVAILABLE_MESSAGE };
167
+ const approval = buildHostToolApproval(input.toolName, parsed.chatId, input.params, {
168
+ kind: parsed.kind, ...details,
169
+ });
170
+ return approval ? { requireApproval: approval } : { block: true, blockReason: APPROVAL_UNSHOWABLE_MESSAGE };
87
171
  }
88
- if (!approve)
89
- return block();
172
+ if (input.toolGrants === "on" && !effectiveApprove) {
173
+ if (multipleCallers && anyGrantedTools.has(input.toolName)) {
174
+ return { block: true, reasonCode: "batch-intersection", blockReason: GRANT_INTERSECTION_BLOCK_MESSAGE };
175
+ }
176
+ }
177
+ if (isCompiledGrants(grants))
178
+ return grantBlock();
179
+ if (!effectiveApprove)
180
+ return input.toolGrants === "on" ? grantBlock() : block();
90
181
  const approval = buildHostToolApproval(input.toolName, parsed.chatId, input.params);
91
182
  return approval ? { requireApproval: approval } : { block: true, blockReason: APPROVAL_UNSHOWABLE_MESSAGE };
92
183
  }
@@ -109,7 +200,7 @@ export const APPROVAL_UNSHOWABLE_MESSAGE = "This call cannot be sent to your own
109
200
  * holding characters the host would escape or hide): the owner must never
110
201
  * approve a command they could not read, so such a call blocks.
111
202
  */
112
- export function buildHostToolApproval(toolName, groupChatId, params) {
203
+ export function buildHostToolApproval(toolName, groupChatId, params, grantContext) {
113
204
  const tool = cleanText(toolName, 48);
114
205
  let args;
115
206
  try {
@@ -123,17 +214,21 @@ export function buildHostToolApproval(toolName, groupChatId, params) {
123
214
  const unattended = STARTS_UNATTENDED_WORK.test(toolName)
124
215
  ? " Approving starts work that later runs with every host tool and is not asked about again."
125
216
  : "";
126
- const before = `A turn in ClawChat group ${cleanText(groupChatId, 64)} (anyone in the group may have asked) wants to run ` +
217
+ const conversation = grantContext?.kind === "direct" ? "friend direct chat" : "group";
218
+ const speakers = grantContext
219
+ ? ` (requested by ${cleanText(grantContext.speakers.map(s => `${s.name} (${s.id})`).join(", "), 100)})`
220
+ : " (anyone in the group may have asked)";
221
+ const before = `A turn in ClawChat ${conversation} ${cleanText(groupChatId, 64)}${speakers} wants to run ` +
127
222
  `the host tool ${tool} on this machine. Arguments: `;
128
223
  const after = `.${unattended} Allow only if you would run it yourself.`;
129
224
  if (before.length + args.length + after.length > APPROVAL_DESCRIPTION_MAX)
130
225
  return null;
131
226
  return {
132
- title: cleanText(`ClawChat group wants to run ${tool}`, APPROVAL_TITLE_MAX),
227
+ title: cleanText(`ClawChat ${conversation} wants to run ${tool}`, APPROVAL_TITLE_MAX),
133
228
  description: `${before}${args}${after}`,
134
229
  severity: "warning",
135
230
  timeoutMs: APPROVAL_TIMEOUT_MS,
136
- timeoutReason: APPROVAL_TIMEOUT_MESSAGE,
231
+ timeoutReason: grantContext ? GRANT_ASK_DENIED_MESSAGE : APPROVAL_TIMEOUT_MESSAGE,
137
232
  allowedDecisions: ["allow-once", "deny"],
138
233
  };
139
234
  }
@@ -171,16 +266,29 @@ export function registerHostToolsGuard(api, options = {}) {
171
266
  const current = api.runtime.config?.current;
172
267
  return (typeof current === "function" ? current.call(api.runtime.config) : api.config);
173
268
  };
174
- // Only consulted for group calls. `undefined` = the account is not in
175
- // approve mode; a route that is not ready blocks like `off`.
176
- const groupApprovalRoute = (accountId) => {
269
+ // Ask grants also need the managed route for friend direct chats.
270
+ const approvalRoute = (accountId, hasAskSnapshot = false) => {
177
271
  try {
178
272
  const cfg = readLiveConfig();
179
273
  const active = listActiveAccountIds(cfg, { knownActive: accountId, hasStoredIdentity });
180
274
  const routeAccountId = accountId ?? (active.length === 1 ? active[0] : undefined);
181
- const anyApprove = (routeAccountId ? [routeAccountId] : active).some(id => resolveOpenclawClawlingAccount(cfg, id).nonOwnerHostTools === "approve");
275
+ const anyApprove = (routeAccountId ? [routeAccountId] : active).some(id => {
276
+ const account = resolveOpenclawClawlingAccount(cfg, id);
277
+ if (account.nonOwnerHostTools === "approve")
278
+ return true;
279
+ if (account.toolGrants === "on" && hasAskSnapshot)
280
+ return true;
281
+ try {
282
+ return account.toolGrants === "on" && openStore().hasActiveAskGrant(id, Date.now());
283
+ }
284
+ catch {
285
+ return false;
286
+ }
287
+ });
182
288
  if (!anyApprove)
183
289
  return undefined;
290
+ if (!hostAtLeast(api.runtime.version, HOST_FLOOR))
291
+ return { ready: false, reason: "not-applied" };
184
292
  const route = routeAccountId
185
293
  ? resolveApprovalRoute({
186
294
  liveConfig: cfg,
@@ -202,69 +310,151 @@ export function registerHostToolsGuard(api, options = {}) {
202
310
  api.on("before_tool_call", (event, sdkCtx) => {
203
311
  // `requester` is host 2026.8.1+ (the floor); the development SDK types predate it.
204
312
  const ctx = sdkCtx;
205
- try {
206
- if (!ctx) {
207
- warn("missing-context");
208
- return block();
209
- }
210
- const sessionKey = ctx.sessionKey;
211
- if (!isClawChatToolCall({ sessionKey }))
212
- return undefined;
213
- const requester = ctx.requester;
214
- const parsed = parseClawChatSessionKey(sessionKey);
215
- if (!parsed || (requester?.channel && requester.channel !== CHANNEL_ID)) {
216
- warn("unknown-session");
217
- return block();
218
- }
219
- // Direct keys carry their account; group keys do not. A group turn is
220
- // non-owner whoever speaks, so a group call without the requester
221
- // account (host tool-search controls and the inner calls they run get
222
- // a run-level ctx without one) still gets the non-owner policy; only
223
- // the tools that act as an account need it. Conflicting ids block.
224
- const keyAccountId = clawChatSessionAccountId(sessionKey);
225
- const requesterAccountId = requester?.accountId?.trim()
226
- ? normalizeOpenclawClawlingAccountId(requester.accountId)
227
- : undefined;
228
- const accountId = requesterAccountId ?? keyAccountId;
229
- if (keyAccountId && keyAccountId !== accountId) {
230
- warn("account-identity");
231
- return block();
232
- }
233
- if (!accountId) {
234
- if (parsed.kind !== "group" || ACCOUNT_SCOPED_TOOLS.includes(event.toolName)) {
313
+ const decision = (() => {
314
+ try {
315
+ if (!ctx) {
316
+ warn("missing-context");
317
+ return block();
318
+ }
319
+ const sessionKey = ctx.sessionKey;
320
+ const runId = ctx.runId ?? event.runId;
321
+ if (sessionKey && isClawChatToolCall({ sessionKey }) && ctx.requester?.senderId?.trim()
322
+ && (!ctx.requester.channel || ctx.requester.channel === CHANNEL_ID)) {
323
+ bindAwaitingTurnRun(sessionKey, runId, ctx.requester.senderId);
324
+ }
325
+ if (event.toolName === "clawchat_tool_request") {
326
+ const origin = sessionKey ? requestOriginForRun(sessionKey, runId) : undefined;
327
+ if (!origin || !sessionKey || !ctx.requester?.senderId
328
+ || (ctx.requester.channel && ctx.requester.channel !== CHANNEL_ID)
329
+ || (ctx.requester.accountId && normalizeOpenclawClawlingAccountId(ctx.requester.accountId) !== origin.accountId)
330
+ || (clawChatSessionAccountId(sessionKey) && clawChatSessionAccountId(sessionKey) !== origin.accountId))
331
+ return block();
332
+ const account = resolveOpenclawClawlingAccount(readLiveConfig(), origin.accountId);
333
+ const ownerUserId = options.ownerUserId ? options.ownerUserId(origin.accountId)
334
+ : resolveOwnerIdentity(openStore(), origin.accountId, account.ownerUserId).ownerUserId;
335
+ const decision = decideHostToolCall({ toolName: event.toolName, params: event.params ?? {}, sessionKey, runId,
336
+ senderId: ctx.requester.senderId, ownerUserId: ownerUserId ?? undefined, toolGrants: account.toolGrants,
337
+ registeredTools: getRegisteredClawChatToolNames() });
338
+ const callId = ctx.toolCallId
339
+ ?? event.toolCallId;
340
+ return decision ?? (bindToolRequestCall(sessionKey, runId, callId) ? undefined : block());
341
+ }
342
+ if (AUTHORIZATION_TOOLS.includes(event.toolName)
343
+ && (!clawChatSessionAccountId(sessionKey) || !ctx.requester?.senderId))
344
+ return block();
345
+ if (!isClawChatToolCall({ sessionKey }))
346
+ return undefined;
347
+ const requester = ctx.requester;
348
+ const parsed = parseClawChatSessionKey(sessionKey);
349
+ if (!parsed || (requester?.channel && requester.channel !== CHANNEL_ID)) {
350
+ warn("unknown-session");
351
+ return block();
352
+ }
353
+ // Direct keys carry their account; group keys do not. A group turn is
354
+ // classified as non-owner at the session level; owner-only runs carry
355
+ // a separate snapshot. A group call without the requester
356
+ // account (host tool-search controls and the inner calls they run get
357
+ // a run-level ctx without one) still gets the non-owner policy; only
358
+ // the tools that act as an account need it. Conflicting ids block.
359
+ const keyAccountId = clawChatSessionAccountId(sessionKey);
360
+ const requesterAccountId = requester?.accountId?.trim()
361
+ ? normalizeOpenclawClawlingAccountId(requester.accountId)
362
+ : undefined;
363
+ const accountId = requesterAccountId ?? keyAccountId;
364
+ if (keyAccountId && keyAccountId !== accountId) {
235
365
  warn("account-identity");
236
366
  return block();
237
367
  }
368
+ if (!accountId && parsed.kind === "group" && isOwnerSideRun(sessionKey, runId)) {
369
+ return decideHostToolCall({ toolName: event.toolName, params: event.params ?? {}, sessionKey, runId,
370
+ registeredTools: getRegisteredClawChatToolNames() });
371
+ }
372
+ if (!accountId) {
373
+ if (parsed.kind !== "group" || ACCOUNT_SCOPED_TOOLS.includes(event.toolName)) {
374
+ warn("account-identity");
375
+ return block();
376
+ }
377
+ const cfg = readLiveConfig();
378
+ const run = readTurnGrantRun(sessionKey, runId);
379
+ if (run.kind === "unavailable")
380
+ return block();
381
+ const snapshotAccountId = run.kind === "bound" ? run.details.accountId : undefined;
382
+ let account = null;
383
+ try {
384
+ if (snapshotAccountId) {
385
+ // Unknown IDs otherwise inherit root settings. A removed account
386
+ // must not acquire another account's public policy that way.
387
+ if (listOpenclawClawlingAccountIds(cfg).includes(snapshotAccountId)) {
388
+ account = resolveOpenclawClawlingAccount(cfg, snapshotAccountId);
389
+ }
390
+ }
391
+ else {
392
+ const active = listActiveAccountIds(cfg, { hasStoredIdentity });
393
+ if (active.length === 1)
394
+ account = resolveOpenclawClawlingAccount(cfg, active[0]);
395
+ }
396
+ }
397
+ catch { /* Missing/unreadable dispatch accounts keep the conservative fallback. */ }
398
+ nonOwnerToolAllowlist(account?.publicTools, api.logger);
399
+ return decideHostToolCall({
400
+ toolName: event.toolName,
401
+ params: event.params ?? {},
402
+ sessionKey,
403
+ runId,
404
+ ownerDirectConversationId: null,
405
+ registeredTools: getRegisteredClawChatToolNames(),
406
+ senderId: requester?.senderId,
407
+ approvalRoute: approvalRoute(account?.accountId, [...grantsForRun(sessionKey, runId).values()].includes("ask")),
408
+ accountId: account?.accountId,
409
+ publicTools: account?.publicTools,
410
+ // A bound run can have grants disabled. Use its recorded mode;
411
+ // older/ambiguous snapshots keep the conservative grants-on path.
412
+ toolGrants: account?.toolGrants ?? (run.kind === "bound" ? run.details.toolGrants ?? "on" : "off"),
413
+ nonOwnerHostTools: account?.nonOwnerHostTools ?? "off",
414
+ });
415
+ }
416
+ let ownerDirectConversationId = null;
417
+ try {
418
+ ownerDirectConversationId = lookupOwnerConversation(accountId);
419
+ }
420
+ catch {
421
+ // Treated as missing below; the allowlist still applies.
422
+ }
423
+ if (!ownerDirectConversationId?.trim())
424
+ warn("missing-owner-conversation");
425
+ const account = resolveOpenclawClawlingAccount(readLiveConfig(), accountId);
426
+ nonOwnerToolAllowlist(account.publicTools, api.logger);
427
+ let ownerUserId = null;
428
+ if (AUTHORIZATION_TOOLS.includes(event.toolName)) {
429
+ try {
430
+ ownerUserId = options.ownerUserId ? options.ownerUserId(accountId)
431
+ : resolveOwnerIdentity(openStore(), accountId, account.ownerUserId).ownerUserId;
432
+ }
433
+ catch { /* A missing owner identity cannot authorize grant tools. */ }
434
+ }
238
435
  return decideHostToolCall({
239
436
  toolName: event.toolName,
240
437
  params: event.params ?? {},
241
438
  sessionKey,
242
- ownerDirectConversationId: null,
439
+ runId,
440
+ ownerDirectConversationId,
243
441
  registeredTools: getRegisteredClawChatToolNames(),
244
- approvalRoute: groupApprovalRoute(undefined),
442
+ accountId,
443
+ publicTools: account.publicTools,
444
+ toolGrants: account.toolGrants,
445
+ nonOwnerHostTools: account.nonOwnerHostTools,
446
+ ownerUserId: ownerUserId ?? undefined,
447
+ senderId: requester?.senderId,
448
+ approvalRoute: approvalRoute(accountId, [...grantsForRun(sessionKey, runId).values()].includes("ask")),
245
449
  });
246
450
  }
247
- let ownerDirectConversationId = null;
248
- try {
249
- ownerDirectConversationId = lookupOwnerConversation(accountId);
250
- }
251
451
  catch {
252
- // Treated as missing below; the allowlist still applies.
452
+ warn("handler-exception");
453
+ return block();
253
454
  }
254
- if (!ownerDirectConversationId?.trim())
255
- warn("missing-owner-conversation");
256
- return decideHostToolCall({
257
- toolName: event.toolName,
258
- params: event.params ?? {},
259
- sessionKey,
260
- ownerDirectConversationId,
261
- registeredTools: getRegisteredClawChatToolNames(),
262
- ...(parsed.kind === "group" ? { approvalRoute: groupApprovalRoute(accountId) } : {}),
263
- });
264
- }
265
- catch {
266
- warn("handler-exception");
267
- return block();
268
- }
455
+ })();
456
+ recordTurnArtifactCall(event, ctx, decision === undefined || "requireApproval" in decision);
457
+ return decision;
269
458
  });
459
+ api.on("after_tool_call", (event, ctx) => registerTurnArtifacts(event, ctx));
270
460
  }
@@ -67,6 +67,8 @@ function extractMentionUsers(fragments) {
67
67
  .filter((mention) => mention !== null);
68
68
  }
69
69
  function normalizeMentionUsers(mentions) {
70
+ if (!Array.isArray(mentions))
71
+ return [];
70
72
  return mentions
71
73
  .map((mention) => {
72
74
  if (typeof mention === "string" && mention.length > 0)
@@ -181,7 +183,7 @@ export async function dispatchOpenclawClawlingInbound(params) {
181
183
  return;
182
184
  }
183
185
  const fragmentMentionUsers = extractMentionUsers(message.body.fragments);
184
- const contextMentionUsers = normalizeMentionUsers(message.context.mentions);
186
+ const contextMentionUsers = normalizeMentionUsers(message.context?.mentions);
185
187
  const mentionedUsers = mergeMentionUsers(fragmentMentionUsers, contextMentionUsers);
186
188
  const mentionIds = mentionedUserIds(mentionedUsers);
187
189
  const rawBody = fragmentsToText(message.body.fragments, {
@@ -225,13 +227,14 @@ export async function dispatchOpenclawClawlingInbound(params) {
225
227
  // when a backend reply_mode="all" row overrides the static config.
226
228
  // Direct chats are unaffected (detectMention returns true for direct).
227
229
  log?.info?.(`[${account.accountId}] clawchat-plugin-openclaw inbound event=${envelope.event} msg=${payload.message_id} from=${sender.id} text_len=${rawBody.length} mentioned=${wasMentioned}`);
228
- const replyCtx = message.context.reply
230
+ const reply = message.context?.reply;
231
+ const replyCtx = reply
229
232
  ? {
230
- replyToMessageId: message.context.reply.reply_to_msg_id,
233
+ replyToMessageId: reply.reply_to_msg_id,
231
234
  replyPreviewChatId: chatId,
232
- replyPreviewSenderId: message.context.reply.reply_preview.id ?? "",
233
- replyPreviewNickName: message.context.reply.reply_preview.nick_name ?? "",
234
- replyPreviewText: fragmentsToText(message.context.reply.reply_preview.fragments),
235
+ replyPreviewSenderId: reply.reply_preview?.id ?? "",
236
+ replyPreviewNickName: reply.reply_preview?.nick_name ?? "",
237
+ replyPreviewText: fragmentsToText((Array.isArray(reply.reply_preview?.fragments) ? reply.reply_preview.fragments : [])),
235
238
  }
236
239
  : undefined;
237
240
  await params.ingest({
@@ -5,15 +5,17 @@
5
5
  // factory ctx), never a parameter or the process environment:
6
6
  //
7
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
8
+ // friend DM → owner.md + users/<requesterSenderId>.md
9
+ // group → owner.md + groups/<this chat>.md + users/<id>.md of present members
10
10
  // anything else (unknown session, missing store / peer, contradictions,
11
11
  // ambiguous chat-id case, resolution errors) → nothing
12
12
  //
13
- // Writes: replace / edit need a readable note; append may go to any note
14
- // except owner.md, which only the owner DM writes. owner.md is injected into
15
- // the owner DM, where the agent has every tool, so text appended from a
16
- // friend or group turn would read there as the owner's own note.
13
+ // owner.md body is also readable in a friend DM and a group: it is injected
14
+ // into every turn, and what to say from it is left to the agent (platform
15
+ // prompt). Its metadata block (behavior, profile) stays owner-DM-only for
16
+ // the metadata tools.
17
+ //
18
+ // Writes: replace / edit need a readable note; append may go to any note.
17
19
  //
18
20
  // Chat ids compare case-insensitively (the host lowercases session keys);
19
21
  // the original case is recovered before any group file / SQL lookup. Member
@@ -138,6 +140,7 @@ export async function resolveMemoryScope(input) {
138
140
  return nothing();
139
141
  }
140
142
  }
143
+ /** Whether the metadata tools may read or change the target (owner.md: owner DM only). */
141
144
  export function canReadMemory(scope, target) {
142
145
  if (scope.kind === "owner")
143
146
  return true;
@@ -147,41 +150,41 @@ export function canReadMemory(scope, target) {
147
150
  return scope.members.has(target.targetId);
148
151
  return scope.kind === "group" && target.targetType === "group" && target.targetId === scope.chatId;
149
152
  }
150
- /** Whether a write (`append` when `append` is true) may touch the target. */
151
- export function canWriteMemory(scope, target, append) {
153
+ /** Whether a note body may be read, searched, replaced or edited. */
154
+ export function canReadNote(scope, target) {
155
+ // A direct chat with no trusted peer reads nothing, owner.md included.
152
156
  if (target.targetType === "owner")
153
- return scope.kind === "owner";
154
- return append || canReadMemory(scope, target);
157
+ return scope.kind !== "unknown" && (scope.kind !== "direct" || scope.members.size > 0);
158
+ return canReadMemory(scope, target);
155
159
  }
156
- /** Refusal for an owner.md write outside the owner DM, with where to write instead. */
157
- export function ownerNoteWriteRefusal(scope) {
158
- const base = "owner.md can only be changed in your owner's direct chat.";
159
- const message = scope.kind === "direct"
160
- ? `${base} Record this in the note about the person you are talking to (their users/<id>.md) instead.`
161
- : scope.kind === "group"
162
- ? `${base} Record this in the speaker's own note (users/<id>.md) or this group's note instead; if the owner wants it in owner.md, they can tell you in their direct chat.`
163
- : base;
164
- return { error: "not_writable_here", code: "memory_scope", message };
160
+ /** Whether a write (`append` when `append` is true) may touch the target. */
161
+ export function canWriteMemory(scope, target, append) {
162
+ return append || canReadNote(scope, target);
165
163
  }
166
164
  export function memoryScopeRefusal(scope, target) {
167
- const message = target.targetType === "owner"
168
- ? "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."
169
- : scope.kind === "direct"
170
- ? "In this direct chat you can only read the note about the person you are talking to."
171
- : scope.kind === "group"
172
- ? "In a group you can only read this group's note and its members' notes."
173
- : "This conversation could not be identified; no ClawChat notes are readable here.";
165
+ // For a known peer, owner-body reads are allowed; only metadata can reach
166
+ // the owner-target refusal below. A missing peer blocks every note body.
167
+ const message = scope.kind === "direct" && scope.members.size === 0
168
+ ? "The other person's identity could not be confirmed; no ClawChat notes are readable in this direct chat."
169
+ : target.targetType === "owner" && scope.kind !== "unknown"
170
+ ? "owner.md metadata can only be read or changed in your owner's direct chat."
171
+ : scope.kind === "direct"
172
+ ? "In this direct chat you can only read the note about the person you are talking to."
173
+ : scope.kind === "group"
174
+ ? "In a group you can only read this group's note and its members' notes."
175
+ : "This conversation could not be identified; no ClawChat notes are readable here.";
174
176
  return { error: "not_readable_here", code: "memory_scope", message };
175
177
  }
176
178
  /** What a restricted search left out, for the model (never counts). */
177
179
  export function memoryScopeNote(scope) {
180
+ if (scope.kind === "unknown" || (scope.kind === "direct" && scope.members.size === 0)) {
181
+ return "This conversation could not be identified; no ClawChat notes were searched.";
182
+ }
178
183
  if (scope.kind === "direct") {
179
- 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.";
184
+ return "In this direct chat only owner.md and the note about the person you are talking to are searched; other people's notes and group notes are left out.";
180
185
  }
181
186
  if (scope.kind === "group") {
182
- 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.";
187
+ return "In a group only owner.md, that group's note and its members' notes are searched; other groups' notes and notes about non-members are left out.";
183
188
  }
184
- if (scope.kind === "unknown")
185
- return "This conversation could not be identified; no ClawChat notes were searched.";
186
189
  return undefined;
187
190
  }