@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.
@@ -1,10 +1,13 @@
1
1
  // Layer 2 of the non-owner turn boundary: a fail-closed `before_tool_call`
2
2
  // hook. Dispatch (layer 1) already narrows the offered tools for turns the
3
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";
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); everything else passes
9
+ // untouched. Tool arguments are never logged.
10
+ import type { OpenClawConfig, OpenClawPluginApi } from "openclaw/plugin-sdk";
8
11
  import { CHANNEL_ID, normalizeOpenclawClawlingAccountId, resolveOpenclawClawlingAccount } from "./config.ts";
9
12
  import {
10
13
  classifyChat,
@@ -16,17 +19,46 @@ import {
16
19
  import { clawChatDbPathForStateDir, getClawChatStore } from "./storage.ts";
17
20
  import { resolveOwnerIdentity } from "./owner-identity.ts";
18
21
  import { getRegisteredClawChatToolNames } from "./tools.ts";
22
+ import {
23
+ APPROVAL_ROUTE_WARNINGS,
24
+ listActiveAccountIds,
25
+ resolveApprovalRoute,
26
+ storedIdentityLookup,
27
+ type ApprovalRoute,
28
+ } from "./approval-route.ts";
19
29
 
20
30
  export const BLOCK_MESSAGE =
21
31
  "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
32
 
23
- export type HostToolDecision = { block: true; blockReason: string } | undefined;
33
+ /** Told to the model when the owner denies, or does not answer in time. */
34
+ export const APPROVAL_TIMEOUT_MESSAGE =
35
+ "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.";
36
+ /** Owner approval wait for one call; the host caps plugin approvals at 600 s. */
37
+ export const APPROVAL_TIMEOUT_MS = 300_000;
38
+ const APPROVAL_TITLE_MAX = 80;
39
+ /** The host rejects (and so blocks) a description over 512 characters. */
40
+ const APPROVAL_DESCRIPTION_MAX = 500;
41
+
42
+ export type HostToolApproval = {
43
+ title: string;
44
+ description: string;
45
+ severity: "warning";
46
+ timeoutMs: number;
47
+ timeoutReason: string;
48
+ allowedDecisions: ["allow-once", "deny"];
49
+ };
50
+ export type HostToolDecision =
51
+ | { block: true; blockReason: string }
52
+ | { requireApproval: HostToolApproval }
53
+ | undefined;
24
54
  export type HostToolInput = {
25
55
  toolName: string;
26
56
  params: Record<string, unknown>;
27
57
  sessionKey?: string;
28
58
  ownerDirectConversationId?: string | null;
29
59
  registeredTools: ReadonlySet<string>;
60
+ /** Group turns only; a ready route sends non-allowlisted tools to the owner for approval. */
61
+ approvalRoute?: ApprovalRoute;
30
62
  };
31
63
 
32
64
  /**
@@ -70,10 +102,17 @@ export function decideHostToolCall(input: HostToolInput): HostToolDecision {
70
102
  if (classifyChat({ ...parsed, ownerDirectConversationId: input.ownerDirectConversationId }) === "owner") {
71
103
  return undefined;
72
104
  }
105
+ // Approve mode: only group turns, and only once the route to the owner's
106
+ // direct chat is live. Friend direct chats never ask; they block below.
107
+ const approve = parsed.kind === "group" && input.approvalRoute?.ready === true;
108
+ const allowlisted = NON_OWNER_TOOL_ALLOWLIST.includes(input.toolName);
73
109
  // Outer pre-check only. The inner tool call is checked again and that check
74
110
  // 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();
111
+ // that this outer check may not recognise. Under approve, tools the owner
112
+ // approves one by one (and the tool-search controls that reach them) show
113
+ // the owner their arguments instead; the allowlisted tools, which run
114
+ // without asking, keep the check.
115
+ if ((!approve || allowlisted) && hasLocalPathArgument(input.params)) return block();
77
116
  if (HOST_TOOL_SEARCH_CONTROLS.includes(input.toolName)) return undefined;
78
117
  if (ACCOUNT_SCOPED_TOOLS.includes(input.toolName)) {
79
118
  // D9: a non-owner turn may only act on its own chat, named explicitly.
@@ -89,19 +128,81 @@ export function decideHostToolCall(input: HostToolInput): HostToolDecision {
89
128
  };
90
129
  }
91
130
  }
92
- if (NON_OWNER_TOOL_ALLOWLIST.includes(input.toolName)) {
131
+ if (allowlisted) {
93
132
  return input.toolName.startsWith("clawchat_") && !input.registeredTools.has(input.toolName)
94
133
  ? block()
95
134
  : undefined;
96
135
  }
97
- return block(); // `off` and the reserved `approve` both block in this release.
136
+ if (!approve) return block();
137
+ const approval = buildHostToolApproval(input.toolName, parsed.chatId, input.params);
138
+ return approval ? { requireApproval: approval } : { block: true, blockReason: APPROVAL_UNSHOWABLE_MESSAGE };
139
+ }
140
+
141
+ // Drop what the host would escape (controls, zero-width, bidi) from the names
142
+ // shown on the card, and collapse whitespace. Arguments are never cleaned:
143
+ // they are shown exactly, or the call is not offered for approval.
144
+ const UNSAFE_TEXT = /[\u0000-\u001f\u007f-\u009f\u00ad\u200b-\u200f\u202a-\u202e\u2060-\u206f\ufeff]/g;
145
+ function cleanText(text: string, max: number): string {
146
+ const flat = text.replace(UNSAFE_TEXT, " ").replace(/\s+/g, " ").trim();
147
+ return flat.length > max ? `${flat.slice(0, max - 1)}…` : flat;
148
+ }
149
+
150
+ /** Tools whose approval starts work that later runs on its own (own session key, no further asking). */
151
+ const STARTS_UNATTENDED_WORK = /spawn|subagent|sessions_send|cron|automation|schedul/i;
152
+
153
+ /** Told to the model when a call's arguments cannot be shown to the owner exactly. */
154
+ export const APPROVAL_UNSHOWABLE_MESSAGE =
155
+ "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.";
156
+
157
+ /**
158
+ * The approval card for one group host-tool call; it goes only to the owner's
159
+ * direct chat. `null` when the arguments cannot be shown in full and exactly
160
+ * (too long for the host's 512-character description, unserializable, or
161
+ * holding characters the host would escape or hide): the owner must never
162
+ * approve a command they could not read, so such a call blocks.
163
+ */
164
+ export function buildHostToolApproval(
165
+ toolName: string,
166
+ groupChatId: string,
167
+ params: Record<string, unknown>,
168
+ ): HostToolApproval | null {
169
+ const tool = cleanText(toolName, 48);
170
+ let args: string;
171
+ try {
172
+ args = JSON.stringify(params) ?? "{}";
173
+ } catch {
174
+ return null;
175
+ }
176
+ if (new RegExp(UNSAFE_TEXT.source).test(args)) return null;
177
+ const unattended = STARTS_UNATTENDED_WORK.test(toolName)
178
+ ? " Approving starts work that later runs with every host tool and is not asked about again."
179
+ : "";
180
+ const before =
181
+ `A turn in ClawChat group ${cleanText(groupChatId, 64)} (anyone in the group may have asked) wants to run ` +
182
+ `the host tool ${tool} on this machine. Arguments: `;
183
+ const after = `.${unattended} Allow only if you would run it yourself.`;
184
+ if (before.length + args.length + after.length > APPROVAL_DESCRIPTION_MAX) return null;
185
+ return {
186
+ title: cleanText(`ClawChat group wants to run ${tool}`, APPROVAL_TITLE_MAX),
187
+ description: `${before}${args}${after}`,
188
+ severity: "warning",
189
+ timeoutMs: APPROVAL_TIMEOUT_MS,
190
+ timeoutReason: APPROVAL_TIMEOUT_MESSAGE,
191
+ allowedDecisions: ["allow-once", "deny"],
192
+ };
98
193
  }
99
194
 
100
195
  type RequesterContext = { channel?: string; accountId?: string };
101
196
 
102
197
  export function registerHostToolsGuard(
103
198
  api: OpenClawPluginApi,
104
- options: { ownerConversation?: (accountId: string) => string | null } = {},
199
+ options: {
200
+ ownerConversation?: (accountId: string) => string | null;
201
+ /** The config the gateway runs with now; defaults to the runtime's live config. */
202
+ liveConfig?: () => unknown;
203
+ /** Whether an account has a stored activation; defaults to the plugin store. */
204
+ hasStoredIdentity?: (accountId: string) => boolean;
205
+ } = {},
105
206
  ): void {
106
207
  const warned = new Set<string>();
107
208
  const warn = (failure: string) => {
@@ -113,15 +214,52 @@ export function registerHostToolsGuard(
113
214
  // Logging cannot unlock a call.
114
215
  }
115
216
  };
116
- const lookupOwnerConversation = (accountId: string): string | null => {
117
- if (options.ownerConversation) return options.ownerConversation(accountId);
118
- const account = resolveOpenclawClawlingAccount(api.config, accountId);
217
+ const openStore = () => {
119
218
  const stateDir = api.runtime.state?.resolveStateDir?.();
120
- const store = getClawChatStore({
219
+ return getClawChatStore({
121
220
  ...(stateDir ? { dbPath: clawChatDbPathForStateDir(stateDir) } : {}),
122
221
  log: { error: message => api.logger.error?.(message) },
123
222
  });
124
- return resolveOwnerIdentity(store, accountId, account.ownerUserId).conversationId;
223
+ };
224
+ const lookupOwnerConversation = (accountId: string): string | null => {
225
+ if (options.ownerConversation) return options.ownerConversation(accountId);
226
+ const account = resolveOpenclawClawlingAccount(api.config, accountId);
227
+ return resolveOwnerIdentity(openStore(), accountId, account.ownerUserId).conversationId;
228
+ };
229
+ const hasStoredIdentity = options.hasStoredIdentity
230
+ ?? ((accountId: string) => storedIdentityLookup(openStore())(accountId));
231
+
232
+ const readLiveConfig = (): OpenClawConfig => {
233
+ if (options.liveConfig) return options.liveConfig() as OpenClawConfig;
234
+ const current = (api.runtime.config as { current?: () => unknown } | undefined)?.current;
235
+ return (typeof current === "function" ? current.call(api.runtime.config) : api.config) as OpenClawConfig;
236
+ };
237
+ // Only consulted for group calls. `undefined` = the account is not in
238
+ // approve mode; a route that is not ready blocks like `off`.
239
+ const groupApprovalRoute = (accountId: string | undefined): ApprovalRoute | undefined => {
240
+ try {
241
+ const cfg = readLiveConfig();
242
+ const active = listActiveAccountIds(cfg, { knownActive: accountId, hasStoredIdentity });
243
+ const routeAccountId = accountId ?? (active.length === 1 ? active[0] : undefined);
244
+ const anyApprove = (routeAccountId ? [routeAccountId] : active).some(
245
+ id => resolveOpenclawClawlingAccount(cfg, id).nonOwnerHostTools === "approve",
246
+ );
247
+ if (!anyApprove) return undefined;
248
+ const route = routeAccountId
249
+ ? resolveApprovalRoute({
250
+ liveConfig: cfg,
251
+ accountId: routeAccountId,
252
+ activeAccountIds: active,
253
+ approve: true,
254
+ ownerConversationId: lookupOwnerConversation(routeAccountId),
255
+ })
256
+ : { ready: false as const, reason: "multiple-accounts" as const };
257
+ if (!route.ready) warn(`approve unavailable (${route.reason}): ${APPROVAL_ROUTE_WARNINGS[route.reason]}`);
258
+ return route;
259
+ } catch {
260
+ warn("approve unavailable (route lookup failed)");
261
+ return { ready: false, reason: "not-applied" };
262
+ }
125
263
  };
126
264
 
127
265
  api.on("before_tool_call", (event, sdkCtx) => {
@@ -165,6 +303,7 @@ export function registerHostToolsGuard(
165
303
  sessionKey,
166
304
  ownerDirectConversationId: null,
167
305
  registeredTools: getRegisteredClawChatToolNames(),
306
+ approvalRoute: groupApprovalRoute(undefined),
168
307
  });
169
308
  }
170
309
  let ownerDirectConversationId: string | null = null;
@@ -180,6 +319,7 @@ export function registerHostToolsGuard(
180
319
  sessionKey,
181
320
  ownerDirectConversationId,
182
321
  registeredTools: getRegisteredClawChatToolNames(),
322
+ ...(parsed.kind === "group" ? { approvalRoute: groupApprovalRoute(accountId) } : {}),
183
323
  });
184
324
  } catch {
185
325
  warn("handler-exception");
@@ -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.
@@ -163,6 +168,27 @@ export function canReadMemory(scope: MemoryScope, target: ClawChatMemoryTarget):
163
168
  return scope.kind === "group" && target.targetType === "group" && target.targetId === scope.chatId;
164
169
  }
165
170
 
171
+ /** Whether a write (`append` when `append` is true) may touch the target. */
172
+ export function canWriteMemory(scope: MemoryScope, target: ClawChatMemoryTarget, append: boolean): boolean {
173
+ if (target.targetType === "owner") return scope.kind === "owner";
174
+ return append || canReadMemory(scope, target);
175
+ }
176
+
177
+ /** Refusal for an owner.md write outside the owner DM, with where to write instead. */
178
+ export function ownerNoteWriteRefusal(scope: MemoryScope): {
179
+ error: "not_writable_here";
180
+ code: "memory_scope";
181
+ message: string;
182
+ } {
183
+ const base = "owner.md can only be changed in your owner's direct chat.";
184
+ const message = scope.kind === "direct"
185
+ ? `${base} Record this in the note about the person you are talking to (their users/<id>.md) instead.`
186
+ : scope.kind === "group"
187
+ ? `${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.`
188
+ : base;
189
+ return { error: "not_writable_here", code: "memory_scope", message };
190
+ }
191
+
166
192
  export function memoryScopeRefusal(scope: MemoryScope, target: ClawChatMemoryTarget): {
167
193
  error: "not_readable_here";
168
194
  code: "memory_scope";
package/src/outbound.ts CHANGED
@@ -979,8 +979,22 @@ function markChannelOutboundAcknowledged(params: {
979
979
  }
980
980
  }
981
981
 
982
+ /**
983
+ * Host approval prompts (exec and plugin approvals forwarded by
984
+ * `approvals.exec` / `approvals.plugin`) never go into a ClawChat group: anyone
985
+ * there could read the request, and only the owner may answer it. The host
986
+ * calls this before each forwarded prompt; throwing fails that one delivery.
987
+ */
988
+ export function refuseGroupApprovalPrompt(params: { target: { to: string }; hint?: { kind?: string } }): void {
989
+ if (params.hint?.kind !== "approval-pending") return;
990
+ if (parseOpenclawRecipient(params.target.to).chatType === "group") {
991
+ throw new Error("clawchat-plugin-openclaw: approval prompts are not sent to ClawChat groups");
992
+ }
993
+ }
994
+
982
995
  export const openclawClawlingOutbound: ChannelOutboundAdapter = {
983
996
  deliveryMode: "direct",
997
+ beforeDeliverPayload: (params) => refuseGroupApprovalPrompt(params),
984
998
  chunker: (text, limit) => chunkMarkdownText(text, limit),
985
999
  chunkerMode: "markdown",
986
1000
  textChunkLimit: 4000,
package/src/runtime.ts CHANGED
@@ -12,7 +12,7 @@ import {
12
12
  type Transport,
13
13
  } from "./protocol-types.ts";
14
14
  import type { ChannelAccountSnapshot } from "openclaw/plugin-sdk/channel-contract";
15
- import { waitUntilAbort } from "openclaw/plugin-sdk/channel-lifecycle";
15
+ import { waitUntilAbort } from "openclaw/plugin-sdk/channel-outbound";
16
16
  import { hasControlCommand } from "openclaw/plugin-sdk/command-detection";
17
17
  import type { OpenClawConfig, PluginRuntime } from "openclaw/plugin-sdk/core";
18
18
  import { createPluginRuntimeStore } from "openclaw/plugin-sdk/runtime-store";
@@ -90,6 +90,13 @@ import {
90
90
  import { getClawChatGroupPrompt, getClawChatPlatformPrompt, getClawChatUserPrompt } from "./plugin-prompts.ts";
91
91
  import { classifyChat, NON_OWNER_TOOL_ALLOWLIST } from "./turn-boundary.ts";
92
92
  import { resolveOwnerIdentity } from "./owner-identity.ts";
93
+ import {
94
+ createApprovalsWriteGuard,
95
+ listActiveAccountIds,
96
+ reconcileApprovalsPlugin,
97
+ resolveApprovalRoute,
98
+ storedIdentityLookup,
99
+ } from "./approval-route.ts";
93
100
  import {
94
101
  loadClawChatPromptMetadata,
95
102
  type ClawChatGroupParticipantPrompt,
@@ -221,6 +228,11 @@ const OPENCLAW_CONFIRM_SLASH_COMMANDS = new Set([
221
228
  "nevermind",
222
229
  ]);
223
230
  const GROUP_OWNER_ATTENTION_TITLE = "requires owner attention";
231
+ /** Group turns under `non-owner-host-tools: approve` (route live). */
232
+ const GROUP_APPROVAL_GUIDANCE =
233
+ "Host tools here (commands, files, browser, sub-agents, scheduling and the like) run only if your owner approves each call: " +
234
+ "the request goes to your owner's direct chat and the call waits up to 5 minutes. " +
235
+ "Do not ask anyone in this group to approve it. If it is denied or times out, do not retry; say your owner did not approve it.";
224
236
  // §C.1 — user-visible message emitted on permanent token expiry. Kept
225
237
  // byte-identical to the Hermes plugin (parity spec §C.1.4). Points the owner
226
238
  // at the reconnect prompt (a code bound to this identity) rather than at a
@@ -590,7 +602,7 @@ export interface StartGatewayParams {
590
602
  setStatus: (next: ChannelAccountSnapshot) => void;
591
603
  getStatus: () => ChannelAccountSnapshot;
592
604
  log?: Log;
593
- /** Internal — set on recursive re-entry so the approve-unsupported warning is issued once per lifecycle. */
605
+ /** Internal — set on recursive re-entry so the approve-route check at start runs once per lifecycle. */
594
606
  boundaryWarningIssued?: boolean;
595
607
  /** Test hook only. */
596
608
  store?: RuntimeConnectionStore | null;
@@ -1084,10 +1096,7 @@ export async function startOpenclawClawlingGateway(params: StartGatewayParams):
1084
1096
  hostToolSearchEnabled(cfg)
1085
1097
  ) {
1086
1098
  loggedHostToolSearch = true;
1087
- log?.info?.("clawchat-plugin-openclaw: host tool search is on; turns your owner did not start can still use only the allowlisted tools (set tools.toolSearch.enabled=false to turn tool search off; this affects every turn)");
1088
- }
1089
- if (!params.boundaryWarningIssued && account.nonOwnerHostTools === "approve") {
1090
- log?.warn?.("clawchat-plugin-openclaw: non-owner-host-tools approve is not supported yet; using off");
1099
+ log?.info?.("clawchat-plugin-openclaw: host tool search is on; turns your owner did not start can still use only the allowlisted tools without asking (approve mode: other tools in group turns still need the owner's approval each call) (set tools.toolSearch.enabled=false to turn tool search off; this affects every turn)");
1091
1100
  }
1092
1101
  log?.info?.(
1093
1102
  `[${accountId}] clawchat-plugin-openclaw runtime start entered configured=${account.configured} enabled=${account.enabled} hasToken=${Boolean(account.token)} hasUserId=${Boolean(account.userId)} hasOwnerUserId=${Boolean(account.ownerUserId)} websocketUrl=${account.websocketUrl || "(empty)"}`,
@@ -2603,6 +2612,98 @@ export async function startOpenclawClawlingGateway(params: StartGatewayParams):
2603
2612
  // forwards) would be unreachable. Registered lazily — the activation
2604
2613
  // conversation may be recorded after the client is constructed.
2605
2614
  client.setOwnerDirectChatResolver(resolveOwnerDirectConversationId);
2615
+ // `non-owner-host-tools: approve` routes group host-tool approvals to the
2616
+ // owner's direct chat through the host's `approvals.plugin`
2617
+ // (approval-route.ts). Checked once at start and again from a group turn
2618
+ // that finds the route missing (the owner chat may be recorded later). One
2619
+ // attempt per owner chat per lifecycle, so a failing write cannot loop.
2620
+ const readLiveConfig = (): OpenClawConfig => {
2621
+ const current = (runtime.config as { current?: () => unknown } | undefined)?.current;
2622
+ return (typeof current === "function" ? current.call(runtime.config) : cfg) as OpenClawConfig;
2623
+ };
2624
+ const approvalReconcileAttempts = new Set<string>();
2625
+ const hasStoredIdentity = storedIdentityLookup(store);
2626
+ const approvalsStateDir = (() => {
2627
+ try {
2628
+ return runtime.state?.resolveStateDir?.();
2629
+ } catch {
2630
+ return undefined;
2631
+ }
2632
+ })();
2633
+ const reconcileApprovalRoute = (): void => {
2634
+ const mutateConfigFile = resolveMutateConfigFile();
2635
+ if (!mutateConfigFile) {
2636
+ if (approvalReconcileAttempts.has("no-config-writes")) return;
2637
+ approvalReconcileAttempts.add("no-config-writes");
2638
+ try {
2639
+ const live = readLiveConfig();
2640
+ if (resolveOpenclawClawlingAccount(live, accountId).nonOwnerHostTools !== "approve") return;
2641
+ const route = resolveApprovalRoute({
2642
+ liveConfig: live,
2643
+ accountId,
2644
+ activeAccountIds: listActiveAccountIds(live, { knownActive: accountId, hasStoredIdentity }),
2645
+ approve: true,
2646
+ ownerConversationId: resolveOwnerDirectConversationId(),
2647
+ });
2648
+ if (route.ready) return;
2649
+ } catch {
2650
+ // Fall through: say why approve is not routing.
2651
+ }
2652
+ log?.warn?.(
2653
+ `[${accountId}] clawchat-plugin-openclaw: non-owner-host-tools approve cannot write approvals.plugin (host config writes unavailable); group host-tool calls stay blocked`,
2654
+ );
2655
+ return;
2656
+ }
2657
+ let ownerChat: string | null = null;
2658
+ try {
2659
+ ownerChat = resolveOwnerDirectConversationId();
2660
+ } catch {
2661
+ ownerChat = null;
2662
+ }
2663
+ const attemptKey = ownerChat ?? "";
2664
+ if (approvalReconcileAttempts.has(attemptKey)) return;
2665
+ approvalReconcileAttempts.add(attemptKey);
2666
+ void reconcileApprovalsPlugin({
2667
+ readConfig: readLiveConfig,
2668
+ mutateConfigFile: mutateConfigFile as never,
2669
+ ownerConversation: (id) => (id === accountId ? ownerChat : null),
2670
+ knownActive: accountId,
2671
+ hasStoredIdentity,
2672
+ ...(approvalsStateDir ? { writeGuard: createApprovalsWriteGuard(approvalsStateDir) } : {}),
2673
+ log: {
2674
+ info: (m) => log?.info?.(`[${accountId}] ${m}`),
2675
+ warn: (m) => {
2676
+ const warn = (log as { warn?: (msg: string) => void } | undefined)?.warn;
2677
+ if (warn) warn(`[${accountId}] ${m}`);
2678
+ else log?.info?.(`[${accountId}] WARNING ${m}`);
2679
+ },
2680
+ error: (m) => log?.error?.(`[${accountId}] ${m}`),
2681
+ },
2682
+ }).catch(() => {
2683
+ // Logged inside; the route simply stays not ready (calls block).
2684
+ });
2685
+ };
2686
+ const groupApprovalRouteReady = (ownerChat: string | null): boolean => {
2687
+ try {
2688
+ const live = readLiveConfig();
2689
+ const approve = resolveOpenclawClawlingAccount(live, accountId).nonOwnerHostTools === "approve";
2690
+ if (!approve) return false;
2691
+ const route = resolveApprovalRoute({
2692
+ liveConfig: live,
2693
+ accountId,
2694
+ activeAccountIds: listActiveAccountIds(live, { knownActive: accountId, hasStoredIdentity }),
2695
+ approve,
2696
+ ownerConversationId: ownerChat,
2697
+ });
2698
+ if (!route.ready && (route.reason === "not-applied" || route.reason === "no-owner-chat")) {
2699
+ reconcileApprovalRoute();
2700
+ }
2701
+ return route.ready;
2702
+ } catch {
2703
+ return false;
2704
+ }
2705
+ };
2706
+ if (!params.boundaryWarningIssued) reconcileApprovalRoute();
2606
2707
  const sendOwnerSkillMessage = async (text: string): Promise<void> => {
2607
2708
  const conversationId = resolveOwnerDirectConversationId();
2608
2709
  if (!conversationId) {
@@ -3389,12 +3490,19 @@ export async function startOpenclawClawlingGateway(params: StartGatewayParams):
3389
3490
  chatId: turn.peer.id,
3390
3491
  ownerDirectConversationId: ownerConversationId,
3391
3492
  });
3392
- const narrowHostTools = turnClass === "non-owner";
3493
+ // `non-owner-host-tools: approve`: a group turn keeps the host tools once
3494
+ // the approval route to the owner's direct chat is live; the hook then
3495
+ // asks the owner for each call. Friend direct chats always narrow.
3496
+ const groupApproval = turnClass === "non-owner"
3497
+ && turn.peer.kind === "group"
3498
+ && groupApprovalRouteReady(ownerConversationId);
3499
+ const narrowHostTools = turnClass === "non-owner" && !groupApproval;
3393
3500
  const baseTurnPrompt = await buildPromptForTurn(turn, memoryRoot);
3394
3501
  const nonOwnerMediaGuidance =
3395
3502
  "To send an image or file here, include it in your final reply as an http(s) URL; local files cannot be sent from this chat.";
3396
3503
  const turnPrompt = [
3397
- ...(narrowHostTools ? [getClawChatPlatformPrompt(), nonOwnerMediaGuidance] : []),
3504
+ ...(turnClass === "non-owner" ? [getClawChatPlatformPrompt(), nonOwnerMediaGuidance] : []),
3505
+ ...(groupApproval ? [GROUP_APPROVAL_GUIDANCE] : []),
3398
3506
  baseTurnPrompt,
3399
3507
  ].filter(Boolean).join("\n\n");
3400
3508
  const ctxPayload = resolveChannelContextBuilder(rt)({
@@ -3560,7 +3668,7 @@ export async function startOpenclawClawlingGateway(params: StartGatewayParams):
3560
3668
  // conversation, not `ownerUserId` (a usr_… chat_id is rejected by
3561
3669
  // member-backend). Passed as a thunk so it is resolved at send time.
3562
3670
  resolveOwnerConversationId: resolveOwnerDirectConversationId,
3563
- nonOwnerTurn: narrowHostTools,
3671
+ nonOwnerTurn: turnClass === "non-owner",
3564
3672
  log,
3565
3673
  });
3566
3674
 
@@ -71,7 +71,7 @@ export const OFFICIAL_SKILLS_BASE =
71
71
  * in the install-cli repo, bump this constant, ship it. `liveware-sample.ts`
72
72
  * imports the same ref, so the `livewares` tree at that tag is pinned too.
73
73
  */
74
- export const DEFAULT_SKILLS_REF = "skills-v1.17.0";
74
+ export const DEFAULT_SKILLS_REF = "skills-v1.17.1";
75
75
 
76
76
  /** Refuse to treat an absurdly large response as a skill file (defence in depth). */
77
77
  export const MAX_SKILL_BYTES = 256 * 1024;
package/src/tools.ts CHANGED
@@ -40,8 +40,10 @@ import {
40
40
  } from "./clawchat-memory.ts";
41
41
  import {
42
42
  canReadMemory,
43
+ canWriteMemory,
43
44
  memoryScopeNote,
44
45
  memoryScopeRefusal,
46
+ ownerNoteWriteRefusal,
45
47
  resolveMemoryScope,
46
48
  type MemoryScope,
47
49
  } from "./memory-scope.ts";
@@ -290,7 +292,7 @@ function toolDescription(...parts: string[]): string {
290
292
  }
291
293
 
292
294
  const MEMORY_SCOPE_DESCRIPTION =
293
- "In a non-owner direct chat only that person's note is readable; in a group only that group's note and its members' notes are readable. owner.md is readable only in the owner's direct chat. Replace, edit and metadata operations require a readable target; append may add facts to any target without revealing existing content.";
295
+ "In a non-owner direct chat only that person's note is readable; in a group only that group's note and its members' notes are readable. owner.md is readable only in the owner's direct chat. Replace, edit and metadata operations require a readable target; append may add facts to any other target without revealing existing content. owner.md can only be written (append included) in the owner's direct chat; elsewhere, record facts about the owner in the speaker's own users/<id>.md or this group's note.";
294
296
 
295
297
  function memoryToolDescription(...parts: string[]): string {
296
298
  return `${parts.join("")} ${MEMORY_SCOPE_DESCRIPTION}`;
@@ -982,8 +984,12 @@ export function registerOpenclawClawlingTools(
982
984
  const target = targetFromParams(p);
983
985
  const scope = await memoryScopeFor(ctx, root);
984
986
  readable = canReadMemory(scope, target);
985
- // Replacing wipes what is there; append may go to any note.
986
- if (p.mode !== "append" && !readable) return jsonResponse(memoryScopeRefusal(scope, target));
987
+ // Replacing wipes what is there; append may go to any note but owner.md.
988
+ if (!canWriteMemory(scope, target, p.mode === "append")) {
989
+ return jsonResponse(target.targetType === "owner"
990
+ ? ownerNoteWriteRefusal(scope)
991
+ : memoryScopeRefusal(scope, target));
992
+ }
987
993
  await writeClawChatMemoryBody(root, target, p.mode, p.content);
988
994
  return jsonResponse({ ok: true, targetType: p.targetType, targetId: p.targetId, mode: p.mode });
989
995
  } catch (err) {
@@ -1022,7 +1028,11 @@ export function registerOpenclawClawlingTools(
1022
1028
  if (targetError) return memoryToolError(new Error(targetError));
1023
1029
  const target = targetFromParams(p);
1024
1030
  const scope = await memoryScopeFor(ctx, root);
1025
- if (!canReadMemory(scope, target)) return jsonResponse(memoryScopeRefusal(scope, target));
1031
+ if (!canWriteMemory(scope, target, false)) {
1032
+ return jsonResponse(target.targetType === "owner"
1033
+ ? ownerNoteWriteRefusal(scope)
1034
+ : memoryScopeRefusal(scope, target));
1035
+ }
1026
1036
  await editClawChatMemoryBody(root, target, p.oldText, p.newText);
1027
1037
  return jsonResponse({ ok: true, targetType: p.targetType, targetId: p.targetId });
1028
1038
  } catch (err) {