Skip to content

feat(api): Tool Invoke API + Naga Scoped Token 鉴权系统#49

Merged
69gg merged 10 commits intomainfrom
feature/naga-openapi
Mar 14, 2026
Merged

feat(api): Tool Invoke API + Naga Scoped Token 鉴权系统#49
69gg merged 10 commits intomainfrom
feature/naga-openapi

Conversation

@69gg
Copy link
Owner

@69gg 69gg commented Mar 13, 2026

Summary

  • 新增 GET /api/v1/toolsPOST /api/v1/tools/invoke 端点,允许外部系统(如 NagaAgent)发现并调用 Undefined 的工具能力
  • 支持同步返回和可选的异步 webhook 回调
  • 默认关闭(tool_invoke_enabled = false),需显式开启
  • 新增 Naga Scoped Token 鉴权系统:双层鉴权(共享 api_key + per-binding scoped token)的回调机制,含绑定审批工作流和 /naga 斜杠命令

Changes

Tool Invoke API

  • 配置层 (config/models.py, config/loader.py): APIConfig 新增 6 个 tool_invoke_* 字段,含合法值校验
  • API 核心 (api/app.py): 工具列表/调用 handler、三层过滤逻辑(denylist > allowlist > expose)、RequestContext 构建与资源注入、异步回调、OpenAPI spec 更新
  • WebUI 代理 (webui/routes/_runtime.py): 新增 GET /api/runtime/toolsPOST /api/runtime/tools/invoke 代理路由
  • 配置模板 (config.toml.example): [api] 段追加 tool_invoke 配置项
  • 文档 (docs/openapi.md): 完整 API 参考(配置、接口、字段表、过滤逻辑、错误码、cURL 示例、故障排查)
  • 测试: 新增 27 个测试覆盖配置解析、工具过滤、调用流程、回调校验、OpenAPI spec

Naga Scoped Token 鉴权系统

  • NagaStore (api/naga_store.py): 绑定数据层 — token 生成/校验/吊销,异步 JSON 持久化(文件锁 + 原子写入,跨平台兼容)
  • Callback API (api/app.py): POST /api/v1/naga/callback 消息回调 + GET /api/v1/naga/targets 目标查询,独立鉴权中间件
  • /naga 命令 (skills/commands/naga/): bind/approve/reject/revoke/list/pending/info 七个子命令,群聊白名单 + 私聊放行
  • scopes.json (skills/commands/naga/scopes.json): 子命令权限控制,支持 group_only/private_only/admin_only/superadmin_only 别名
  • /help 权限过滤 (skills/commands/help/handler.py): 按用户权限级别隐藏不可用命令
  • 总开关: 统一使用 features.nagaagent_mode_enabled,无独立 naga.enabled
  • 配置 (config/models.py, config/loader.py): 新增 NagaConfig(api_url/api_key/allowed_groups)
  • 文档: docs/configuration.md(4.25 节)、docs/slash-commands.md(/naga + scopes.json 开发指南)、docs/openapi.md(第 8 节)同步更新
  • 测试: 21 个 NagaStore 单元测试

Security

Tool Invoke API

  • 功能默认关闭,需 tool_invoke_enabled = true 显式开启
  • 复用现有 X-Undefined-API-Key 认证中间件
  • 三层工具访问控制:denylist > allowlist > expose 范围
  • 回调 URL 仅允许 HTTP/HTTPS scheme
  • 独立超时控制(执行 / 回调)

Naga 鉴权

  • 双层鉴权:共享 api_keysecrets.compare_digest)+ scoped udf_xxx token
  • 总开关 features.nagaagent_mode_enabled 关闭时端点不注册、命令不可用
  • 群聊操作受 allowed_groups 白名单限制
  • 绑定数据文件 Unix 下自动 chmod 600(非 Unix 平台跳过)
  • Token 日志脱敏(仅显示前 12 字符)

Test plan

  • uv run ruff format . && uv run ruff check . 通过
  • uv run mypy src/ 通过(300 source files, 0 errors)
  • uv run pytest tests/ 通过(301 tests passed)
  • 手动测试:tool_invoke_enabled = false 时返回 403
  • 手动测试:GET /api/v1/tools 返回过滤后的工具列表
  • 手动测试:POST /api/v1/tools/invoke 同步调用工具
  • 手动测试:带 callback 的异步调用
  • 手动测试:/naga bind 在白名单群内提交绑定
  • 手动测试:/naga approve 审批后 token 同步到 Naga
  • 手动测试:POST /api/v1/naga/callback 回调消息投递

🤖 Generated with Claude Code

Add GET /api/v1/tools and POST /api/v1/tools/invoke endpoints to allow
external systems (e.g. Naga) to discover and invoke Undefined's tools.
Supports synchronous response and optional async webhook callback.

- APIConfig: add 6 tool_invoke_* fields (enabled, expose, allow/denylist, timeouts)
- Three-layer tool filtering: denylist > allowlist > expose scope
- RequestContext + resource injection for proper tool execution
- WebUI proxy routes for /api/runtime/tools and /api/runtime/tools/invoke
- Update docs/openapi.md with full API reference, examples, and troubleshooting
- Add 27 tests covering config parsing, filtering logic, invocation, and callbacks

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@chatgpt-codex-connector
Copy link

You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard.

devin-ai-integration[bot]

This comment was marked as resolved.

Co-authored-by: devin-ai-integration[bot] <158243242+devin-ai-integration[bot]@users.noreply.github.com>
devin-ai-integration[bot]

This comment was marked as resolved.

69gg and others added 2 commits March 13, 2026 22:21
- 保存 fire-and-forget task 引用到 _background_tasks 集合,防止被 GC
- 合并 get_agents_schema() 调用,避免非 all 模式下重复调用
- WebUI 代理超时改为动态读取 tool_invoke_timeout + 60s 缓冲
- 回调移除多余 Content-Type header,由 aiohttp json= 自动处理
- 文档修正 callback URL 要求为支持 HTTP 和 HTTPS
- RequestContext 类型注释补充 "api" 类型

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
实现 NagaAgent 双层鉴权(共享 api_key + scoped token)回调机制:
- NagaStore 数据层:绑定/审批/吊销/校验,异步 JSON 持久化(文件锁 + 原子写入)
- /naga 斜杠命令:bind/approve/reject/revoke/list/pending/info 七个子命令
- Callback API:POST /api/v1/naga/callback + GET /api/v1/naga/targets
- scopes.json 子命令权限控制:支持 group_only/private_only/admin_only/superadmin_only 别名
- /help 权限可见性过滤:按用户权限级别隐藏不可用命令
- 总开关统一为 features.nagaagent_mode_enabled
- 文档:configuration.md、slash-commands.md、openapi.md 同步更新

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@69gg 69gg changed the title feat(api): 新增工具调用 API (Tool Invoke API) feat(api): Tool Invoke API + Naga Scoped Token 鉴权系统 Mar 14, 2026
devin-ai-integration[bot]

This comment was marked as resolved.

- SSRF 防护:_validate_callback_url 拒绝私有/回环 IP 字面量
- 临时文件泄漏:naga callback 渲染图片后 finally 清理 mkstemp 文件
- 后台任务泄漏:RuntimeAPIServer.stop() 先 cancel 再 cleanup
- allowed_groups 查找优化:list[int] → frozenset[int],O(1) 查找
- 文档修正:明确群聊场景所有 /naga 子命令受 allowed_groups 白名单限制

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
devin-ai-integration[bot]

This comment was marked as resolved.

- CQ image URI:改用 Path.as_uri() 构建跨平台 file URI(Win/Mac/Linux)
- _load_scopes() 异步化:拆分 sync + asyncio.to_thread,遵循项目 IO 规范
- openapi.md:修正已删除的 [naga].enabled 引用为 features.nagaagent_mode_enabled

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
devin-ai-integration[bot]

This comment was marked as resolved.

- 私聊代理绕过:approve/reject 通知申请人时使用 _notify_user 绕过
  _PrivateCommandSenderProxy,确保消息发给目标用户而非命令调用者
- 热更新警告:_RESTART_REQUIRED_KEYS 加入 "naga",变更时提示需重启
- auth 中间件:/api/v1/naga/ 路径的认证跳过改为仅在 nagaagent_mode_enabled
  时生效,关闭时所有 /api/ 路径统一走主认证

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@69gg
Copy link
Owner Author

69gg commented Mar 14, 2026

Devin Review 未修项说明

以下是 review 中标记但不需要修改的项目及原因:


Bug: allowed_groups check silently blocks ALL naga subcommands (handler.py:108)

By design. 这是 maintainer 的明确要求:群聊场景下所有 /naga 子命令仅在 allowed_groups 白名单群内可用,非白名单群静默忽略。私聊不受限制。文档(slash-commands.md 和 configuration.md)已同步更新。


Flag: Tool invoke callback URL allows localhost/domain-based SSRF (app.py:244-271)

已缓解。 _validate_callback_url 已拒绝 IP 字面量形式的私有/回环地址(127.0.0.110.*192.168.*[::1] 等)。域名形式不做 DNS 解析校验,因为:

  1. 校验函数中做阻塞 DNS 解析不合适
  2. DNS 结果可能在校验和实际请求之间变化(TOCTOU)
  3. aiohttp 请求时可通过网络层策略进一步限制

Flag: Naga callback deletes temp image immediately after send (app.py:1654-1659)

正确行为。 临时文件在消息发送完成后(无论成功与否)通过 finally 清理。NapCat/Lagrange 在收到 file:// URI 后会立即将文件复制到自身缓存目录,不需要源文件持续存在。项目中其他渲染(如 /stats)也是同样的即用即删模式。


Flag: NagaStore.record_usage called regardless of message delivery success (app.py:1662)

有意为之。 record_usage 记录的是回调调用次数而非投递成功次数,用于审计"Naga 调了多少次回调"。即使部分投递失败(如群发成功但私聊失败),调用本身是有效的,应当计数。


Flag: Naga auth bypass path prefix check could match unintended routes (app.py:525-528)

已修复为条件检查。 当前实现已改为 is_naga_path and cfg.nagaagent_mode_enabled 双条件,naga 模式关闭时不跳过认证。且路由注册完全由我们控制,不存在意外匹配的路径。


Flag: Naga endpoint registration is evaluated once at startup (app.py:563-572)

By design. Naga 端点在 _create_app() 时条件注册。naga.* 已加入 _RESTART_REQUIRED_KEYS,热更新时会提示用户需要重启。aiohttp 的路由表本身也不支持运行时动态增删。


Flag: Naga command visible to all users but most subcommands require superadmin (config.json:5)

By design. /nagaconfig.json 声明 "permission": "public" 是因为 bind 子命令对所有用户开放。实际权限由 scopes.json 按子命令细粒度控制。/help 已按用户权限过滤——superadmin 命令对普通用户不可见。


Flag: NagaStore read methods are lock-free (naga_store.py:210-220)

By design. list_bindings/list_pending/verify/get_binding 是纯内存 dict 读取,CPython GIL 保证单次 dict 操作的原子性。所有写操作已通过 asyncio.Lock 序列化,读操作无需加锁。


Flag: NagaStore.record_usage() persists to disk on every callback (naga_store.py:237-245)

可接受。 Naga 回调频率预期很低(分钟级),每次写入通过 write_json(原子写入 + 文件锁)完成,不构成性能瓶颈。如未来频率显著增加,可改为批量写入或内存计数 + 定期刷盘。

devin-ai-integration[bot]

This comment was marked as resolved.

69gg and others added 3 commits March 14, 2026 11:29
开关分层:
- features.nagaagent_mode_enabled — 总开关,控制 AI 侧行为(提示词、工具暴露)
- naga.enabled — 子开关,控制外部网关集成(回调 API、/naga 命令、绑定管理)
- nagaagent_mode_enabled=false 时强制关闭所有 Naga 功能

WebUI 多行注释修复:
- comment.py: 多行同语言注释用换行符连接而非空格压缩
- toml_render.py: 渲染时按换行符拆分为多行 # zh:/en: 输出

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
markdown 格式回调中 render_markdown_to_html 未包裹 try/except,
异常时直接 500 而非回退文本发送。现与 render_html_to_image 一致,
失败时回退到纯文本。

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@69gg 69gg merged commit a0ca044 into main Mar 14, 2026
2 of 3 checks passed
Copy link
Contributor

@devin-ai-integration devin-ai-integration bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Devin Review found 1 new potential issue.

View 15 additional findings in Devin Review.

Open in Devin Review

Comment on lines +259 to +267
hostname = parsed.hostname or ""
if not hostname:
return "callback.url must include a hostname"

# 仅检查 IP 字面量(如 http://127.0.0.1/、http://[::1]/、http://10.0.0.1/)
try:
addr = ipaddress.ip_address(hostname)
except ValueError:
pass # 域名形式,放行
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 SSRF protection bypass via localhost hostname in callback URL validation

The _validate_callback_url function blocks private/loopback IP literals (127.0.0.1, ::1, 10.x.x.x, etc.) but allows the localhost hostname, which is an RFC 6761 reserved name that always resolves to 127.0.0.1. Unlike arbitrary domain names (e.g. internal.company.com) that require DNS resolution to determine their target, localhost is a well-known loopback alias that can be blocked with a simple string check — no DNS needed. An authenticated caller can use http://localhost:<port>/path as the callback URL to reach any service on the bot's host, enabling SSRF-based internal service discovery and potential data exfiltration via the tool result payload. The test at tests/test_runtime_api_tool_invoke.py:136 explicitly asserts localhost is allowed, confirming the gap.

Suggested change
hostname = parsed.hostname or ""
if not hostname:
return "callback.url must include a hostname"
# 仅检查 IP 字面量(如 http://127.0.0.1/、http://[::1]/、http://10.0.0.1/)
try:
addr = ipaddress.ip_address(hostname)
except ValueError:
pass # 域名形式,放行
hostname = parsed.hostname or ""
if not hostname:
return "callback.url must include a hostname"
# Block well-known loopback hostnames (no DNS needed to know these are local)
_LOOPBACK_HOSTNAMES = {"localhost", "localhost.localdomain", "ip6-localhost", "ip6-loopback"}
if hostname.lower() in _LOOPBACK_HOSTNAMES:
return "callback.url must not point to a private/loopback address"
# 仅检查 IP 字面量(如 http://127.0.0.1/、http://[::1]/、http://10.0.0.1/)
try:
addr = ipaddress.ip_address(hostname)
except ValueError:
pass # 域名形式,放行
Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant