# 设计说明 本文说明这个插件为什么这样实现。安装与使用见 [README](../README.md)。 ## 一句话架构 ``` Harness Agent 循环 │ 调用原生工具 nmap_scan / http_probe_batch / ... ▼ index.js 注册动态工具与会话级工具;把 OpenAI schema 译成 Harness author DSL ▼ bridge.js stdio JSON-RPC 客户端(单进程长驻,保住 evidence / traffic 状态) ▼ vulnclaw_bridge.py AgentCore + execute_mcp_tool —— VulnClaw 自己的分发路径 ▼ VulnClaw 工具实现(nmap / httpx / 编解码 / 报告 / PoC / 知识库 …) ``` Python 桥是长驻单进程,不是每次调用起一个。这不是性能优化,而是**正确性要求**: VulnClaw 的 evidence、traffic 存储和目标状态都挂在 agent 对象上, `evidence_view`、`traffic_view` 和报告生成要看到之前所有调用的产物, 就必须共享同一个 agent。 ## 单一模型 VulnClaw 自带 Agent 循环(`AgentCore` + `solver` + `llm_client`)。本插件**不启动它**, 因此不需要为 VulnClaw 配凭据,也不会出现两个 Agent 争抢同一批工具调用。 这不是设计意图的声明,而是**验证过的**行为:在 VulnClaw 唯一的模型接缝 (`vulnclaw.config.settings.make_openai_client`)注入哨兵客户端后,跑遍桥接暴露的每一项操作 (全部工具、报告生成、源码扫描),构造次数为 **0**。 ### 模型接缝默认关闭 尽管上面证明了不需要模型,这个接缝仍然默认被**封死**——调用会抛出一条说明性错误。 原因是环境会变:一个残留的 `VULNCLAW_LLM_API_KEY`、一份 `~/.vulnclaw/config.yaml`, 都可能让某条后续代码路径悄悄开出第二个模型连接,而当前会话正用它做别的事。 那正是这个模式要避免的失败——两个 Agent、一段对话、一份没人预期的账单。 封死的方式是包装那一个工厂函数(VulnClaw 所有调用路径唯一的客户端构造点), **不修改 VulnClaw 的 LLM 实现**。需要 VulnClaw 自己的模型能力时显式打开: ```bash export VULNCLAW_BRIDGE_LLM_BASE_URL="https://your-endpoint/v1" export VULNCLAW_BRIDGE_LLM_API_KEY="sk-..." ``` `initialize` 响应里的 `model_seam` 会显示 `open` 还是 `closed`,`llm_refusals` 列出被拒绝的 构造尝试(含调用栈尾部)。 ## 不改 VulnClaw 源码 一切通过公开模块完成: | 步骤 | 调用 | |---|---| | 构造 | `AgentCore(config, mcp_manager)` | | 取 schema | `build_openai_tools(mcp_manager, include_subagent_tool=False)` | | 执行 | `execute_mcp_tool(agent, tool_name, args)` | | 记录证据 | `AgentState.remember_tool_result()` + `record_tool_call()` | 执行走的是 `execute_mcp_tool`——与 VulnClaw 自己的 Agent 循环**同一套分发**, 所以工具的内部行为、约束检查(端口/主机/路径范围)、风险评估逻辑完全一致。 证据记录刻意复刻 `tool_call_manager` 的两步调用,让返回文本带上同样的 `[evidence:eNNN]` 前缀。这样 VulnClaw 的 evidence / traffic / 报告链路对这个模式产生的数据一视同仁。 ## Schema 翻译 Harness 的工具参数 schema 是自己的 **author DSL**,与 OpenAI 的 JSON Schema 不同: | | OpenAI JSON Schema | Harness author DSL | |---|---|---| | 根节点 | `{type:"object", properties:{...}, required:[...]}` | 隐式属性映射,无根节点 | | 必填 | 根部 `required` 字符串数组 | 属性上 `required: true` | | 对象开放性 | 省略即为开放 | 必须显式声明 `additionalProperties` 布尔值 | | 类型 | 可为字符串数组 | 单一字符串;联合用 `oneOf` | 所以 `encodeToolParameters()` 做一次翻译。两个关键决策: 1. **没有声明 `properties` 的对象保持开放。** VulnClaw 的 `cookies` / `form` / `headers` / `params` 和 `findings[]` 都是「`type: object` + 描述,`properties` 为空」的键值对袋子, 靠描述说明键名。把这类对象关闭会拒绝调用方提供的每一个键——这个缺陷真实发生过, 见 [FINDINGS](FINDINGS.md#1-嵌套对象-schema-被错误关闭)。 2. **显式写了 `additionalProperties: false` 且*有*声明属性的对象仍然闭合。** 那是作者在表达意图,不是在推断形状。 未知或无法表达的类型节点映射为 DSL 的 `json` 类型(编译后是无类型节点,接受任意 JSON), 而不是丢弃——丢弃会静默改变契约。 ## 按作用域注册 `cordis.patch.yml` 插入两行: | Loader 行 | 作用 | |---|---| | `vulnclaw-pentest-tools` | 以 `exposeTools: false` 挂载——只为把包放入 Loader 的解析图,**不注册任何工具**。 | | `preset-pentest` | 声明 `id: pentest` 的 Agent 预设,即模式列表里的「渗透测试」。引用同一个包并打开 `exposeTools: true`。 | 工具注册因此是**按作用域**的:standard / ptc / minimal / cordis 四个模式一个工具都不会多, 移除 bundle 时模式与工具一起消失。这是这个插件"加法式"的关键。 ### 目录裁剪用 deny 而不是 allow 预设默认隐藏 56 个 `cua_driver_native__*` 桌面自动化工具(占目录 55%,对 Web 目标毫无用处)。 用 `deny` 而非 `allow` 是有意的:Harness 的 `tools.restrict()` 会**拒绝** "指名了本部署没有的工具"的限制,而动态注册的 VulnClaw 工具在预设激活时还不存在—— allow 列表无法枚举它们,deny 列表只需列举必然存在的工具。实现中还把调用包在 try/catch 里: 裁剪失败只导致目录不干净,绝不让模式坏掉。 ## 工具注册延后到首次使用 `apply()` 里不启动 Python、不做解释器探测,注册发生在第一次需要工具时。 理由:Harness 必须能在没有可用 Python 的机器上正常启动,而"缺解释器"应该表现为 **一条清晰的工具错误**,而不是一个激活失败的插件。代价是注册是异步的—— 在真实 Harness 里,预设激活与模型首次看到工具目录之间隔了足够时间(实测激活到注册完成约 1.5 秒), 但这是实现上的一个已知时序依赖。 ## ensure() 契约 `ensure()` 统一返回 `{client, info, tools}`,调用方一律使用**返回值**,不读外层变量。 早期版本让会话级工具解构 `{client}`,而 `ensure()` 返回的是发现结果 `{info, tools}`—— 于是 `vulnclaw_set_target` 和 `vulnclaw_report` 在首次调用时崩溃 (`Cannot read properties of undefined (reading 'invoke')`)。这个缺陷只在"先调会话级工具"时 暴露,见 [FINDINGS](FINDINGS.md#3-会话级工具在首次调用时崩溃)。 约定:**任何工具都必须能被单独调用,不依赖调用顺序。** ## 解释器解析与准备 顺序(首次用到工具时执行,不是激活时): 1. `VULNCLAW_PYTHON` —— 显式指定,直接使用。失败**就报错**,不会偷偷回退到别的解释器: 你显式做的选择不该被静默覆盖。 2. `$DSH_HOME/vulnclaw-bridge/venv` —— 已备好的托管环境。 3. 用 `uv` 新建,`uv pip install "vulnclaw[web]"`。 解析完成后检查 VulnClaw 工具描述承诺的执行库(`requests` 等)是否真的能导入,缺失就补装。 理由见 [FINDINGS](FINDINGS.md#2-python_execute-缺少它自己承诺的库): 工具描述是模型推理所依据的契约的一部分,环境应当去符合它。 补装失败只记录不致命——解释器本身已经可用,受影响的只是那些 import 该库的脚本。 ## 为什么用 stdio JSON-RPC 而不是 HTTP VulnClaw 有 FastAPI Web 后端,但它的 API 是**任务粒度**的(`/api/tasks/run` + SSE 流), 不是工具粒度。要暴露单个工具(`nmap_scan` 的参数与结果),就得走进程内调用。 stdio + 行分隔 JSON 的好处是:没有端口、没有鉴权、没有监听面,子进程生命周期由父进程 完全掌握,崩溃能被立即感知。 ### stdout 纪律 `AgentCore.__init__` 会通过 Rich console 把知识库状态**打印到 stdout**——在 JSON-RPC 传输上 这就是协议污染。防御有两层: 1. 每次 VulnClaw 调用都包在 `_quiet_stdout()` 里(重定向 `sys.stdout`); 2. 启动时在**文件描述符层面**把 fd 1 指向 stderr,协议写入保存在一个 `dup` 出来的描述符上。 第二层是必要的,因为 CPython 和原生库可以绕过 `sys.stdout` 直接写 fd 1 (例如 GC 期间的回溯)。两层都在,是因为第一层让这个模块在测试环境里也能直接用。 ## 权限模型 `permissionMode` 默认 `full_access`。VulnClaw 的危险工具默认弹 y/N 确认, 而无头子进程没有这个交互通道——不设 `full_access` 的话,`shell_command` / `python_execute` / PoC 会一律以 `no_channel` 被拒绝。 **审批权上移到 Harness**:这两个工具在 Harness 里等同于执行本机命令, 归 Harness 的权限系统管。文档中明确说明这一点,避免被误当成无审批的任意代码执行入口。 ## VulnClaw 命令行侧的改动 `python/vulnclaw_dsh_mode.py` 提供两处**运行时**定制,不改已安装的包: 1. **TUI 模式列表新增「渗透测试」**:装饰 `vulnclaw.cli.tui._build_modes` 并刷新模块级 `MODES`(它只在 import 时构建一次,只patch builder 会留下陈旧的全局)。新模式命令 `run`, 放行 `recon`/`scan`/`exploit`,`needs_extra_confirm=True`,标签随界面语言切换。 2. **隐藏 AI 管理命令**:从 Typer 的 `registered_commands` / `registered_groups` 里移除 `config`、`login`、`logout`、`doctor`、`init`。 移除注册项而不是设 `hidden=True`:后者只是不进帮助,命令照样能跑。 命令对象仍留在进程内可被程序化调用,VulnClaw 的逻辑一行未动。 `reset()` 可还原,测试用它。