@clawling/clawchat-plugin-openclaw 2026.10.9-4 → 2026.10.11-2

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/dist/index.js CHANGED
@@ -43,6 +43,16 @@ const channelEntry = defineChannelPluginEntry({
43
43
  * instead. Each load registers the commands once; a duplicate registration
44
44
  * would mark the plugin as errored on the host side.
45
45
  */
46
+ /**
47
+ * The host-tools guard (`before_tool_call`) follows the same rule, for the
48
+ * same reason: on OpenClaw 2026.8.x the agent run uses the hook runner of the
49
+ * request-scoped `discovery` registry, so a guard registered only in
50
+ * `registerFull` never fires there (measured on 2026.8.2: the call ran with no
51
+ * hook invocation). Every load except `cli-metadata` gets the guard exactly
52
+ * once: `registerFull` covers `full` / `tool-discovery`, the wrapper covers
53
+ * the rest.
54
+ */
55
+ const FULL_REGISTRATION_MODES = new Set(["full", "tool-discovery"]);
46
56
  export default {
47
57
  // Newer hosts expose `configSchema` as a lazy getter; the spread evaluates it
48
58
  // once here, which is fine because the schema is a static object.
@@ -51,6 +61,8 @@ export default {
51
61
  channelEntry.register(api);
52
62
  if (api.registrationMode !== "cli-metadata") {
53
63
  registerOpenclawClawlingCommands(api);
64
+ if (!FULL_REGISTRATION_MODES.has(String(api.registrationMode)))
65
+ registerHostToolsGuard(api);
54
66
  }
55
67
  },
56
68
  };
@@ -0,0 +1,282 @@
1
+ // Where `non-owner-host-tools: approve` sends a group turn's host-tool request.
2
+ //
3
+ // The host asks for a plugin approval when `before_tool_call` returns
4
+ // `requireApproval`; it shows that request in a chat only through the global
5
+ // `approvals.plugin` forwarding config (OpenClaw 2026.8.1+). Without it the
6
+ // request is posted nowhere and simply times out. Its default `session` mode
7
+ // would answer in the chat the turn came from — the group — so the plugin
8
+ // manages one exact block instead: `mode: "targets"`, one target (the owner's
9
+ // direct chat on the one ClawChat account), and a `sessionFilter` that matches
10
+ // ClawChat group sessions only.
11
+ //
12
+ // The plugin writes that block only when the host has none, and later rewrites
13
+ // or removes only a block of exactly that shape. Any other `approvals.plugin`
14
+ // is the owner's own and is never touched; approve then cannot route and group
15
+ // calls stay blocked. A group call asks for approval only while the LIVE
16
+ // config holds the managed block for this account and its current owner chat.
17
+ import fs from "node:fs";
18
+ import path from "node:path";
19
+ import { CHANNEL_ID, listOpenclawClawlingAccountIds, resolveOpenclawClawlingAccount } from "./config.js";
20
+ /** Substring of every ClawChat group session key (`agent:<id>:<channel>:group:<chat>`). */
21
+ export const APPROVAL_SESSION_FILTER = `:${CHANNEL_ID}:group:`;
22
+ function isRec(value) {
23
+ return Boolean(value) && typeof value === "object" && !Array.isArray(value);
24
+ }
25
+ const norm = (value) => (typeof value === "string" ? value.trim().toLowerCase() : "");
26
+ /** Keys holding a value; a loader may add `undefined` placeholders. */
27
+ const definedKeys = (rec) => Object.keys(rec).filter((key) => rec[key] !== undefined).sort().join(",");
28
+ /**
29
+ * Enabled ClawChat accounts in use: those whose config section carries an
30
+ * identity (token or user id), those with a stored activation
31
+ * (`hasStoredIdentity`, the SQLite row; credentials may live only there), plus
32
+ * `knownActive` (an account the caller knows is running). Every caller must
33
+ * count the same way, or two accounts' runtimes would disagree about the one
34
+ * global block. The root section always lists as an account, so an empty root
35
+ * next to named accounts does not count.
36
+ */
37
+ export function listActiveAccountIds(cfg, options = {}) {
38
+ const { knownActive, hasStoredIdentity } = options;
39
+ const ids = new Set();
40
+ for (const id of listOpenclawClawlingAccountIds(cfg)) {
41
+ const account = resolveOpenclawClawlingAccount(cfg, id);
42
+ if (!account.enabled)
43
+ continue;
44
+ let stored = false;
45
+ try {
46
+ stored = hasStoredIdentity?.(account.accountId) === true;
47
+ }
48
+ catch {
49
+ // Unknown counts as in use: more accounts only ever blocks.
50
+ stored = true;
51
+ }
52
+ if (account.token || account.userId || stored || norm(id) === norm(knownActive))
53
+ ids.add(account.accountId);
54
+ }
55
+ if (knownActive?.trim() && ![...ids].some((id) => norm(id) === norm(knownActive)))
56
+ ids.add(knownActive.trim());
57
+ return [...ids];
58
+ }
59
+ /** The exact `approvals.plugin` block the plugin writes for `target`. */
60
+ export function buildManagedApprovalsPlugin(target) {
61
+ return {
62
+ enabled: true,
63
+ mode: "targets",
64
+ sessionFilter: [APPROVAL_SESSION_FILTER],
65
+ targets: [{ channel: CHANNEL_ID, to: target.to, accountId: target.accountId }],
66
+ };
67
+ }
68
+ /** Classify the host's `approvals.plugin`; only the exact managed shape is ours. */
69
+ export function readApprovalsPluginState(cfg) {
70
+ const approvals = isRec(cfg) ? cfg.approvals : undefined;
71
+ if (approvals === undefined || (isRec(approvals) && approvals.plugin === undefined)) {
72
+ return { kind: "absent" };
73
+ }
74
+ if (!isRec(approvals) || !isRec(approvals.plugin))
75
+ return { kind: "foreign" };
76
+ const block = approvals.plugin;
77
+ if (definedKeys(block) !== "enabled,mode,sessionFilter,targets")
78
+ return { kind: "foreign" };
79
+ if (block.enabled !== true || block.mode !== "targets")
80
+ return { kind: "foreign" };
81
+ const filter = block.sessionFilter;
82
+ if (!Array.isArray(filter) || filter.length !== 1 || filter[0] !== APPROVAL_SESSION_FILTER) {
83
+ return { kind: "foreign" };
84
+ }
85
+ const targets = block.targets;
86
+ if (!Array.isArray(targets) || targets.length !== 1 || !isRec(targets[0]))
87
+ return { kind: "foreign" };
88
+ const target = targets[0];
89
+ if (definedKeys(target) !== "accountId,channel,to")
90
+ return { kind: "foreign" };
91
+ if (target.channel !== CHANNEL_ID)
92
+ return { kind: "foreign" };
93
+ const to = typeof target.to === "string" ? target.to.trim() : "";
94
+ const accountId = typeof target.accountId === "string" ? target.accountId.trim() : "";
95
+ if (!to || !accountId)
96
+ return { kind: "foreign" };
97
+ return { kind: "managed", target: { to, accountId } };
98
+ }
99
+ /**
100
+ * The target the managed block should hold now, or `null` when none should
101
+ * exist. Approve routes only with exactly one active ClawChat account: the
102
+ * block is global, so a second account's owner would see the first one's
103
+ * requests.
104
+ */
105
+ export function desiredApprovalTarget(input) {
106
+ if (input.approveAccountIds.length === 0)
107
+ return { target: null, reason: "not-approve" };
108
+ if (input.activeAccountIds.length !== 1)
109
+ return { target: null, reason: "multiple-accounts" };
110
+ const accountId = input.activeAccountIds[0];
111
+ if (!input.approveAccountIds.includes(accountId))
112
+ return { target: null, reason: "not-approve" };
113
+ const to = input.ownerConversation(accountId)?.trim();
114
+ if (!to)
115
+ return { target: null, reason: "no-owner-chat" };
116
+ return { target: { accountId, to } };
117
+ }
118
+ /** What to write so `approvals.plugin` matches `desired`, never touching a foreign block. */
119
+ export function planApprovalsPluginWrite(current, desired) {
120
+ if (current.kind === "foreign")
121
+ return { action: "none" };
122
+ if (!desired)
123
+ return current.kind === "managed" ? { action: "remove" } : { action: "none" };
124
+ // Never take the block over from another account: that account's runtime
125
+ // would take it back (each write restarts a pre-9.2 gateway). Drop it.
126
+ if (current.kind === "managed" && norm(current.target.accountId) !== norm(desired.accountId)) {
127
+ return { action: "remove" };
128
+ }
129
+ if (current.kind === "managed" &&
130
+ norm(current.target.to) === norm(desired.to) &&
131
+ norm(current.target.accountId) === norm(desired.accountId)) {
132
+ return { action: "none" };
133
+ }
134
+ return { action: "set", target: desired };
135
+ }
136
+ /** Apply `plan` to a config draft in place. */
137
+ export function applyApprovalsPluginWrite(draft, plan) {
138
+ if (plan.action === "none")
139
+ return;
140
+ const approvals = isRec(draft.approvals) ? { ...draft.approvals } : {};
141
+ if (plan.action === "set") {
142
+ approvals.plugin = buildManagedApprovalsPlugin(plan.target);
143
+ draft.approvals = approvals;
144
+ return;
145
+ }
146
+ delete approvals.plugin;
147
+ if (Object.keys(approvals).length === 0)
148
+ delete draft.approvals;
149
+ else
150
+ draft.approvals = approvals;
151
+ }
152
+ /**
153
+ * Whether a group call on `accountId` can be sent for approval now: the live
154
+ * config must already hold the managed block for exactly this account and its
155
+ * current owner chat. Everything else blocks.
156
+ */
157
+ export function resolveApprovalRoute(input) {
158
+ if (!input.approve)
159
+ return { ready: false, reason: "not-approve" };
160
+ if (input.activeAccountIds.length !== 1 || norm(input.activeAccountIds[0]) !== norm(input.accountId)) {
161
+ return { ready: false, reason: "multiple-accounts" };
162
+ }
163
+ const owner = input.ownerConversationId?.trim();
164
+ if (!owner)
165
+ return { ready: false, reason: "no-owner-chat" };
166
+ const state = readApprovalsPluginState(input.liveConfig);
167
+ if (state.kind === "foreign")
168
+ return { ready: false, reason: "foreign-config" };
169
+ if (state.kind !== "managed" ||
170
+ norm(state.target.accountId) !== norm(input.accountId) ||
171
+ norm(state.target.to) !== norm(owner)) {
172
+ return { ready: false, reason: "not-applied" };
173
+ }
174
+ return { ready: true, target: state.target };
175
+ }
176
+ export const APPROVAL_ROUTE_WARNINGS = {
177
+ "not-approve": "",
178
+ "multiple-accounts": "non-owner-host-tools approve needs exactly one ClawChat account on this gateway; group host-tool calls stay blocked",
179
+ "no-owner-chat": "non-owner-host-tools approve has no owner direct chat yet (activate first); group host-tool calls stay blocked",
180
+ "foreign-config": "non-owner-host-tools approve cannot route: approvals.plugin is already set in the OpenClaw config and the plugin will not change it; group host-tool calls stay blocked",
181
+ "not-applied": "non-owner-host-tools approve is waiting for the gateway to load approvals.plugin; group host-tool calls stay blocked until it does",
182
+ };
183
+ /**
184
+ * Bring `approvals.plugin` in line with the accounts' `non-owner-host-tools`:
185
+ * write the managed block when approve can route, remove a managed block that
186
+ * no longer applies, and never touch the owner's own block. The write lets the
187
+ * host decide how to load it (`afterWrite: auto`): OpenClaw 2026.9.2+ applies
188
+ * `approvals.plugin` without a restart, earlier hosts restart the gateway once.
189
+ */
190
+ export async function reconcileApprovalsPlugin(params) {
191
+ const cfg = params.readConfig();
192
+ const active = listActiveAccountIds(cfg, {
193
+ knownActive: params.knownActive,
194
+ hasStoredIdentity: params.hasStoredIdentity,
195
+ });
196
+ const approveIds = active.filter((id) => resolveOpenclawClawlingAccount(cfg, id).nonOwnerHostTools === "approve");
197
+ const desired = desiredApprovalTarget({
198
+ activeAccountIds: active,
199
+ approveAccountIds: approveIds,
200
+ ownerConversation: params.ownerConversation,
201
+ });
202
+ const current = readApprovalsPluginState(cfg);
203
+ if (approveIds.length > 0) {
204
+ const reason = current.kind === "foreign" ? "foreign-config" : desired.reason;
205
+ if (reason && reason !== "not-approve")
206
+ params.log?.warn?.(`clawchat-plugin-openclaw: ${APPROVAL_ROUTE_WARNINGS[reason]}`);
207
+ }
208
+ const planned = planApprovalsPluginWrite(current, desired.target);
209
+ if (planned.action === "none")
210
+ return "none";
211
+ const guardKey = planned.action === "set"
212
+ ? `set:${norm(planned.target.accountId)}:${norm(planned.target.to)}`
213
+ : "remove";
214
+ try {
215
+ if (params.writeGuard?.seen(guardKey)) {
216
+ params.log?.warn?.("clawchat-plugin-openclaw: approvals.plugin was already updated recently but the gateway does not show it; not writing again (group host-tool calls stay blocked)");
217
+ return "none";
218
+ }
219
+ params.writeGuard?.record(guardKey);
220
+ }
221
+ catch {
222
+ // A broken guard must not stop the write; the per-lifecycle guard still applies.
223
+ }
224
+ let action = "none";
225
+ try {
226
+ await params.mutateConfigFile({
227
+ afterWrite: { mode: "auto" },
228
+ mutate(draft) {
229
+ // Re-plan against the file as it is now, not the snapshot read above.
230
+ const plan = planApprovalsPluginWrite(readApprovalsPluginState(draft), desired.target);
231
+ action = plan.action;
232
+ applyApprovalsPluginWrite(draft, plan);
233
+ },
234
+ });
235
+ }
236
+ catch (err) {
237
+ params.log?.error?.(`clawchat-plugin-openclaw: approvals.plugin update failed; group host-tool calls stay blocked (${err instanceof Error ? err.message : String(err)})`);
238
+ return "failed";
239
+ }
240
+ if (action === "set") {
241
+ params.log?.info?.("clawchat-plugin-openclaw: non-owner-host-tools approve: wrote approvals.plugin so group host-tool approvals go only to the owner's ClawChat direct chat");
242
+ }
243
+ else if (action === "remove") {
244
+ params.log?.info?.("clawchat-plugin-openclaw: removed the plugin-managed approvals.plugin (approve is no longer in use)");
245
+ }
246
+ return action;
247
+ }
248
+ export const APPROVALS_WRITE_MARKER_FILENAME = "clawchat-approvals-plugin-write.json";
249
+ const WRITE_GUARD_WINDOW_MS = 10 * 60_000;
250
+ /**
251
+ * File-backed `writeGuard` in the plugin's state dir: the same write is made
252
+ * at most once per 10 minutes, across gateway restarts.
253
+ */
254
+ export function createApprovalsWriteGuard(stateDir, now = Date.now) {
255
+ const file = path.join(stateDir, APPROVALS_WRITE_MARKER_FILENAME);
256
+ const read = () => {
257
+ try {
258
+ const parsed = JSON.parse(fs.readFileSync(file, "utf8"));
259
+ return isRec(parsed) ? parsed : {};
260
+ }
261
+ catch {
262
+ return {};
263
+ }
264
+ };
265
+ return {
266
+ seen: (key) => {
267
+ const last = read();
268
+ return last.key === key && typeof last.at === "number" && now() - last.at < WRITE_GUARD_WINDOW_MS;
269
+ },
270
+ record: (key) => {
271
+ fs.mkdirSync(stateDir, { recursive: true });
272
+ fs.writeFileSync(file, JSON.stringify({ key, at: now() }));
273
+ },
274
+ };
275
+ }
276
+ /** `hasStoredIdentity` from the plugin store; an unavailable store counts every account. */
277
+ export function storedIdentityLookup(store) {
278
+ return (accountId) => {
279
+ const lookup = store?.lookupActivationIdentity?.({ platform: "openclaw", accountId });
280
+ return !lookup || lookup.status !== "absent";
281
+ };
282
+ }
@@ -3,7 +3,15 @@ import { classifyChat, clawChatSessionAccountId, NON_OWNER_TOOL_ALLOWLIST, norma
3
3
  import { clawChatDbPathForStateDir, getClawChatStore } from "./storage.js";
4
4
  import { resolveOwnerIdentity } from "./owner-identity.js";
5
5
  import { getRegisteredClawChatToolNames } from "./tools.js";
6
+ import { APPROVAL_ROUTE_WARNINGS, listActiveAccountIds, resolveApprovalRoute, storedIdentityLookup, } from "./approval-route.js";
6
7
  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
+ /** Told to the model when the owner denies, or does not answer in time. */
9
+ 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.";
10
+ /** Owner approval wait for one call; the host caps plugin approvals at 600 s. */
11
+ export const APPROVAL_TIMEOUT_MS = 300_000;
12
+ const APPROVAL_TITLE_MAX = 80;
13
+ /** The host rejects (and so blocks) a description over 512 characters. */
14
+ const APPROVAL_DESCRIPTION_MAX = 500;
7
15
  /**
8
16
  * Host tool-search controls (OpenClaw 2026.9.6+). The host adds them to every
9
17
  * run on its own and builds their catalog from the run's allowed tools, so in
@@ -44,10 +52,17 @@ export function decideHostToolCall(input) {
44
52
  if (classifyChat({ ...parsed, ownerDirectConversationId: input.ownerDirectConversationId }) === "owner") {
45
53
  return undefined;
46
54
  }
55
+ // 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);
47
59
  // Outer pre-check only. The inner tool call is checked again and that check
48
60
  // is authoritative: the host also accepts dotted/flattened argument keys
49
- // that this outer check may not recognise.
50
- if (hasLocalPathArgument(input.params))
61
+ // that this outer check may not recognise. Under approve, tools the owner
62
+ // approves one by one (and the tool-search controls that reach them) show
63
+ // the owner their arguments instead; the allowlisted tools, which run
64
+ // without asking, keep the check.
65
+ if ((!approve || allowlisted) && hasLocalPathArgument(input.params))
51
66
  return block();
52
67
  if (HOST_TOOL_SEARCH_CONTROLS.includes(input.toolName))
53
68
  return undefined;
@@ -65,12 +80,62 @@ export function decideHostToolCall(input) {
65
80
  };
66
81
  }
67
82
  }
68
- if (NON_OWNER_TOOL_ALLOWLIST.includes(input.toolName)) {
83
+ if (allowlisted) {
69
84
  return input.toolName.startsWith("clawchat_") && !input.registeredTools.has(input.toolName)
70
85
  ? block()
71
86
  : undefined;
72
87
  }
73
- return block(); // `off` and the reserved `approve` both block in this release.
88
+ if (!approve)
89
+ return block();
90
+ const approval = buildHostToolApproval(input.toolName, parsed.chatId, input.params);
91
+ return approval ? { requireApproval: approval } : { block: true, blockReason: APPROVAL_UNSHOWABLE_MESSAGE };
92
+ }
93
+ // Drop what the host would escape (controls, zero-width, bidi) from the names
94
+ // shown on the card, and collapse whitespace. Arguments are never cleaned:
95
+ // they are shown exactly, or the call is not offered for approval.
96
+ const UNSAFE_TEXT = /[\u0000-\u001f\u007f-\u009f\u00ad\u200b-\u200f\u202a-\u202e\u2060-\u206f\ufeff]/g;
97
+ function cleanText(text, max) {
98
+ const flat = text.replace(UNSAFE_TEXT, " ").replace(/\s+/g, " ").trim();
99
+ return flat.length > max ? `${flat.slice(0, max - 1)}…` : flat;
100
+ }
101
+ /** Tools whose approval starts work that later runs on its own (own session key, no further asking). */
102
+ const STARTS_UNATTENDED_WORK = /spawn|subagent|sessions_send|cron|automation|schedul/i;
103
+ /** Told to the model when a call's arguments cannot be shown to the owner exactly. */
104
+ export const APPROVAL_UNSHOWABLE_MESSAGE = "This call cannot be sent to your owner for approval: its arguments are too long or contain hidden characters, and your owner must see exactly what would run. Do not retry it as is; use a much shorter call, or tell the person to ask your owner directly.";
105
+ /**
106
+ * The approval card for one group host-tool call; it goes only to the owner's
107
+ * direct chat. `null` when the arguments cannot be shown in full and exactly
108
+ * (too long for the host's 512-character description, unserializable, or
109
+ * holding characters the host would escape or hide): the owner must never
110
+ * approve a command they could not read, so such a call blocks.
111
+ */
112
+ export function buildHostToolApproval(toolName, groupChatId, params) {
113
+ const tool = cleanText(toolName, 48);
114
+ let args;
115
+ try {
116
+ args = JSON.stringify(params) ?? "{}";
117
+ }
118
+ catch {
119
+ return null;
120
+ }
121
+ if (new RegExp(UNSAFE_TEXT.source).test(args))
122
+ return null;
123
+ const unattended = STARTS_UNATTENDED_WORK.test(toolName)
124
+ ? " Approving starts work that later runs with every host tool and is not asked about again."
125
+ : "";
126
+ const before = `A turn in ClawChat group ${cleanText(groupChatId, 64)} (anyone in the group may have asked) wants to run ` +
127
+ `the host tool ${tool} on this machine. Arguments: `;
128
+ const after = `.${unattended} Allow only if you would run it yourself.`;
129
+ if (before.length + args.length + after.length > APPROVAL_DESCRIPTION_MAX)
130
+ return null;
131
+ return {
132
+ title: cleanText(`ClawChat group wants to run ${tool}`, APPROVAL_TITLE_MAX),
133
+ description: `${before}${args}${after}`,
134
+ severity: "warning",
135
+ timeoutMs: APPROVAL_TIMEOUT_MS,
136
+ timeoutReason: APPROVAL_TIMEOUT_MESSAGE,
137
+ allowedDecisions: ["allow-once", "deny"],
138
+ };
74
139
  }
75
140
  export function registerHostToolsGuard(api, options = {}) {
76
141
  const warned = new Set();
@@ -85,16 +150,54 @@ export function registerHostToolsGuard(api, options = {}) {
85
150
  // Logging cannot unlock a call.
86
151
  }
87
152
  };
88
- const lookupOwnerConversation = (accountId) => {
89
- if (options.ownerConversation)
90
- return options.ownerConversation(accountId);
91
- const account = resolveOpenclawClawlingAccount(api.config, accountId);
153
+ const openStore = () => {
92
154
  const stateDir = api.runtime.state?.resolveStateDir?.();
93
- const store = getClawChatStore({
155
+ return getClawChatStore({
94
156
  ...(stateDir ? { dbPath: clawChatDbPathForStateDir(stateDir) } : {}),
95
157
  log: { error: message => api.logger.error?.(message) },
96
158
  });
97
- return resolveOwnerIdentity(store, accountId, account.ownerUserId).conversationId;
159
+ };
160
+ const lookupOwnerConversation = (accountId) => {
161
+ if (options.ownerConversation)
162
+ return options.ownerConversation(accountId);
163
+ const account = resolveOpenclawClawlingAccount(api.config, accountId);
164
+ return resolveOwnerIdentity(openStore(), accountId, account.ownerUserId).conversationId;
165
+ };
166
+ const hasStoredIdentity = options.hasStoredIdentity
167
+ ?? ((accountId) => storedIdentityLookup(openStore())(accountId));
168
+ const readLiveConfig = () => {
169
+ if (options.liveConfig)
170
+ return options.liveConfig();
171
+ const current = api.runtime.config?.current;
172
+ return (typeof current === "function" ? current.call(api.runtime.config) : api.config);
173
+ };
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) => {
177
+ try {
178
+ const cfg = readLiveConfig();
179
+ const active = listActiveAccountIds(cfg, { knownActive: accountId, hasStoredIdentity });
180
+ const routeAccountId = accountId ?? (active.length === 1 ? active[0] : undefined);
181
+ const anyApprove = (routeAccountId ? [routeAccountId] : active).some(id => resolveOpenclawClawlingAccount(cfg, id).nonOwnerHostTools === "approve");
182
+ if (!anyApprove)
183
+ return undefined;
184
+ const route = routeAccountId
185
+ ? resolveApprovalRoute({
186
+ liveConfig: cfg,
187
+ accountId: routeAccountId,
188
+ activeAccountIds: active,
189
+ approve: true,
190
+ ownerConversationId: lookupOwnerConversation(routeAccountId),
191
+ })
192
+ : { ready: false, reason: "multiple-accounts" };
193
+ if (!route.ready)
194
+ warn(`approve unavailable (${route.reason}): ${APPROVAL_ROUTE_WARNINGS[route.reason]}`);
195
+ return route;
196
+ }
197
+ catch {
198
+ warn("approve unavailable (route lookup failed)");
199
+ return { ready: false, reason: "not-applied" };
200
+ }
98
201
  };
99
202
  api.on("before_tool_call", (event, sdkCtx) => {
100
203
  // `requester` is host 2026.8.1+ (the floor); the development SDK types predate it.
@@ -138,6 +241,7 @@ export function registerHostToolsGuard(api, options = {}) {
138
241
  sessionKey,
139
242
  ownerDirectConversationId: null,
140
243
  registeredTools: getRegisteredClawChatToolNames(),
244
+ approvalRoute: groupApprovalRoute(undefined),
141
245
  });
142
246
  }
143
247
  let ownerDirectConversationId = null;
@@ -155,6 +259,7 @@ export function registerHostToolsGuard(api, options = {}) {
155
259
  sessionKey,
156
260
  ownerDirectConversationId,
157
261
  registeredTools: getRegisteredClawChatToolNames(),
262
+ ...(parsed.kind === "group" ? { approvalRoute: groupApprovalRoute(accountId) } : {}),
158
263
  });
159
264
  }
160
265
  catch {
@@ -10,6 +10,11 @@
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.
17
+ //
13
18
  // Chat ids compare case-insensitively (the host lowercases session keys);
14
19
  // the original case is recovered before any group file / SQL lookup. Member
15
20
  // user ids stay case-sensitive.
@@ -142,6 +147,22 @@ export function canReadMemory(scope, target) {
142
147
  return scope.members.has(target.targetId);
143
148
  return scope.kind === "group" && target.targetType === "group" && target.targetId === scope.chatId;
144
149
  }
150
+ /** Whether a write (`append` when `append` is true) may touch the target. */
151
+ export function canWriteMemory(scope, target, append) {
152
+ if (target.targetType === "owner")
153
+ return scope.kind === "owner";
154
+ return append || canReadMemory(scope, target);
155
+ }
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 };
165
+ }
145
166
  export function memoryScopeRefusal(scope, target) {
146
167
  const message = target.targetType === "owner"
147
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."
@@ -713,8 +713,22 @@ function markChannelOutboundAcknowledged(params) {
713
713
  // Best-effort diagnostics only; ack has already succeeded.
714
714
  }
715
715
  }
716
+ /**
717
+ * Host approval prompts (exec and plugin approvals forwarded by
718
+ * `approvals.exec` / `approvals.plugin`) never go into a ClawChat group: anyone
719
+ * there could read the request, and only the owner may answer it. The host
720
+ * calls this before each forwarded prompt; throwing fails that one delivery.
721
+ */
722
+ export function refuseGroupApprovalPrompt(params) {
723
+ if (params.hint?.kind !== "approval-pending")
724
+ return;
725
+ if (parseOpenclawRecipient(params.target.to).chatType === "group") {
726
+ throw new Error("clawchat-plugin-openclaw: approval prompts are not sent to ClawChat groups");
727
+ }
728
+ }
716
729
  export const openclawClawlingOutbound = {
717
730
  deliveryMode: "direct",
731
+ beforeDeliverPayload: (params) => refuseGroupApprovalPrompt(params),
718
732
  chunker: (text, limit) => chunkMarkdownText(text, limit),
719
733
  chunkerMode: "markdown",
720
734
  textChunkLimit: 4000,