Skip to content

Commit 56c2d63

Browse files
fix(qqbot): tighten bundled skill guardrails (#98032)
1 parent 85ee712 commit 56c2d63

9 files changed

Lines changed: 283 additions & 74 deletions

File tree

extensions/qqbot/skills/qqbot-channel/SKILL.md

Lines changed: 54 additions & 41 deletions
Large diffs are not rendered by default.

extensions/qqbot/skills/qqbot-channel/references/api_references.md

Lines changed: 16 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,14 @@
44

55
通过 `qqbot_channel_api` 工具代理请求,工具自动处理鉴权。
66

7+
## 调用安全规则
8+
9+
- `POST``PUT``PATCH``DELETE` 会修改真实 QQ 资源。调用前确认用户明确授权了该操作。
10+
- 删除接口不可逆。删除前先用读取接口确认目标 ID、名称和范围,并把将要删除的对象复述给用户;`qqbot_channel_api` 要求 `confirmed: true` 才会执行 `DELETE`
11+
- 批量删除 sentinel 必须二次确认并额外传 `bulkConfirmed: true`;不要把模糊表达自动扩展成“删除全部”。
12+
- 删除端点不作为普通 agent 速查路径列出。需要删除时,先用读取接口确认对象,再通过受确认保护的删除流程执行。
13+
- 成员资料和头像 URL 只用于当前请求;除非用户明确要求查看头像/图标,不要内联展示或转发这些图片 URL。
14+
715
---
816

917
## 📌 通用说明
@@ -346,9 +354,9 @@ interface Schedule {
346354

347355
---
348356

349-
### DELETE /channels/{channel_id} — 删除子频道
357+
### 删除子频道(破坏性操作)
350358

351-
> ⚠️ 不可逆!仅私域机器人可用
359+
> ⚠️ 不可逆!仅私域机器人可用。调用前必须确认具体子频道 ID、子频道名称和用户删除意图,并传 `confirmed: true`;不要按模糊名称猜测删除目标。确认后使用子频道资源路径 `/channels/{channel_id}`
352360
353361
---
354362

@@ -415,9 +423,9 @@ interface Schedule {
415423

416424
---
417425

418-
### DELETE /guilds/{guild_id}/announces/{message_id} — 删除公告
426+
### 删除公告(破坏性操作)
419427

420-
> `message_id` 设为 `all` 删除所有公告
428+
> 调用前必须确认具体公告 ID 并传 `confirmed: true`,确认后使用公告资源路径 `/guilds/{guild_id}/announces/{message_id}`。批量删除全部公告只能在用户明确要求并再次确认后使用 `/guilds/{guild_id}/announces/all`,并且必须额外传 `bulkConfirmed: true`
421429
422430
---
423431

@@ -453,9 +461,9 @@ interface Schedule {
453461

454462
---
455463

456-
### DELETE /channels/{channel_id}/threads/{thread_id} — 删除帖子
464+
### 删除帖子(破坏性操作)
457465

458-
> ⚠️ 不可逆!仅私域机器人可用
466+
> ⚠️ 不可逆!仅私域机器人可用。调用前必须确认具体帖子 ID、帖子标题/作者和用户删除意图,并传 `confirmed: true`。确认后使用帖子资源路径 `/channels/{channel_id}/threads/{thread_id}`
459467
460468
---
461469

@@ -516,6 +524,6 @@ interface Schedule {
516524

517525
---
518526

519-
### DELETE /channels/{channel_id}/schedules/{schedule_id} — 删除日程
527+
### 删除日程(破坏性操作)
520528

521-
> ⚠️ 不可逆!需要管理频道权限
529+
> ⚠️ 不可逆!需要管理频道权限。调用前必须确认具体日程 ID、日程名称/时间和用户删除意图,并传 `confirmed: true`。确认后使用日程资源路径 `/channels/{channel_id}/schedules/{schedule_id}`

extensions/qqbot/skills/qqbot-media/SKILL.md

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
---
22
name: qqbot-media
3-
description: QQBot rich media send and receive support. Use <qqmedia> tags to send image, voice, video, or file attachments, with the media type inferred from the file extension.
3+
description: QQBot rich media send and receive support. Use <qqmedia> tags only for explicit media send/view requests, treating inbound attachment paths as private current-conversation context.
44
metadata: { "openclaw": { "emoji": "📸", "requires": { "config": ["channels.qqbot"] } } }
55
---
66

@@ -22,8 +22,10 @@ metadata: { "openclaw": { "emoji": "📸", "requires": { "config": ["channels.qq
2222

2323
## 接收媒体
2424

25-
- 用户发来的**图片**自动下载到本地,路径在上下文【附件】中,可直接用 `<qqmedia>路径</qqmedia>` 回发
26-
- 用户发来的**语音**路径在上下文中;若有 STT 能力则优先转写
25+
- 用户发来的**图片**会由 QQBot 运行时下载到 OpenClaw 管理的 QQBot media 目录,路径只作为当前会话的附件上下文使用。
26+
- 用户发来的**语音**路径在上下文中;若有 STT 能力则优先转写。
27+
- 附件路径和远程 URL 可能包含用户私有内容。不要无关输出本地绝对路径,不要把附件转发到其他会话;只有用户明确要求回发、分析或转存该媒体时才使用。
28+
- 不承诺长期保留附件。若用户需要长期保存,说明应由用户自行保存或重新发送。
2729

2830
## 规则
2931

@@ -35,3 +37,4 @@ metadata: { "openclaw": { "emoji": "📸", "requires": { "config": ["channels.qq
3537
6. 发送语音时不要重复语音中已朗读的文字
3638
7. 多个媒体用多个标签
3739
8. 以会话上下文中的能力说明为准(如未启用语音则不要发语音)
40+
9. 不要扫描或发送上下文之外的本地文件;只使用用户提供、工具生成,或明确位于受信 media 目录中的路径

extensions/qqbot/skills/qqbot-remind/SKILL.md

Lines changed: 15 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,30 +1,31 @@
11
---
22
name: qqbot-remind
3-
description: QQBot scheduled reminders. Create, list, and cancel one-time or recurring reminders when a QQ conversation involves reminders, alarms, or scheduled tasks.
3+
description: QQBot scheduled reminders. Use only for explicit user requests to create, list, or cancel one-time or recurring QQ reminders; ask for missing time, content, or timezone before scheduling.
44
metadata: { "openclaw": { "emoji": "⏰", "requires": { "config": ["channels.qqbot"] } } }
55
---
66

77
# QQ Bot 定时提醒
88

9-
## ⚠️ 强制规则
9+
## ⚠️ 意图规则
1010

11-
**当用户提到「提醒」「闹钟」「定时」「X分钟/小时后」「每天X点」「叫我」等任何涉及延时或定时的请求时,你必须调用工具,绝对不能只用自然语言回复说"好的,我会提醒你"!**
11+
只有当用户明确要求创建、查询或取消提醒/闹钟/定时任务时,才调用工具。闲聊、假设、解释提醒功能、讨论将来计划但未要求创建提醒时,不要调用工具。
1212

13-
你没有内存或后台线程,口头承诺"到时候提醒"是无效的——只有调用工具才能真正注册定时任务
13+
如果用户确实要求提醒,你没有内存或后台线程,口头承诺"到时候提醒"是无效的——必须调用工具才能真正注册定时任务。时间、提醒内容、目标会话或时区不清楚时先追问;不要替用户猜测
1414

1515
---
1616

1717
## 推荐流程(使用 `qqbot_remind` 工具)
1818

1919
**第一步**:调用 `qqbot_remind` 工具,传入简单参数:
2020

21-
| 参数 | 说明 | 示例 |
22-
| --------- | -------------------------------------------- | ---------------------------------- |
23-
| `action` | 操作类型 | `"add"` / `"list"` / `"remove"` |
24-
| `content` | 提醒内容 | `"喝水"` |
25-
| `to` | 目标地址(可选,系统自动获取,通常无需填写) ||
26-
| `time` | 时间(相对时间或 cron 表达式) | `"5m"` / `"1h30m"` / `"0 8 * * *"` |
27-
| `jobId` | 任务 ID(仅 remove) | `"xxx"` |
21+
| 参数 | 说明 | 示例 |
22+
| ---------- | -------------------------------------------- | ------------------------------------------- |
23+
| `action` | 操作类型 | `"add"` / `"list"` / `"remove"` |
24+
| `content` | 提醒内容 | `"喝水"` |
25+
| `to` | 目标地址(可选,系统自动获取,通常无需填写) ||
26+
| `time` | 时间(相对时间或 cron 表达式) | `"5m"` / `"1h30m"` / `"0 8 * * *"` |
27+
| `timezone` | IANA 时区(周期提醒建议明确传入) | `"Asia/Shanghai"` / `"America/Los_Angeles"` |
28+
| `jobId` | 任务 ID(仅 remove) | `"xxx"` |
2829

2930
**第二步**:根据 `qqbot_remind` 的返回结果,回复用户。`qqbot_remind` 会直接创建、查询或取消 Gateway cron 任务;成功后不要再调用 `cron` 工具。
3031

@@ -92,7 +93,7 @@ metadata: { "openclaw": { "emoji": "⏰", "requires": { "config": ["channels.qqb
9293
"action": "add",
9394
"job": {
9495
"name": "{任务名}",
95-
"schedule": { "kind": "cron", "expr": "0 8 * * *", "tz": "Asia/Shanghai" },
96+
"schedule": { "kind": "cron", "expr": "0 8 * * *", "tz": "{用户确认的 IANA 时区}" },
9697
"sessionTarget": "isolated",
9798
"wakeMode": "now",
9899
"payload": {
@@ -124,7 +125,7 @@ metadata: { "openclaw": { "emoji": "⏰", "requires": { "config": ["channels.qqb
124125
| 每周末上午10点 | `"0 10 * * 0,6"` |
125126
| 每小时整点 | `"0 * * * *"` |
126127

127-
> 周期提醒必须加 `"tz": "Asia/Shanghai"`
128+
> 周期提醒应使用用户明确提供、用户资料/会话中可信可得,或用户确认过的 IANA 时区。无法判断时先追问;不要把所有用户都假定在同一时区
128129
129130
---
130131

@@ -141,7 +142,7 @@ metadata: { "openclaw": { "emoji": "⏰", "requires": { "config": ["channels.qqb
141142
| "修改提醒时间" | `remove``add` ||
142143
| "提醒我"(无时间) | **需追问** ||
143144

144-
纯相对时间("5分钟后"、"1小时后")可直接计算,无需确认。时间模糊或缺失时需追问
145+
纯相对时间("5分钟后"、"1小时后")可直接计算,无需确认。时间、日期、周期、内容或时区模糊/缺失时需追问。周期提醒在回复中说明解释后的本地时间和时区
145146

146147
---
147148

extensions/qqbot/src/bridge/tools/channel.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ export function registerChannelTool(api: OpenClawPluginApi): void {
3535
label: "QQBot Channel API",
3636
description:
3737
"Authenticated HTTP proxy for QQ Open Platform channel APIs. " +
38+
"Use write and delete endpoints only after explicit user intent; DELETE requires confirmed=true, and bulk deletes require bulkConfirmed=true after confirming the exact target. " +
3839
"Common endpoints: " +
3940
"list guilds GET /users/@me/guilds | " +
4041
"list channels GET /guilds/{guild_id}/channels | " +

extensions/qqbot/src/bridge/tools/remind.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -52,12 +52,13 @@ export function createRemindTool(
5252
label: "QQBot Reminder",
5353
description:
5454
"Create, list, and remove QQ reminders. " +
55+
"Use only for explicit user requests, and ask when reminder content, schedule, or timezone is ambiguous. " +
5556
"This tool schedules Gateway cron jobs directly; do not call the cron tool after it succeeds.\n" +
5657
"Create: action=add, content=message, time=schedule (to is optional, " +
5758
"resolved automatically from the current conversation)\n" +
5859
"List: action=list\n" +
5960
"Remove: action=remove, jobId=job id from list\n" +
60-
'Time examples: "5m", "1h", "0 8 * * *"',
61+
'Time examples: "5m", "1h", "0 8 * * *"; include timezone for recurring cron reminders when known.',
6162
parameters: RemindSchema,
6263
async execute(_toolCallId, params) {
6364
const ctx = getRequestContext();

extensions/qqbot/src/engine/tools/channel-api.test.ts

Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -139,4 +139,113 @@ describe("executeChannelApi", () => {
139139
expect(textSpy).not.toHaveBeenCalled();
140140
expect(release).toHaveBeenCalledTimes(1);
141141
});
142+
143+
it("requires confirmation before DELETE requests", async () => {
144+
const result = await executeChannelApi(
145+
{ method: "DELETE", path: "/channels/123" },
146+
{ accessToken: "token-1" },
147+
);
148+
149+
expect(result.details).toEqual({
150+
error:
151+
"DELETE requests require confirmed=true after the user confirms the exact QQ resource.",
152+
path: "/channels/123",
153+
});
154+
expect(fetchWithSsrFGuardMock).not.toHaveBeenCalled();
155+
});
156+
157+
it("allows confirmed DELETE requests", async () => {
158+
const release = vi.fn(async () => {});
159+
fetchWithSsrFGuardMock.mockResolvedValueOnce({
160+
response: new Response(null, { status: 204, statusText: "No Content" }),
161+
release,
162+
});
163+
164+
const result = await executeChannelApi(
165+
{ method: "DELETE", path: "/channels/123", confirmed: true },
166+
{ accessToken: "token-1" },
167+
);
168+
169+
expect(result.details).toEqual({
170+
success: true,
171+
status: 204,
172+
path: "/channels/123",
173+
});
174+
expect(fetchWithSsrFGuardMock).toHaveBeenCalledWith(
175+
expect.objectContaining({
176+
url: "https://api.sgroup.qq.com/channels/123",
177+
init: expect.objectContaining({ method: "DELETE" }),
178+
}),
179+
);
180+
expect(release).toHaveBeenCalledTimes(1);
181+
});
182+
183+
it("requires separate confirmation before bulk announcement deletes", async () => {
184+
const result = await executeChannelApi(
185+
{ method: "DELETE", path: "/guilds/123/announces/all", confirmed: true },
186+
{ accessToken: "token-1" },
187+
);
188+
189+
expect(result.details).toEqual({
190+
error:
191+
"Deleting all announcements requires bulkConfirmed=true after a separate bulk-delete confirmation.",
192+
path: "/guilds/123/announces/all",
193+
});
194+
expect(fetchWithSsrFGuardMock).not.toHaveBeenCalled();
195+
});
196+
197+
it("requires bulk confirmation for encoded all announcement sentinel", async () => {
198+
const result = await executeChannelApi(
199+
{ method: "DELETE", path: "/guilds/123/announces/%61%6c%6c", confirmed: true },
200+
{ accessToken: "token-1" },
201+
);
202+
203+
expect(result.details).toEqual({
204+
error:
205+
"Deleting all announcements requires bulkConfirmed=true after a separate bulk-delete confirmation.",
206+
path: "/guilds/123/announces/%61%6c%6c",
207+
});
208+
expect(fetchWithSsrFGuardMock).not.toHaveBeenCalled();
209+
});
210+
211+
it("rejects encoded path separators before fetch", async () => {
212+
const result = await executeChannelApi(
213+
{ method: "GET", path: "/guilds/123%2fannounces" },
214+
{ accessToken: "token-1" },
215+
);
216+
217+
expect(result.details).toEqual({ error: "path contains encoded path separators" });
218+
expect(fetchWithSsrFGuardMock).not.toHaveBeenCalled();
219+
});
220+
221+
it("allows bulk announcement deletes after both confirmations", async () => {
222+
const release = vi.fn(async () => {});
223+
fetchWithSsrFGuardMock.mockResolvedValueOnce({
224+
response: new Response(null, { status: 204, statusText: "No Content" }),
225+
release,
226+
});
227+
228+
const result = await executeChannelApi(
229+
{
230+
method: "DELETE",
231+
path: "/guilds/123/announces/all",
232+
confirmed: true,
233+
bulkConfirmed: true,
234+
},
235+
{ accessToken: "token-1" },
236+
);
237+
238+
expect(result.details).toEqual({
239+
success: true,
240+
status: 204,
241+
path: "/guilds/123/announces/all",
242+
});
243+
expect(fetchWithSsrFGuardMock).toHaveBeenCalledWith(
244+
expect.objectContaining({
245+
url: "https://api.sgroup.qq.com/guilds/123/announces/all",
246+
init: expect.objectContaining({ method: "DELETE" }),
247+
}),
248+
);
249+
expect(release).toHaveBeenCalledTimes(1);
250+
});
142251
});

0 commit comments

Comments
 (0)