From 56c2d637d9405d00b38a2ca87b92a25f8622d93d Mon Sep 17 00:00:00 2001 From: Patrick Erichsen Date: Mon, 29 Jun 2026 22:48:52 -0700 Subject: [PATCH] fix(qqbot): tighten bundled skill guardrails (#98032) --- .../qqbot/skills/qqbot-channel/SKILL.md | 95 ++++++++------- .../references/api_references.md | 24 ++-- extensions/qqbot/skills/qqbot-media/SKILL.md | 9 +- extensions/qqbot/skills/qqbot-remind/SKILL.md | 29 ++--- extensions/qqbot/src/bridge/tools/channel.ts | 1 + extensions/qqbot/src/bridge/tools/remind.ts | 3 +- .../src/engine/tools/channel-api.test.ts | 109 ++++++++++++++++++ .../qqbot/src/engine/tools/channel-api.ts | 74 +++++++++++- .../qqbot/src/engine/tools/remind-logic.ts | 13 ++- 9 files changed, 283 insertions(+), 74 deletions(-) diff --git a/extensions/qqbot/skills/qqbot-channel/SKILL.md b/extensions/qqbot/skills/qqbot-channel/SKILL.md index 93a2142d6041..af6976a3848a 100644 --- a/extensions/qqbot/skills/qqbot-channel/SKILL.md +++ b/extensions/qqbot/skills/qqbot-channel/SKILL.md @@ -1,6 +1,6 @@ --- name: qqbot-channel -description: QQ channel management skill. Use qqbot_channel_api to list guilds and channels, inspect members, publish posts, manage announcements, and work with schedules through the QQ Open Platform HTTP API with automatic token authentication. +description: QQ channel management skill. Use qqbot_channel_api for explicit QQ channel-management requests; confirm write, delete, and bulk actions before calling authenticated QQ Open Platform endpoints. metadata: { "openclaw": { "emoji": "📡", "requires": { "config": ["channels.qqbot"] } } } --- @@ -18,15 +18,25 @@ metadata: { "openclaw": { "emoji": "📡", "requires": { "config": ["channels.qq ## 🔧 工具参数 -| 参数 | 类型 | 必填 | 说明 | -| -------- | ------ | ---- | ---------------------------------------------------------------------------- | -| `method` | string | 是 | HTTP 方法:`GET`, `POST`, `PUT`, `PATCH`, `DELETE` | -| `path` | string | 是 | API 路径(不含域名),如 `/guilds/{guild_id}/channels`,需替换占位符为实际值 | -| `body` | object | 否 | 请求体 JSON(POST/PUT/PATCH 使用) | -| `query` | object | 否 | URL 查询参数键值对,值为字符串类型 | +| 参数 | 类型 | 必填 | 说明 | +| --------------- | ------- | ---- | ---------------------------------------------------------------------------- | +| `method` | string | 是 | HTTP 方法:`GET`, `POST`, `PUT`, `PATCH`, `DELETE` | +| `path` | string | 是 | API 路径(不含域名),如 `/guilds/{guild_id}/channels`,需替换占位符为实际值 | +| `body` | object | 否 | 请求体 JSON(POST/PUT/PATCH 使用) | +| `query` | object | 否 | URL 查询参数键值对,值为字符串类型 | +| `confirmed` | boolean | 否 | `DELETE` 必须传 `true`,表示用户已确认精确删除目标 | +| `bulkConfirmed` | boolean | 否 | 批量 `DELETE`(如删除全部公告)必须额外传 `true` | > 基础 URL:`https://api.sgroup.qq.com`,鉴权头 `Authorization: QQBot {token}` 由工具自动填充。 +## 🛡️ 安全边界 + +- 只在用户明确要求管理 QQ 频道、子频道、公告、论坛帖子或日程时调用写入接口。 +- `POST`、`PUT`、`PATCH` 和 `DELETE` 会修改真实 QQ 资源。调用前先复述目标频道/子频道/帖子/日程和预期改动;删除、批量删除、公告覆盖等不可逆或大范围操作必须等用户确认后再执行。 +- 删除前优先用 `GET`/列表接口查出候选项,让用户选择具体 ID;不要根据模糊名称猜测删除目标。 +- `DELETE` 请求必须传 `confirmed: true`,否则工具会拒绝执行。`announces/all` 这样的批量操作还必须传 `bulkConfirmed: true`,只有在用户明确说要删除全部公告并再次确认后才可使用。 +- 成员资料、头像 URL、频道图标等属于用户/群组资料。默认只总结必要字段;只有用户要求查看头像/图标或视觉比对时才内联展示图片,不要无关转发头像 URL。 + --- ## ⭐ 接口速查 @@ -40,13 +50,13 @@ metadata: { "openclaw": { "emoji": "📡", "requires": { "config": ["channels.qq ### 子频道(Channel) -| 操作 | 方法 | 路径 | 参数说明 | -| -------------- | -------- | ----------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------- | -| 获取子频道列表 | `GET` | `/guilds/{guild_id}/channels` | — | -| 获取子频道详情 | `GET` | `/channels/{channel_id}` | — | -| 创建子频道 | `POST` | `/guilds/{guild_id}/channels` | body: `name`\*, `type`\*, `position`\*, `sub_type`, `parent_id`, `private_type`, `private_user_ids`, `speak_permission`, `application_id` | -| 修改子频道 | `PATCH` | `/channels/{channel_id}` | body: `name`, `position`, `parent_id`, `private_type`, `speak_permission`(至少一个) | -| 删除子频道 | `DELETE` | `/channels/{channel_id}` | ⚠️ 不可逆 | +| 操作 | 方法 | 路径 | 参数说明 | +| -------------- | ------- | ----------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------- | +| 获取子频道列表 | `GET` | `/guilds/{guild_id}/channels` | — | +| 获取子频道详情 | `GET` | `/channels/{channel_id}` | — | +| 创建子频道 | `POST` | `/guilds/{guild_id}/channels` | body: `name`\*, `type`\*, `position`\*, `sub_type`, `parent_id`, `private_type`, `private_user_ids`, `speak_permission`, `application_id` | +| 修改子频道 | `PATCH` | `/channels/{channel_id}` | body: `name`, `position`, `parent_id`, `private_type`, `speak_permission`(至少一个) | +| 删除子频道 | — | 见受确认保护的删除流程 | 破坏性操作;不要在未确认时调用 | **子频道类型(type)**:`0`=文字, `2`=语音, `4`=分组(position≥2), `10005`=直播, `10006`=应用, `10007`=论坛 @@ -61,28 +71,28 @@ metadata: { "openclaw": { "emoji": "📡", "requires": { "config": ["channels.qq ### 公告(Announces) -| 操作 | 方法 | 路径 | 参数说明 | -| -------- | -------- | ------------------------------------------- | ------------------------------------------------------------------------------------------------ | -| 创建公告 | `POST` | `/guilds/{guild_id}/announces` | body: `message_id`, `channel_id`, `announces_type`(0=成员,1=欢迎), `recommend_channels`(最多3条) | -| 删除公告 | `DELETE` | `/guilds/{guild_id}/announces/{message_id}` | message_id 设 `all` 删除所有 | +| 操作 | 方法 | 路径 | 参数说明 | +| -------- | ------ | ------------------------------ | ------------------------------------------------------------------------------------------------ | +| 创建公告 | `POST` | `/guilds/{guild_id}/announces` | body: `message_id`, `channel_id`, `announces_type`(0=成员,1=欢迎), `recommend_channels`(最多3条) | +| 删除公告 | — | 见受确认保护的删除流程 | 破坏性操作;批量删除需二次确认 | ### 论坛(Forum)— 仅私域机器人 -| 操作 | 方法 | 路径 | 参数说明 | -| ------------ | -------- | ---------------------------------------------------- | ------------------------------------------------------------------------------ | -| 获取帖子列表 | `GET` | `/channels/{channel_id}/threads` | — | -| 获取帖子详情 | `GET` | `/channels/{channel_id}/threads/{thread_id}` | — | -| 发表帖子 | `PUT` | `/channels/{channel_id}/threads` | body: `title`\*, `content`\*, `format`(1=文本,2=HTML,3=Markdown,4=JSON,默认3) | -| 删除帖子 | `DELETE` | `/channels/{channel_id}/threads/{thread_id}` | ⚠️ 不可逆 | -| 发表评论 | `POST` | `/channels/{channel_id}/threads/{thread_id}/comment` | body: `thread_author`\*, `content`\*, `thread_create_time`, `image` | +| 操作 | 方法 | 路径 | 参数说明 | +| ------------ | ------ | ---------------------------------------------------- | ------------------------------------------------------------------------------ | +| 获取帖子列表 | `GET` | `/channels/{channel_id}/threads` | — | +| 获取帖子详情 | `GET` | `/channels/{channel_id}/threads/{thread_id}` | — | +| 发表帖子 | `PUT` | `/channels/{channel_id}/threads` | body: `title`\*, `content`\*, `format`(1=文本,2=HTML,3=Markdown,4=JSON,默认3) | +| 删除帖子 | — | 见受确认保护的删除流程 | 破坏性操作;不要在未确认时调用 | +| 发表评论 | `POST` | `/channels/{channel_id}/threads/{thread_id}/comment` | body: `thread_author`\*, `content`\*, `thread_create_time`, `image` | ### 日程(Schedule) -| 操作 | 方法 | 路径 | 参数说明 | -| -------- | -------- | ------------------------------------------------ | ----------------------------------------------------------------------------------------------- | -| 创建日程 | `POST` | `/channels/{channel_id}/schedules` | body: `{ schedule: { name*, start_timestamp*, end_timestamp*, jump_channel_id, remind_type } }` | -| 修改日程 | `PATCH` | `/channels/{channel_id}/schedules/{schedule_id}` | body: `{ schedule: { name*, start_timestamp*, end_timestamp*, jump_channel_id, remind_type } }` | -| 删除日程 | `DELETE` | `/channels/{channel_id}/schedules/{schedule_id}` | ⚠️ 不可逆 | +| 操作 | 方法 | 路径 | 参数说明 | +| -------- | ------- | ------------------------------------------------ | ----------------------------------------------------------------------------------------------- | +| 创建日程 | `POST` | `/channels/{channel_id}/schedules` | body: `{ schedule: { name*, start_timestamp*, end_timestamp*, jump_channel_id, remind_type } }` | +| 修改日程 | `PATCH` | `/channels/{channel_id}/schedules/{schedule_id}` | body: `{ schedule: { name*, start_timestamp*, end_timestamp*, jump_channel_id, remind_type } }` | +| 删除日程 | — | 见受确认保护的删除流程 | 破坏性操作;不要在未确认时调用 | **提醒类型(remind_type)**:`"0"`=不提醒, `"1"`=开始时, `"2"`=5分钟前, `"3"`=15分钟前, `"4"`=30分钟前, `"5"`=60分钟前 @@ -180,14 +190,17 @@ metadata: { "openclaw": { "emoji": "📡", "requires": { "config": ["channels.qq } ``` -### 删除所有公告 +### 受确认保护的删除流程 -```json -{ - "method": "DELETE", - "path": "/guilds/123456/announces/all" -} -``` +删除类 QQ API 不作为普通速查示例暴露。若用户明确要求删除资源,先读取并复述目标对象,确认后再调用 `qqbot_channel_api`:`method` 设为 `"DELETE"`,`confirmed` 设为 `true`,`path` 使用已确认对象对应的资源路径。 + +| 删除对象 | 已确认后使用的 `path` | 额外要求 | +| -------- | ------------------------------------------------ | ---------------------------------------- | +| 子频道 | `/channels/{channel_id}` | 确认子频道 ID 和名称 | +| 单条公告 | `/guilds/{guild_id}/announces/{message_id}` | 确认公告 ID | +| 全部公告 | `/guilds/{guild_id}/announces/all` | 用户再次确认后再传 `bulkConfirmed: true` | +| 帖子 | `/channels/{channel_id}/threads/{thread_id}` | 确认帖子 ID、标题/作者 | +| 日程 | `/channels/{channel_id}/schedules/{schedule_id}` | 确认日程 ID、名称/时间 | --- @@ -222,7 +235,7 @@ metadata: { "openclaw": { "emoji": "📡", "requires": { "config": ["channels.qq ### 展示成员头像 -成员详情返回的 `user.avatar` 是头像 URL,**必须使用 Markdown 图片语法展示**,让用户直接看到头像图片,而非纯文本链接: +成员详情返回的 `user.avatar` 是头像 URL。默认只展示昵称、ID、加入时间等必要字段;当用户明确要求查看头像/图标或头像是当前任务的必要依据时,再用 Markdown 图片语法内联展示: ``` 成员信息: @@ -231,7 +244,7 @@ metadata: { "openclaw": { "emoji": "📡", "requires": { "config": ["channels.qq ![头像]({user.avatar}) ``` -> **禁止**将头像 URL 作为纯文本或超链接展示(如 `查看头像`),必须用 `![描述](URL)` 语法内联显示。频道的 `icon` 字段同理。 +不要无关输出原始头像 URL 或把头像作为普通链接转发。频道的 `icon` 字段同理:仅在用户明确需要查看时展示。 --- @@ -255,8 +268,8 @@ metadata: { "openclaw": { "emoji": "📡", "requires": { "config": ["channels.qq 3. **成员列表翻页**时可能返回重复成员,需按 `user.id` 去重 4. **公告**的两种类型(消息公告和推荐子频道公告)会互相顶替 5. **日程**的时间戳为毫秒级字符串 -6. **删除操作不可逆**,请谨慎使用 +6. **删除操作不可逆**,必须先确认精确目标并传 `confirmed: true`;批量删除需二次确认并传 `bulkConfirmed: true` 7. **论坛操作**仅私域机器人可用 8. **子频道分组**(type=4)的 `position` 必须 >= 2 9. **日程操作**有频率限制:单个管理员每天 10 次,单个频道每天 100 次 -10. **头像/图标展示**:成员 `user.avatar` 和频道 `icon` 等图片 URL 必须使用 Markdown 图片语法 `![描述](URL)` 展示,禁止作为纯文本或超链接展示 +10. **头像/图标展示**:成员 `user.avatar` 和频道 `icon` 等图片 URL 属于资料信息;默认总结必要字段,只在用户明确需要查看图片时用 Markdown 图片语法 `![描述](URL)` 展示 diff --git a/extensions/qqbot/skills/qqbot-channel/references/api_references.md b/extensions/qqbot/skills/qqbot-channel/references/api_references.md index acf0da8dc031..fb60f84154e1 100644 --- a/extensions/qqbot/skills/qqbot-channel/references/api_references.md +++ b/extensions/qqbot/skills/qqbot-channel/references/api_references.md @@ -4,6 +4,14 @@ 通过 `qqbot_channel_api` 工具代理请求,工具自动处理鉴权。 +## 调用安全规则 + +- `POST`、`PUT`、`PATCH` 和 `DELETE` 会修改真实 QQ 资源。调用前确认用户明确授权了该操作。 +- 删除接口不可逆。删除前先用读取接口确认目标 ID、名称和范围,并把将要删除的对象复述给用户;`qqbot_channel_api` 要求 `confirmed: true` 才会执行 `DELETE`。 +- 批量删除 sentinel 必须二次确认并额外传 `bulkConfirmed: true`;不要把模糊表达自动扩展成“删除全部”。 +- 删除端点不作为普通 agent 速查路径列出。需要删除时,先用读取接口确认对象,再通过受确认保护的删除流程执行。 +- 成员资料和头像 URL 只用于当前请求;除非用户明确要求查看头像/图标,不要内联展示或转发这些图片 URL。 + --- ## 📌 通用说明 @@ -346,9 +354,9 @@ interface Schedule { --- -### DELETE /channels/{channel_id} — 删除子频道 +### 删除子频道(破坏性操作) -> ⚠️ 不可逆!仅私域机器人可用 +> ⚠️ 不可逆!仅私域机器人可用。调用前必须确认具体子频道 ID、子频道名称和用户删除意图,并传 `confirmed: true`;不要按模糊名称猜测删除目标。确认后使用子频道资源路径 `/channels/{channel_id}`。 --- @@ -415,9 +423,9 @@ interface Schedule { --- -### DELETE /guilds/{guild_id}/announces/{message_id} — 删除公告 +### 删除公告(破坏性操作) -> `message_id` 设为 `all` 删除所有公告 +> 调用前必须确认具体公告 ID 并传 `confirmed: true`,确认后使用公告资源路径 `/guilds/{guild_id}/announces/{message_id}`。批量删除全部公告只能在用户明确要求并再次确认后使用 `/guilds/{guild_id}/announces/all`,并且必须额外传 `bulkConfirmed: true`。 --- @@ -453,9 +461,9 @@ interface Schedule { --- -### DELETE /channels/{channel_id}/threads/{thread_id} — 删除帖子 +### 删除帖子(破坏性操作) -> ⚠️ 不可逆!仅私域机器人可用 +> ⚠️ 不可逆!仅私域机器人可用。调用前必须确认具体帖子 ID、帖子标题/作者和用户删除意图,并传 `confirmed: true`。确认后使用帖子资源路径 `/channels/{channel_id}/threads/{thread_id}`。 --- @@ -516,6 +524,6 @@ interface Schedule { --- -### DELETE /channels/{channel_id}/schedules/{schedule_id} — 删除日程 +### 删除日程(破坏性操作) -> ⚠️ 不可逆!需要管理频道权限 +> ⚠️ 不可逆!需要管理频道权限。调用前必须确认具体日程 ID、日程名称/时间和用户删除意图,并传 `confirmed: true`。确认后使用日程资源路径 `/channels/{channel_id}/schedules/{schedule_id}`。 diff --git a/extensions/qqbot/skills/qqbot-media/SKILL.md b/extensions/qqbot/skills/qqbot-media/SKILL.md index d83d89ec12b4..12ae0202e776 100644 --- a/extensions/qqbot/skills/qqbot-media/SKILL.md +++ b/extensions/qqbot/skills/qqbot-media/SKILL.md @@ -1,6 +1,6 @@ --- name: qqbot-media -description: QQBot rich media send and receive support. Use tags to send image, voice, video, or file attachments, with the media type inferred from the file extension. +description: QQBot rich media send and receive support. Use tags only for explicit media send/view requests, treating inbound attachment paths as private current-conversation context. metadata: { "openclaw": { "emoji": "📸", "requires": { "config": ["channels.qqbot"] } } } --- @@ -22,8 +22,10 @@ metadata: { "openclaw": { "emoji": "📸", "requires": { "config": ["channels.qq ## 接收媒体 -- 用户发来的**图片**自动下载到本地,路径在上下文【附件】中,可直接用 `路径` 回发 -- 用户发来的**语音**路径在上下文中;若有 STT 能力则优先转写 +- 用户发来的**图片**会由 QQBot 运行时下载到 OpenClaw 管理的 QQBot media 目录,路径只作为当前会话的附件上下文使用。 +- 用户发来的**语音**路径在上下文中;若有 STT 能力则优先转写。 +- 附件路径和远程 URL 可能包含用户私有内容。不要无关输出本地绝对路径,不要把附件转发到其他会话;只有用户明确要求回发、分析或转存该媒体时才使用。 +- 不承诺长期保留附件。若用户需要长期保存,说明应由用户自行保存或重新发送。 ## 规则 @@ -35,3 +37,4 @@ metadata: { "openclaw": { "emoji": "📸", "requires": { "config": ["channels.qq 6. 发送语音时不要重复语音中已朗读的文字 7. 多个媒体用多个标签 8. 以会话上下文中的能力说明为准(如未启用语音则不要发语音) +9. 不要扫描或发送上下文之外的本地文件;只使用用户提供、工具生成,或明确位于受信 media 目录中的路径 diff --git a/extensions/qqbot/skills/qqbot-remind/SKILL.md b/extensions/qqbot/skills/qqbot-remind/SKILL.md index 817f943b9c2c..c3c5e1e5c9e6 100644 --- a/extensions/qqbot/skills/qqbot-remind/SKILL.md +++ b/extensions/qqbot/skills/qqbot-remind/SKILL.md @@ -1,16 +1,16 @@ --- name: qqbot-remind -description: QQBot scheduled reminders. Create, list, and cancel one-time or recurring reminders when a QQ conversation involves reminders, alarms, or scheduled tasks. +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. metadata: { "openclaw": { "emoji": "⏰", "requires": { "config": ["channels.qqbot"] } } } --- # QQ Bot 定时提醒 -## ⚠️ 强制规则 +## ⚠️ 意图规则 -**当用户提到「提醒」「闹钟」「定时」「X分钟/小时后」「每天X点」「叫我」等任何涉及延时或定时的请求时,你必须调用工具,绝对不能只用自然语言回复说"好的,我会提醒你"!** +只有当用户明确要求创建、查询或取消提醒/闹钟/定时任务时,才调用工具。闲聊、假设、解释提醒功能、讨论将来计划但未要求创建提醒时,不要调用工具。 -你没有内存或后台线程,口头承诺"到时候提醒"是无效的——只有调用工具才能真正注册定时任务。 +如果用户确实要求提醒,你没有内存或后台线程,口头承诺"到时候提醒"是无效的——必须调用工具才能真正注册定时任务。时间、提醒内容、目标会话或时区不清楚时先追问;不要替用户猜测。 --- @@ -18,13 +18,14 @@ metadata: { "openclaw": { "emoji": "⏰", "requires": { "config": ["channels.qqb **第一步**:调用 `qqbot_remind` 工具,传入简单参数: -| 参数 | 说明 | 示例 | -| --------- | -------------------------------------------- | ---------------------------------- | -| `action` | 操作类型 | `"add"` / `"list"` / `"remove"` | -| `content` | 提醒内容 | `"喝水"` | -| `to` | 目标地址(可选,系统自动获取,通常无需填写) | — | -| `time` | 时间(相对时间或 cron 表达式) | `"5m"` / `"1h30m"` / `"0 8 * * *"` | -| `jobId` | 任务 ID(仅 remove) | `"xxx"` | +| 参数 | 说明 | 示例 | +| ---------- | -------------------------------------------- | ------------------------------------------- | +| `action` | 操作类型 | `"add"` / `"list"` / `"remove"` | +| `content` | 提醒内容 | `"喝水"` | +| `to` | 目标地址(可选,系统自动获取,通常无需填写) | — | +| `time` | 时间(相对时间或 cron 表达式) | `"5m"` / `"1h30m"` / `"0 8 * * *"` | +| `timezone` | IANA 时区(周期提醒建议明确传入) | `"Asia/Shanghai"` / `"America/Los_Angeles"` | +| `jobId` | 任务 ID(仅 remove) | `"xxx"` | **第二步**:根据 `qqbot_remind` 的返回结果,回复用户。`qqbot_remind` 会直接创建、查询或取消 Gateway cron 任务;成功后不要再调用 `cron` 工具。 @@ -92,7 +93,7 @@ metadata: { "openclaw": { "emoji": "⏰", "requires": { "config": ["channels.qqb "action": "add", "job": { "name": "{任务名}", - "schedule": { "kind": "cron", "expr": "0 8 * * *", "tz": "Asia/Shanghai" }, + "schedule": { "kind": "cron", "expr": "0 8 * * *", "tz": "{用户确认的 IANA 时区}" }, "sessionTarget": "isolated", "wakeMode": "now", "payload": { @@ -124,7 +125,7 @@ metadata: { "openclaw": { "emoji": "⏰", "requires": { "config": ["channels.qqb | 每周末上午10点 | `"0 10 * * 0,6"` | | 每小时整点 | `"0 * * * *"` | -> 周期提醒必须加 `"tz": "Asia/Shanghai"`。 +> 周期提醒应使用用户明确提供、用户资料/会话中可信可得,或用户确认过的 IANA 时区。无法判断时先追问;不要把所有用户都假定在同一时区。 --- @@ -141,7 +142,7 @@ metadata: { "openclaw": { "emoji": "⏰", "requires": { "config": ["channels.qqb | "修改提醒时间" | `remove` → `add` | — | | "提醒我"(无时间) | **需追问** | — | -纯相对时间("5分钟后"、"1小时后")可直接计算,无需确认。时间模糊或缺失时需追问。 +纯相对时间("5分钟后"、"1小时后")可直接计算,无需确认。时间、日期、周期、内容或时区模糊/缺失时需追问。周期提醒在回复中说明解释后的本地时间和时区。 --- diff --git a/extensions/qqbot/src/bridge/tools/channel.ts b/extensions/qqbot/src/bridge/tools/channel.ts index efa83af6003d..6bfece68f6c1 100644 --- a/extensions/qqbot/src/bridge/tools/channel.ts +++ b/extensions/qqbot/src/bridge/tools/channel.ts @@ -35,6 +35,7 @@ export function registerChannelTool(api: OpenClawPluginApi): void { label: "QQBot Channel API", description: "Authenticated HTTP proxy for QQ Open Platform channel APIs. " + + "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. " + "Common endpoints: " + "list guilds GET /users/@me/guilds | " + "list channels GET /guilds/{guild_id}/channels | " + diff --git a/extensions/qqbot/src/bridge/tools/remind.ts b/extensions/qqbot/src/bridge/tools/remind.ts index 3579647efda5..41861d195086 100644 --- a/extensions/qqbot/src/bridge/tools/remind.ts +++ b/extensions/qqbot/src/bridge/tools/remind.ts @@ -52,12 +52,13 @@ export function createRemindTool( label: "QQBot Reminder", description: "Create, list, and remove QQ reminders. " + + "Use only for explicit user requests, and ask when reminder content, schedule, or timezone is ambiguous. " + "This tool schedules Gateway cron jobs directly; do not call the cron tool after it succeeds.\n" + "Create: action=add, content=message, time=schedule (to is optional, " + "resolved automatically from the current conversation)\n" + "List: action=list\n" + "Remove: action=remove, jobId=job id from list\n" + - 'Time examples: "5m", "1h", "0 8 * * *"', + 'Time examples: "5m", "1h", "0 8 * * *"; include timezone for recurring cron reminders when known.', parameters: RemindSchema, async execute(_toolCallId, params) { const ctx = getRequestContext(); diff --git a/extensions/qqbot/src/engine/tools/channel-api.test.ts b/extensions/qqbot/src/engine/tools/channel-api.test.ts index a8cabd040ccc..4b45d1a29f77 100644 --- a/extensions/qqbot/src/engine/tools/channel-api.test.ts +++ b/extensions/qqbot/src/engine/tools/channel-api.test.ts @@ -139,4 +139,113 @@ describe("executeChannelApi", () => { expect(textSpy).not.toHaveBeenCalled(); expect(release).toHaveBeenCalledTimes(1); }); + + it("requires confirmation before DELETE requests", async () => { + const result = await executeChannelApi( + { method: "DELETE", path: "/channels/123" }, + { accessToken: "token-1" }, + ); + + expect(result.details).toEqual({ + error: + "DELETE requests require confirmed=true after the user confirms the exact QQ resource.", + path: "/channels/123", + }); + expect(fetchWithSsrFGuardMock).not.toHaveBeenCalled(); + }); + + it("allows confirmed DELETE requests", async () => { + const release = vi.fn(async () => {}); + fetchWithSsrFGuardMock.mockResolvedValueOnce({ + response: new Response(null, { status: 204, statusText: "No Content" }), + release, + }); + + const result = await executeChannelApi( + { method: "DELETE", path: "/channels/123", confirmed: true }, + { accessToken: "token-1" }, + ); + + expect(result.details).toEqual({ + success: true, + status: 204, + path: "/channels/123", + }); + expect(fetchWithSsrFGuardMock).toHaveBeenCalledWith( + expect.objectContaining({ + url: "https://api.sgroup.qq.com/channels/123", + init: expect.objectContaining({ method: "DELETE" }), + }), + ); + expect(release).toHaveBeenCalledTimes(1); + }); + + it("requires separate confirmation before bulk announcement deletes", async () => { + const result = await executeChannelApi( + { method: "DELETE", path: "/guilds/123/announces/all", confirmed: true }, + { accessToken: "token-1" }, + ); + + expect(result.details).toEqual({ + error: + "Deleting all announcements requires bulkConfirmed=true after a separate bulk-delete confirmation.", + path: "/guilds/123/announces/all", + }); + expect(fetchWithSsrFGuardMock).not.toHaveBeenCalled(); + }); + + it("requires bulk confirmation for encoded all announcement sentinel", async () => { + const result = await executeChannelApi( + { method: "DELETE", path: "/guilds/123/announces/%61%6c%6c", confirmed: true }, + { accessToken: "token-1" }, + ); + + expect(result.details).toEqual({ + error: + "Deleting all announcements requires bulkConfirmed=true after a separate bulk-delete confirmation.", + path: "/guilds/123/announces/%61%6c%6c", + }); + expect(fetchWithSsrFGuardMock).not.toHaveBeenCalled(); + }); + + it("rejects encoded path separators before fetch", async () => { + const result = await executeChannelApi( + { method: "GET", path: "/guilds/123%2fannounces" }, + { accessToken: "token-1" }, + ); + + expect(result.details).toEqual({ error: "path contains encoded path separators" }); + expect(fetchWithSsrFGuardMock).not.toHaveBeenCalled(); + }); + + it("allows bulk announcement deletes after both confirmations", async () => { + const release = vi.fn(async () => {}); + fetchWithSsrFGuardMock.mockResolvedValueOnce({ + response: new Response(null, { status: 204, statusText: "No Content" }), + release, + }); + + const result = await executeChannelApi( + { + method: "DELETE", + path: "/guilds/123/announces/all", + confirmed: true, + bulkConfirmed: true, + }, + { accessToken: "token-1" }, + ); + + expect(result.details).toEqual({ + success: true, + status: 204, + path: "/guilds/123/announces/all", + }); + expect(fetchWithSsrFGuardMock).toHaveBeenCalledWith( + expect.objectContaining({ + url: "https://api.sgroup.qq.com/guilds/123/announces/all", + init: expect.objectContaining({ method: "DELETE" }), + }), + ); + expect(release).toHaveBeenCalledTimes(1); + }); }); diff --git a/extensions/qqbot/src/engine/tools/channel-api.ts b/extensions/qqbot/src/engine/tools/channel-api.ts index b9b8173eb016..6dd64e40c080 100644 --- a/extensions/qqbot/src/engine/tools/channel-api.ts +++ b/extensions/qqbot/src/engine/tools/channel-api.ts @@ -36,6 +36,8 @@ export interface ChannelApiParams { path: string; body?: Record; query?: Record; + confirmed?: boolean; + bulkConfirmed?: boolean; } /** @@ -47,7 +49,9 @@ export const ChannelApiSchema = { properties: { method: { type: "string", - description: "HTTP method. Allowed values: GET, POST, PUT, PATCH, DELETE.", + description: + "HTTP method. Allowed values: GET, POST, PUT, PATCH, DELETE. " + + "Use DELETE and other mutating methods only after explicit user intent and target confirmation.", enum: ["GET", "POST", "PUT", "PATCH", "DELETE"], }, path: { @@ -59,7 +63,8 @@ export const ChannelApiSchema = { body: { type: "object", description: - "JSON request body for POST/PUT/PATCH requests. GET/DELETE usually do not need it.", + "JSON request body for POST/PUT/PATCH requests. GET/DELETE usually do not need it. " + + "For write requests, include only fields the user explicitly asked to change.", }, query: { type: "object", @@ -68,6 +73,16 @@ export const ChannelApiSchema = { 'For example, { "limit": "100", "after": "0" } becomes ?limit=100&after=0.', additionalProperties: { type: "string" }, }, + confirmed: { + type: "boolean", + description: + "Required true for DELETE requests after the user confirms the exact QQ resource to delete.", + }, + bulkConfirmed: { + type: "boolean", + description: + "Required true in addition to confirmed for bulk DELETE requests such as deleting all announcements.", + }, }, required: ["method", "path"], } as const; @@ -107,6 +122,56 @@ function validatePath(path: string): string | null { if (!/^\/[a-zA-Z0-9\-._~:@!$&'()*+,;=/%]+$/.test(path) && path !== "/") { return "path contains unsupported characters"; } + for (const segment of path.split("/").slice(1)) { + let decodedSegment: string; + try { + decodedSegment = decodeURIComponent(segment); + } catch { + return "path contains invalid percent encoding"; + } + if (decodedSegment.includes("/") || decodedSegment.includes("\\")) { + return "path contains encoded path separators"; + } + if (decodedSegment === "." || decodedSegment === "..") { + return "path must not contain . or .. segments"; + } + } + return null; +} + +function decodePathSegments(path: string): string[] | null { + try { + return path + .replace(/\/+$/, "") + .split("/") + .slice(1) + .map((segment) => decodeURIComponent(segment)); + } catch { + return null; + } +} + +function isBulkAnnouncementDeletePath(path: string): boolean { + const segments = decodePathSegments(path); + return Boolean( + segments && + segments.length === 4 && + segments[0]?.toLowerCase() === "guilds" && + segments[2]?.toLowerCase() === "announces" && + segments[3]?.toLowerCase() === "all", + ); +} + +function validateDeleteConfirmation(params: ChannelApiParams): string | null { + if (params.method.toUpperCase() !== "DELETE") { + return null; + } + if (!params.confirmed) { + return "DELETE requests require confirmed=true after the user confirms the exact QQ resource."; + } + if (isBulkAnnouncementDeletePath(params.path) && !params.bulkConfirmed) { + return "Deleting all announcements requires bulkConfirmed=true after a separate bulk-delete confirmation."; + } return null; } @@ -156,6 +221,11 @@ export async function executeChannelApi( return json({ error: pathError }); } + const confirmationError = validateDeleteConfirmation({ ...params, method }); + if (confirmationError) { + return json({ error: confirmationError, path: params.path }); + } + if ( (method === "GET" || method === "DELETE") && params.body && diff --git a/extensions/qqbot/src/engine/tools/remind-logic.ts b/extensions/qqbot/src/engine/tools/remind-logic.ts index 1431c6cf0440..a55f3775ca11 100644 --- a/extensions/qqbot/src/engine/tools/remind-logic.ts +++ b/extensions/qqbot/src/engine/tools/remind-logic.ts @@ -26,6 +26,8 @@ export interface RemindParams { jobId?: string; } +const QQBOT_DEFAULT_REMINDER_TIMEZONE = "Asia/Shanghai"; + /** * Context supplied by the bridge layer so the engine can remain free of * framework / AsyncLocalStorage dependencies. `fallbackTo` and @@ -69,7 +71,7 @@ export const RemindSchema = { action: { type: "string", description: - "Action type. add=create a reminder, list=show reminders, remove=delete a reminder.", + "Action type. add=create a reminder only after explicit user request, list=show reminders, remove=delete a reminder by confirmed job ID.", enum: ["add", "list", "remove"], }, content: { @@ -91,11 +93,12 @@ export const RemindSchema = { '1. Relative time, for example "5m", "1h", "1h30m", or "2d"\n' + '2. Cron expression, for example "0 8 * * *" or "0 9 * * 1-5"\n' + "Values containing spaces are treated as cron expressions; everything else is treated as a one-shot relative delay.\n" + - "Required when action=add.", + "Required when action=add. Ask for clarification before scheduling if the time is ambiguous.", }, timezone: { type: "string", - description: 'Timezone used for cron reminders. Defaults to "Asia/Shanghai".', + description: + "Optional IANA timezone used for cron reminders. Include it when the user provides or confirms a timezone; if omitted, QQBot preserves its existing default timezone.", }, name: { type: "string", @@ -216,7 +219,7 @@ function buildOnceJob(params: RemindParams, atMs: number, to: string, accountId: function buildCronJob(params: RemindParams, to: string, accountId: string) { const content = params.content!; const name = params.name || generateJobName(content); - const tz = params.timezone || "Asia/Shanghai"; + const tz = params.timezone || QQBOT_DEFAULT_REMINDER_TIMEZONE; return { action: "add" as const, job: { @@ -308,7 +311,7 @@ export function prepareRemindCronAction( ok: true, action: "add", cronAction: buildCronJob(params, resolvedTo, resolvedAccountId), - summary: `⏰ Recurring reminder: "${params.content}" (${params.time}, tz=${params.timezone || "Asia/Shanghai"})`, + summary: `⏰ Recurring reminder: "${params.content}" (${params.time}, tz=${params.timezone || QQBOT_DEFAULT_REMINDER_TIMEZONE})`, }; }