diff --git a/.agents/notes/implemented/architecture/2026-06-24-web-capability-seam.i18n.yaml b/.agents/notes/implemented/architecture/2026-06-24-web-capability-seam.i18n.yaml
index 71c06fbd35..855b0b2aff 100644
--- a/.agents/notes/implemented/architecture/2026-06-24-web-capability-seam.i18n.yaml
+++ b/.agents/notes/implemented/architecture/2026-06-24-web-capability-seam.i18n.yaml
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-06-24-web-capability-seam.md
-2026-06-24-web-capability-seam.md: 5c8ca698386392f87e60e5dc543c6478316338ed
-2026-06-24-web-capability-seam.zh.md: 1946748e2fef7db72c7450f2bfc44c46aed51ee2
+2026-06-24-web-capability-seam.md: a8438d804bb8f4312b5ca2a39ccaa74cef39d31e
+2026-06-24-web-capability-seam.zh.md: 9506a3c46688bfe6656d4ba9be4bc16ca9af0051
diff --git a/.agents/notes/implemented/architecture/2026-06-24-web-capability-seam.md b/.agents/notes/implemented/architecture/2026-06-24-web-capability-seam.md
index 5c8ca69838..a8438d804b 100644
--- a/.agents/notes/implemented/architecture/2026-06-24-web-capability-seam.md
+++ b/.agents/notes/implemented/architecture/2026-06-24-web-capability-seam.md
@@ -61,6 +61,8 @@ flowchart LR
perplexity["@deepseek-ai/dsh-web-search-perplexity"] -->|registerSearchProvider| web
deepseek["@deepseek-ai/dsh-web-search-deepseek"] -->|registerSearchProvider| web
fetchLocal["@deepseek-ai/dsh-web-fetch-http"] -->|registerFetchProvider| web
+ fetchPermission["@deepseek-ai/dsh-web-fetch-approval-policy"] -->|pre-execute ask/deny| webFetch
+ fetchPermission -->|public destination preflight| fetchLocal
toolWeb["@deepseek-ai/dsh-tool-web"] -->|search/fetch| web
toolWeb -->|ctx.tools.register| webSearch["tool: web_search"]
toolWeb -->|ctx.tools.register| webFetch["tool: web_fetch"]
@@ -145,6 +147,9 @@ The "single provider auto-selects" rule is for tests, demos, and simple deployme
- id: web-fetch-http
name: '@deepseek-ai/dsh-web-fetch-http'
+- id: web-fetch-approval-policy
+ name: '@deepseek-ai/dsh-web-fetch-approval-policy'
+
- id: tool-web
name: '@deepseek-ai/dsh-tool-web'
```
@@ -244,6 +249,8 @@ The fetch provider's resource controls:
The provider rejects an entire DNS answer set when any address is not public instead of silently filtering the unsafe members. This fail-closed rule prevents connection-family selection or fallback from reaching an address that did not satisfy the public-network policy.
+`dsh-web-fetch-approval-policy` owns user-consent decisions without moving them into the provider or tool schema. It delegates `danger-full-access`; in `read-only` and `workspace-write` it denies approval policy `never`, otherwise performs the provider's public-destination preflight and returns `ask` only after downstream policies allow. The existing approval service correlates the request to the exact call id, and only `allowed-once` runs that call. The preflight DNS result is never an authorization token: the provider independently resolves and pins the actual connection. Plan mode stays an independent collaboration state and uses whichever sandbox and approval policies the product composes with it.
+
## Tool consumer behavior
`dsh-tool-web` owns two `ToolDefinition`s: `web_search` and `web_fetch`. It owns model-facing JSON schemas, snake_case argument names, prompt sections, result rendering to `ContentBlock[]`, `presentCall`, and `presentResult`.
@@ -328,7 +335,7 @@ Rejected because hostname syntax does not establish the connection destination:
**Provider state can change after startup.** A tool can be visible in the request assembled at step start and lose its provider before execution. The execution path resolves again and fails with a structured error.
-**Fetch is a network boundary, not just a read-only tool.** Public-address validation and connection pinning prevent `web_fetch` from reaching non-public destinations, but a model can still disclose data through a public URL and fetched text remains untrusted model input. Product enablement therefore still needs a deliberate permission policy rather than treating fetch as equivalent to local read-only observation.
+**Fetch is a network boundary, not just a read-only tool.** Public-address validation and connection pinning prevent `web_fetch` from reaching non-public destinations, but a model can still disclose data through a public URL and fetched text remains untrusted model input. Restricted shipped presets therefore require one-shot approval, while `danger-full-access` deliberately delegates without asking.
**Large web content can damage context quality.** Providers enforce byte/character caps and report `truncated`; `tool-web` formats bounded model output with clear continuation or follow-up guidance.
@@ -336,10 +343,8 @@ Rejected because hostname syntax does not establish the connection destination:
- A `pdf` `WebFetchBody` kind: the `http` provider decodes text-extractable PDFs (best-effort, capped, `truncated`) into a `{ kind: 'pdf'; content; pageCount? }` arm, and `tool-web` renders it. This is fetch, not `web_extract` — PDF retrieval is a concrete HTTP 200 plus deterministic local decoding, not provider-side extraction of a non-HTTP resource. Adding it is a coordinated change across `dsh-web` (declare the arm), the provider (decode + narrow "binary rejection" to "reject binary except text-extractable PDF"; scanned/image PDFs needing OCR stay out of scope), and `tool-web` (render). The closed `WebFetchBody` union makes the consumer side fail to compile until the new arm is handled.
- Provider-backed extraction as a separate `web_extract` capability, rather than widening `web_fetch` silently.
-- Permission policy integration: the permission system now exists ([sandbox and approval](../feature/2026-07-06-sandbox.md), [web permission presets](../feature/2026-07-23-web-permission-and-approval.md)) but bundles only sandbox mode and approval policy; web permission policy remains unintegrated.
- Provider-neutral search controls beyond `query` and `maxResults`, once Exa and Perplexity can both honor them honestly.
## Open questions
- Should product app packages probe web configuration at startup (treating `WEB_PROVIDER_CONFIGURED_MISSING`, `WEB_PROVIDER_CONFIGURED_UNAVAILABLE`, and `WEB_PROVIDER_AMBIGUOUS` as fatal when web is explicitly configured), or leave misconfiguration to surface at the first execution?
-- Where should permission policy for public web access live in the shipped permission system ([sandbox and approval](../feature/2026-07-06-sandbox.md), [web permission presets](../feature/2026-07-23-web-permission-and-approval.md)): a dedicated web permission plugin on `tools/execute`, provider config, or both?
diff --git a/.agents/notes/implemented/architecture/2026-06-24-web-capability-seam.zh.md b/.agents/notes/implemented/architecture/2026-06-24-web-capability-seam.zh.md
index 1946748e2f..9506a3c466 100644
--- a/.agents/notes/implemented/architecture/2026-06-24-web-capability-seam.zh.md
+++ b/.agents/notes/implemented/architecture/2026-06-24-web-capability-seam.zh.md
@@ -61,6 +61,8 @@ flowchart LR
perplexity["@deepseek-ai/dsh-web-search-perplexity"] -->|registerSearchProvider| web
deepseek["@deepseek-ai/dsh-web-search-deepseek"] -->|registerSearchProvider| web
fetchLocal["@deepseek-ai/dsh-web-fetch-http"] -->|registerFetchProvider| web
+ fetchPermission["@deepseek-ai/dsh-web-fetch-approval-policy"] -->|pre-execute ask/deny| webFetch
+ fetchPermission -->|public destination preflight| fetchLocal
toolWeb["@deepseek-ai/dsh-tool-web"] -->|search/fetch| web
toolWeb -->|ctx.tools.register| webSearch["tool: web_search"]
toolWeb -->|ctx.tools.register| webFetch["tool: web_fetch"]
@@ -145,6 +147,9 @@ interface WebRuntime {
- id: web-fetch-http
name: '@deepseek-ai/dsh-web-fetch-http'
+- id: web-fetch-approval-policy
+ name: '@deepseek-ai/dsh-web-fetch-approval-policy'
+
- id: tool-web
name: '@deepseek-ai/dsh-tool-web'
```
@@ -244,6 +249,8 @@ fetch 提供方的资源控制:
只要 DNS 完整解析结果中存在任一非公开地址,提供方就会拒绝整个结果,而不是静默过滤不安全成员。该 fail-closed 规则可防止连接的地址族选择或回退触及未满足公开网络策略的地址。
+`dsh-web-fetch-approval-policy` 负责用户同意决策,而不会把它移入提供方或工具 schema。它委托 `danger-full-access`;在 `read-only` 与 `workspace-write` 中,它拒绝审批策略 `never`,否则执行提供方的公开目的地址预检,并且只在下游策略允许后返回 `ask`。现有审批服务把请求关联到精确的 call id,只有 `allowed-once` 会运行该次调用。预检 DNS 结果绝不是授权令牌:提供方会独立解析并固定实际连接。Plan mode 保持独立的协作状态,采用产品与其组合的 sandbox 和审批策略。
+
## 工具消费方行为
`dsh-tool-web` 拥有两个 `ToolDefinition`:`web_search` 和 `web_fetch`。它拥有面向模型的 JSON Schema、snake_case 参数名、提示词段落、结果渲染为 `ContentBlock[]`、`presentCall` 和 `presentResult`。
@@ -328,7 +335,7 @@ fetch 提供方的资源控制:
**提供方状态可能在启动后变化。** 一个工具可能在步骤开始时组装的请求中可见,但在执行前失去其提供方。执行路径重新解析并以结构化错误失败。
-**Fetch 是网络边界,不仅仅是只读工具。** 公开地址校验与连接固定可防止 `web_fetch` 触达非公开目的地址,但模型仍可通过公开 URL 泄露数据,抓取文本也仍是不受信任的模型输入。因此,产品启用 fetch 仍需要明确的权限策略,不能把它等同于本地只读观察。
+**Fetch 是网络边界,不仅仅是只读工具。** 公开地址校验与连接固定可防止 `web_fetch` 触达非公开目的地址,但模型仍可通过公开 URL 泄露数据,抓取文本也仍是不受信任的模型输入。因此,已交付的受限 preset 要求单次审批,而 `danger-full-access` 会有意地不询问并委托。
**大量 web 内容可能损害上下文质量。** 提供方强制执行字节/字符上限并报告 `truncated`;`tool-web` 格式化有界的模型输出,附带清晰的继续或后续引导。
@@ -338,10 +345,8 @@ fetch 提供方的资源控制:
- `pdf` `WebFetchBody` 类别:`http` 提供方将可文本提取的 PDF 解码(尽力而为、有上限、`truncated`)为 `{ kind: 'pdf'; content; pageCount? }` 分支,`tool-web` 渲染它。这是 fetch 而非 `web_extract`——PDF 获取是具体的 HTTP 200 加确定性的本地解码,不是提供方侧对非 HTTP 资源的提取。添加它是跨 `dsh-web`(声明分支)、提供方(解码 + 将「二进制拒绝」收窄为「拒绝二进制,但可文本提取的 PDF 除外」;需要 OCR 的扫描/图片 PDF 不在范围内)和 `tool-web`(渲染)的协调变更。封闭的 `WebFetchBody` 联合类型使消费方在新分支被处理之前编译失败。
- 提供方支撑的提取作为独立的 `web_extract` 能力,而非静默扩展 `web_fetch`。
-- 权限策略集成:权限系统现已存在([沙箱与审批](../feature/2026-07-06-sandbox.zh.md)、[web 权限预设](../feature/2026-07-23-web-permission-and-approval.zh.md)),但只捆绑了沙箱模式与审批策略;web 权限策略仍未集成。
- `query` 和 `maxResults` 之外的提供方无关搜索控制,待 Exa 和 Perplexity 都能诚实遵守时再添加。
## 开放问题
- 产品应用包是否应在启动时探测 web 配置(当 web 被显式配置时将 `WEB_PROVIDER_CONFIGURED_MISSING`、`WEB_PROVIDER_CONFIGURED_UNAVAILABLE` 和 `WEB_PROVIDER_AMBIGUOUS` 视为致命错误),还是将配置错误留到首次执行时浮出?
-- 在已交付的权限系统([沙箱与审批](../feature/2026-07-06-sandbox.zh.md)、[web 权限预设](../feature/2026-07-23-web-permission-and-approval.zh.md))中,公开 web 访问的权限策略应放在哪里:`tools/execute` 上的专用 web 权限插件、提供方配置,还是两者兼有?
diff --git a/.agents/notes/implemented/feature/2026-07-23-web-permission-and-approval.i18n.yaml b/.agents/notes/implemented/feature/2026-07-23-web-permission-and-approval.i18n.yaml
index 87bcfb6040..02b707b8f8 100644
--- a/.agents/notes/implemented/feature/2026-07-23-web-permission-and-approval.i18n.yaml
+++ b/.agents/notes/implemented/feature/2026-07-23-web-permission-and-approval.i18n.yaml
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-23-web-permission-and-approval.md
-2026-07-23-web-permission-and-approval.md: 9fba57e37e0d26a6cb40a81e9330ecfaa9e881b9
-2026-07-23-web-permission-and-approval.zh.md: 0a030d60dcf94e83adc41a21aee850d839d1af01
+2026-07-23-web-permission-and-approval.md: 8df512bdcf86b7910a16681dbd8b8d836602f8a8
+2026-07-23-web-permission-and-approval.zh.md: 637f7bd6b792496537be17ff24963403dcbe5e10
diff --git a/.agents/notes/implemented/feature/2026-07-23-web-permission-and-approval.md b/.agents/notes/implemented/feature/2026-07-23-web-permission-and-approval.md
index 9fba57e37e..8df512bdcf 100644
--- a/.agents/notes/implemented/feature/2026-07-23-web-permission-and-approval.md
+++ b/.agents/notes/implemented/feature/2026-07-23-web-permission-and-approval.md
@@ -12,6 +12,8 @@ The web host booted an unconfined agent: `bootHost` composed `dsh-bash-local` an
The web host composes the same sandboxed product path as the acp-agent composition: `dsh-sandbox-local`, `dsh-sandbox-policy`, `dsh-bash-sandbox`, `dsh-fs-sandbox`, `dsh-user-approval`, and `dsh-permission-presets`, with `BootHostOptions.sandbox` supplying the deployment defaults (`mode`, default `workspace-write`; `approvalPolicy`, default `ask`).
+The shipped web composition also mounts `dsh-web-fetch-approval-policy` on `tools/pre-execute`. `danger-full-access` delegates `web_fetch` without asking; `read-only` and `workspace-write` require one-shot approval after the HTTP provider's public-destination preflight; approval policy `never` denies without resolving or prompting. The preflight result only prevents an invalid question: the provider resolves again and pins the actual connection, so `allowed-once` cannot authorize a private destination or a later DNS-rebinding answer. Downstream `deny` and `ask` decisions remain authoritative. `plan` stays independent collaboration state, and products restrict plan work by composing it with a restricted sandbox preset rather than adding a second network-mode vocabulary.
+
`createApiProxy` owns the approval pending registry. Its `approval/request` waterfall answerer reads the approval id from the session's just-appended `approval/asked` audit event (an ask with no audit event is a foreign channel and delegates), mints one stable rpcId per question, broadcasts the answerable `approval/requested` frame to every open mux stream, and replays still-pending frames verbatim on each mux open — the refresh-recovery baseline the contract already promised. `respond` routes by the echoed rpcId, validates `ApprovalResponsePayload` with the existing zod schema, cross-checks the payload's audit correlation against the routed entry, resolves the answerer, and broadcasts `approval/resolved`; the ask's abort signal withdraws the question as `cancelled`.
The permission select rides two new unary RPCs, `session.permissions` and `session.setPermission`, projecting `ctx.permissionPresets` into a protocol-owned `PermissionOption` DTO (the ACP bridge precedent: each protocol owns its presentation shape). A permission-less composition serves an empty select and clients hide the control. Idle switches are held last-write-wins in a proxy-side pending map and flushed on `agent/pre-step`, because knob events must stay turn-enclosed for durable replay; the shared `hasOpenTurn` fold moved to `dsh-session` and replaced the private copies in `dsh-user-approval`, the ACP bridge, and the proxy.
@@ -28,6 +30,8 @@ Client-side, `Session` gained `permissions` and `setPermission`, and approval an
**Optimistic card removal on click.** Rejected: the broadcast resolved frame is the truth; removing on click would hide a question that a rejected receipt or transport failure left standing. The panel disables its buttons locally and re-arms them on failure instead.
+**Persistent domain authorization in the first fetch policy.** Rejected: the existing approval vocabulary has one grant, `allowed-once`, and already correlates it to the exact tool call. A session/domain grant needs its own durable scope, revocation, display, and redirect semantics; none is required to exercise the permission chain safely.
+
## Consequences
-Web sessions start confined (`workspace-write` + `ask` by default) and a sandbox-denial escalation reaches the browser as an answerable card; the deployment can widen or narrow the default through `BootHostOptions.sandbox` without touching the assembly. Question answering uses the same registry pattern (ui-user-questions over the question pending table), and Session navigation identifies approval, plan-review, and ordinary question waits before the user opens them. The permission select reads once per mount; live refresh from another client's switch is deferred. Coverage: proxy registry and permission RPC unit suites, session-object and fixture unit suites, the keyless web smoke for fixture-mode approval and preset switching, and real-composition plan-review and question snapshots that pin the pending sidebar status through resolution.
+Web sessions start confined (`workspace-write` + `ask` by default), `web_fetch` pauses for an answerable one-shot request only after a public-address preflight, and a sandbox-denial escalation reaches the browser through the same channel. The deployment can widen or narrow the default through `BootHostOptions.sandbox` without touching the assembly. Question answering uses the same registry pattern (ui-user-questions over the question pending table), and Session navigation identifies approval, plan-review, and ordinary question waits before the user opens them. The permission select reads once per mount; live refresh from another client's switch is deferred. Coverage includes the policy decision matrix and public-address preflight, proxy registry and permission RPC suites, session-object and fixture suites, the keyless web smoke for fixture-mode approval and preset switching, and real-composition plan-review and question snapshots that pin pending sidebar status through resolution.
diff --git a/.agents/notes/implemented/feature/2026-07-23-web-permission-and-approval.zh.md b/.agents/notes/implemented/feature/2026-07-23-web-permission-and-approval.zh.md
index 0a030d60dc..637f7bd6b7 100644
--- a/.agents/notes/implemented/feature/2026-07-23-web-permission-and-approval.zh.md
+++ b/.agents/notes/implemented/feature/2026-07-23-web-permission-and-approval.zh.md
@@ -12,6 +12,8 @@ Web 承载层启动的是一个不受限的 agent(智能体):`bootHost`
Web 承载层组合与 acp-agent 相同的沙箱化产品路径:`dsh-sandbox-local`、`dsh-sandbox-policy`、`dsh-bash-sandbox`、`dsh-fs-sandbox`、`dsh-user-approval` 与 `dsh-permission-presets`,由 `BootHostOptions.sandbox` 提供部署默认值(`mode`,默认 `workspace-write`;`approvalPolicy`,默认 `ask`)。
+已交付的 Web 组合还会在 `tools/pre-execute` 上挂载 `dsh-web-fetch-approval-policy`。`danger-full-access` 不询问并委托 `web_fetch`;`read-only` 与 `workspace-write` 会先执行 HTTP 提供方的公开目的地址预检,再要求单次审批;审批策略 `never` 不解析或提示,直接拒绝。预检结果只用于避免提出无效问题:提供方会重新解析并固定实际连接,因此 `allowed-once` 无法授权私有目的地址或之后的 DNS rebinding 解析结果。下游的 `deny` 与 `ask` 决策保持权威。`plan` 仍是独立的协作状态;产品通过把 plan 工作与受限 sandbox preset 组合来限制它,而不会引入第二套网络 mode 词汇。
+
`createApiProxy` 拥有审批 pending 注册表。它的 `approval/request` waterfall(瀑布式事件)应答者从会话刚追加的 `approval/asked` 审计事件中读取审批 id(没有审计事件的 ask 属于外部通道,予以委托),为每个问题 mint 一个稳定的 rpcId,向每个打开的 mux 流广播可应答的 `approval/requested` 帧,并在每次 mux 打开时原样重放仍处于 pending 的帧——这正是约定早已承诺的刷新恢复基线。`respond` 按回显的 rpcId 路由,用既有的 zod schema 校验 `ApprovalResponsePayload`,将载荷的审计关联与所路由的条目交叉核对,解析应答者,并广播 `approval/resolved`;ask 的中断信号会以 `cancelled` 撤回该问题。
权限选择依托两个新的一元 RPC,`session.permissions` 与 `session.setPermission`,把 `ctx.permissionPresets` 投影为一个由协议拥有的 `PermissionOption` DTO(沿用 ACP bridge 的先例:每个协议拥有自己的呈现形状)。无权限的组合提供空的选择项,client 隐藏该控件。空闲期的切换以后写胜出(last-write-wins)的方式保存在 proxy 侧的 pending map 中,并在 `agent/pre-step` 时冲刷,因为旋钮事件必须保持轮次内闭合以支持持久回放;共享的 `hasOpenTurn` 折叠迁入 `dsh-session`,取代了 `dsh-user-approval`、ACP bridge 与 proxy 中各自的私有副本。
@@ -28,6 +30,8 @@ Web 承载层组合与 acp-agent 相同的沙箱化产品路径:`dsh-sandbox-l
**点击即乐观移除卡片。** 不予采纳:广播的 resolved 帧才是真相;点击即移除会隐藏一个因拒绝回执或传输失败而仍然悬置的问题。面板改为在本地禁用其按钮,并在失败时重新启用。
+**在首版抓取策略中加入持久域名授权。** 不予采纳:现有审批词汇只有一个授权结果 `allowed-once`,并且已把它关联到精确的工具调用。按 session/域名授权需要自身的持久作用域、撤销、展示与重定向语义;安全验证权限链不需要这些机制。
+
## 后果
-Web 会话从受限状态启动(默认 `workspace-write` + `ask`),一次沙箱拒绝的升级会以可应答的卡片形式抵达浏览器;部署方可以通过 `BootHostOptions.sandbox` 放宽或收紧默认值,无需触动装配。问题应答使用同一注册表模式(ui-user-questions 基于问题 pending 表),Session 导航会在用户打开会话前识别审批、计划审阅与普通问题等待。权限选择在每次挂载时读取一次;来自另一个 client 切换的实时刷新暂缓实现。覆盖率:proxy 注册表与权限 RPC 的单元测试套件、会话对象与 fixture 的单元测试套件、针对 fixture 模式审批应答与预设切换的无密钥 Web 冒烟测试,以及真实组合的 plan-review 与问题快照;这些快照会固定 pending 侧边栏状态直至解决。
+Web 会话从受限状态启动(默认 `workspace-write` + `ask`);`web_fetch` 只有在公开地址预检通过后才会等待可应答的单次请求,沙箱拒绝升级也通过同一通道抵达浏览器。部署方可以通过 `BootHostOptions.sandbox` 放宽或收紧默认值,无需触动装配。问题应答使用同一注册表模式(ui-user-questions 基于问题 pending 表),Session 导航会在用户打开会话前识别审批、计划审阅与普通问题等待。权限选择在每次挂载时读取一次;来自另一个 client 切换的实时刷新暂缓实现。覆盖包括策略决策矩阵与公开地址预检、proxy 注册表与权限 RPC 单元测试套件、会话对象与 fixture 单元测试套件、针对 fixture 模式审批应答与 preset 切换的无密钥 Web 冒烟测试,以及真实组合的 plan-review 与问题快照;这些快照会固定 pending 侧边栏状态直至解决。
diff --git a/apps/cli/composition.md b/apps/cli/composition.md
index 9e119a6100..d3feb80caf 100644
--- a/apps/cli/composition.md
+++ b/apps/cli/composition.md
@@ -158,6 +158,10 @@ flowchart LR
cfg --> plugin_dsh_base_web
plugin_dsh_base_web_search_deepseek["web-search-deepseek
@deepseek-ai/dsh-web-search-deepseek"]
cfg --> plugin_dsh_base_web_search_deepseek
+ plugin_dsh_base_web_fetch_http["web-fetch-http
@deepseek-ai/dsh-web-fetch-http"]
+ cfg --> plugin_dsh_base_web_fetch_http
+ plugin_dsh_base_web_fetch_approval_policy["web-fetch-approval-policy
@deepseek-ai/dsh-web-fetch-approval-policy"]
+ cfg --> plugin_dsh_base_web_fetch_approval_policy
plugin_dsh_base_tool_web["tool-web
@deepseek-ai/dsh-tool-web"]
cfg --> plugin_dsh_base_tool_web
plugin_dsh_base_tools["tools
@deepseek-ai/dsh-tools"]
@@ -249,6 +253,8 @@ flowchart LR
| `repeat-tool-reminder` | `@deepseek-ai/dsh-repeat-tool-reminder` |
| `web` | `@deepseek-ai/dsh-web` |
| `web-search-deepseek` | `@deepseek-ai/dsh-web-search-deepseek` |
+| `web-fetch-http` | `@deepseek-ai/dsh-web-fetch-http` |
+| `web-fetch-approval-policy` | `@deepseek-ai/dsh-web-fetch-approval-policy` |
| `tool-web` | `@deepseek-ai/dsh-tool-web` |
| `tools` | `@deepseek-ai/dsh-tools` |
| `system-prompt` | `@deepseek-ai/dsh-system-prompt` |
diff --git a/docs/capability-seams.i18n.yaml b/docs/capability-seams.i18n.yaml
index 406b4015a0..cdbf54af8a 100644
--- a/docs/capability-seams.i18n.yaml
+++ b/docs/capability-seams.i18n.yaml
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write docs/capability-seams.md
-capability-seams.md: 75f050f329e709e5c88bffbe0d3bc2072d4286de
-capability-seams.zh.md: 25fa48c67e406b03677debba44eff5d49fd3c626
+capability-seams.md: 87f0ac17105bcde199e86cebc75fc9390f37fc24
+capability-seams.zh.md: b19afcbd3857935b20c39f9bfdb21c18913cbd01
diff --git a/docs/capability-seams.md b/docs/capability-seams.md
index 75f050f329..87f0ac1710 100644
--- a/docs/capability-seams.md
+++ b/docs/capability-seams.md
@@ -183,6 +183,7 @@ flowchart LR
pkg_web_search_perplexity["web-search-perplexity"]
pkg_web_search_deepseek["web-search-deepseek"]
pkg_web_fetch_http["web-fetch-http"]
+ pkg_web_fetch_approval_policy["web-fetch-approval-policy"]
pkg_spill["spill"]
svc_spillStore["ctx.spillStore
Spill storage seam"]
pkg_spill_local["spill-local"]
@@ -433,6 +434,7 @@ flowchart LR
svc_typert --> pkg_typert_loader
svc_userQuestions --> pkg_tool_ask_user
svc_web --> pkg_tool_web
+ svc_web --> pkg_web_fetch_approval_policy
svc_webServer --> pkg_connection
svc_webServer --> pkg_hmr
svc_webServer --> pkg_modules
@@ -497,7 +499,7 @@ flowchart LR
| `ctx.subagents` | `seam` | [`subagent`](../packages/subagent/subagent) | [`subagent-spawn-in-process`](../packages/subagent/subagent-spawn-in-process), [`subagent-fork-in-process`](../packages/subagent/subagent-fork-in-process), [`subagent-acp`](../packages/subagent/subagent-acp), [`subagent-codex`](../packages/subagent/subagent-codex), [`subagent-claude-code`](../packages/subagent/subagent-claude-code), [`subagent-dsh-sdk`](../packages/subagent/subagent-dsh-sdk) | [`tool-subagent`](../packages/subagent/tool-subagent), [`tool-subagent-control`](../packages/subagent/tool-subagent-control), [`tool-ralph`](../packages/workflow/tool-ralph) | - | Providers implement transports; the service also owns optional Activation-based continuation orchestration, tool-subagent selects one-shot or continuable delegation, tool-subagent-control delivers follow-ups, and tool-ralph requires one fresh structured-output route. |
| `ctx.agentTeams` | `core` | `agent-team` | - | `tool-agent-team` | - | Owns the implicit-root roster, durable peer mailbox, shared task DAG, and continuable-child lifecycle; tool-agent-team contributes the scoped model policy and controls. |
| `ctx.jobs` | `seam` | [`jobs`](../packages/jobs/jobs) | [`jobs-local`](../packages/jobs/jobs-local) | [`tool-bash`](../packages/shell/tool-bash), [`tool-terminal`](../packages/terminal/tool-terminal), [`tool-subagent`](../packages/subagent/tool-subagent), [`tool-jobs`](../packages/jobs/tool-jobs) | - | Producers (background bash, PTY sends, and subagent delegations) register running work; tool-jobs is the model-facing controller that reads, lists, and kills it; jobs-local is the process-local registry. |
-| `ctx.web` | `seam` | [`web`](../packages/web/web) | [`web-search-exa`](../packages/web/web-search-exa), [`web-search-perplexity`](../packages/web/web-search-perplexity), [`web-search-deepseek`](../packages/web/web-search-deepseek), [`web-fetch-http`](../packages/web/web-fetch-http) | [`tool-web`](../packages/web/tool-web) | - | Search and fetch providers register into one ctx.web seam; tool-web owns the stable model-facing names. |
+| `ctx.web` | `seam` | [`web`](../packages/web/web) | [`web-search-exa`](../packages/web/web-search-exa), [`web-search-perplexity`](../packages/web/web-search-perplexity), [`web-search-deepseek`](../packages/web/web-search-deepseek), [`web-fetch-http`](../packages/web/web-fetch-http) | [`tool-web`](../packages/web/tool-web), [`web-fetch-approval-policy`](../packages/web/web-fetch-approval-policy) | - | Search and fetch providers register into one ctx.web seam; tool-web owns the stable model-facing names, and web-fetch-approval-policy applies one-shot consent before restricted fetch calls. |
| `ctx.spillStore` | `seam` | [`spill`](../packages/spill/spill) | [`spill-local`](../packages/spill/spill-local) | [`spill-policy`](../packages/spill/spill-policy) | - | The backend saves oversized tool text and returns a model-facing locator plus retrieval hint; spill-policy is the tools/post-execute consumer that decides when to spill. |
| `ctx.directoryPicker` | `seam` | `directory-picker` | `directory-picker-native`, `directory-picker-browse` | `apiproxy` | - | Discriminated interaction capability: the native backend opens one OS chooser on the host display, the browse backend serves listing/creation primitives for the in-app browser; dual-face backends fill ui-workspace directory-flow slots from their browser halves (no wire advertisement). |
| `ctx.webServer` | `core` | `webserver` | - | `connection`, `modules`, `hmr` | - | Plain node:http carrier: named-route registry, index transform taps, and the static dist fallback; web-transport plugins register their own routes. |
diff --git a/docs/capability-seams.zh.md b/docs/capability-seams.zh.md
index 25fa48c67e..b19afcbd38 100644
--- a/docs/capability-seams.zh.md
+++ b/docs/capability-seams.zh.md
@@ -185,6 +185,7 @@ flowchart LR
pkg_web_search_perplexity["web-search-perplexity"]
pkg_web_search_deepseek["web-search-deepseek"]
pkg_web_fetch_http["web-fetch-http"]
+ pkg_web_fetch_approval_policy["web-fetch-approval-policy"]
pkg_spill["spill"]
svc_spillStore["ctx.spillStore
Spill storage seam"]
pkg_spill_local["spill-local"]
@@ -435,6 +436,7 @@ flowchart LR
svc_typert --> pkg_typert_loader
svc_userQuestions --> pkg_tool_ask_user
svc_web --> pkg_tool_web
+ svc_web --> pkg_web_fetch_approval_policy
svc_webServer --> pkg_connection
svc_webServer --> pkg_hmr
svc_webServer --> pkg_modules
@@ -499,7 +501,7 @@ flowchart LR
| `ctx.subagents` | `seam` | [`subagent`](../packages/subagent/subagent) | [`subagent-spawn-in-process`](../packages/subagent/subagent-spawn-in-process), [`subagent-fork-in-process`](../packages/subagent/subagent-fork-in-process), [`subagent-acp`](../packages/subagent/subagent-acp), [`subagent-codex`](../packages/subagent/subagent-codex), [`subagent-claude-code`](../packages/subagent/subagent-claude-code), [`subagent-dsh-sdk`](../packages/subagent/subagent-dsh-sdk) | [`tool-subagent`](../packages/subagent/tool-subagent), [`tool-subagent-control`](../packages/subagent/tool-subagent-control), [`tool-ralph`](../packages/workflow/tool-ralph) | - | 提供方实现传输;该服务还负责可选的、基于 Activation 的延续编排,tool-subagent 选择一次性或可延续委派,tool-subagent-control 传递后续消息,而 tool-ralph 要求一条全新的结构化输出路由。 |
| `ctx.agentTeams` | `core` | `agent-team` | - | `tool-agent-team` | - | 负责隐式 Root roster、持久 peer mailbox、共享任务 DAG 与 continuable child 生命周期;tool-agent-team 提供作用域化模型策略和控制工具。 |
| `ctx.jobs` | `seam` | [`jobs`](../packages/jobs/jobs) | [`jobs-local`](../packages/jobs/jobs-local) | [`tool-bash`](../packages/shell/tool-bash), [`tool-terminal`](../packages/terminal/tool-terminal), [`tool-subagent`](../packages/subagent/tool-subagent), [`tool-jobs`](../packages/jobs/tool-jobs) | - | 生产方(后台 bash、PTY 发送和 subagent 委派)登记正在运行的工作;tool-jobs 是面向模型的控制器,用于读取、列出和终止这些工作;jobs-local 是进程本地注册表。 |
-| `ctx.web` | `seam` | [`web`](../packages/web/web) | [`web-search-exa`](../packages/web/web-search-exa), [`web-search-perplexity`](../packages/web/web-search-perplexity), [`web-search-deepseek`](../packages/web/web-search-deepseek), [`web-fetch-http`](../packages/web/web-fetch-http) | [`tool-web`](../packages/web/tool-web) | - | 搜索和抓取提供方注册到同一个 ctx.web seam;tool-web 负责稳定的面向模型名称。 |
+| `ctx.web` | `seam` | [`web`](../packages/web/web) | [`web-search-exa`](../packages/web/web-search-exa), [`web-search-perplexity`](../packages/web/web-search-perplexity), [`web-search-deepseek`](../packages/web/web-search-deepseek), [`web-fetch-http`](../packages/web/web-fetch-http) | [`tool-web`](../packages/web/tool-web), [`web-fetch-approval-policy`](../packages/web/web-fetch-approval-policy) | - | 搜索和抓取提供方注册到同一个 ctx.web seam;tool-web 负责稳定的面向模型名称,web-fetch-approval-policy 则在受限抓取调用前应用单次同意策略。 |
| `ctx.spillStore` | `seam` | [`spill`](../packages/spill/spill) | [`spill-local`](../packages/spill/spill-local) | [`spill-policy`](../packages/spill/spill-policy) | - | 后端保存过大的工具文本,并返回面向模型的定位信息和取回提示;spill-policy 是 tools/post-execute 消费方,负责决定何时 spill。 |
| `ctx.directoryPicker` | `seam` | `directory-picker` | `directory-picker-native`, `directory-picker-browse` | `apiproxy` | - | 带判别标记的交互能力:原生后端在 Host 显示设备上打开一个操作系统选择器,浏览后端为应用内浏览器提供列表与创建原语;双端后端通过其浏览器侧填充 ui-workspace 目录流程的 slot(不通过协议发布)。 |
| `ctx.webServer` | `core` | `webserver` | - | `connection`, `modules`, `hmr` | - | 普通的 node:http 载体:具名路由注册表、索引转换 tap,以及静态 dist 回退;Web 传输插件注册自己的路由。 |
diff --git a/docs/config-catalog.i18n.yaml b/docs/config-catalog.i18n.yaml
index aed3574106..51d622a13c 100644
--- a/docs/config-catalog.i18n.yaml
+++ b/docs/config-catalog.i18n.yaml
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write docs/config-catalog.md
-config-catalog.md: a845fe22e13ed085765668c7ec8d54d6bbdf129a
-config-catalog.zh.md: 39ba9d48368f99483733292f997609ba3a8aa43e
+config-catalog.md: b72d89095865fa05d4626ecf23c01912a457c525
+config-catalog.zh.md: 8c80830295299e58806e741863edfcc953cfa31b
diff --git a/docs/config-catalog.md b/docs/config-catalog.md
index a845fe22e1..b72d890958 100644
--- a/docs/config-catalog.md
+++ b/docs/config-catalog.md
@@ -3119,7 +3119,7 @@ export interface Config {
}
```
-Source: [`packages/web/web-fetch-http/src/index.ts:32`](../packages/web/web-fetch-http/src/index.ts)
+Source: [`packages/web/web-fetch-http/src/index.ts:33`](../packages/web/web-fetch-http/src/index.ts)
@@ -3328,6 +3328,7 @@ These load from a `cordis.yml` entry with no `config:` block; they declare no co
- `@deepseek-ai/dsh-tool-cordis` — requires `tools` · `systemPrompt` · `dynamicCordisRunner` · `cordisInspect` ([`packages/extensions/tool-cordis/src/index.ts`](../packages/extensions/tool-cordis/src/index.ts))
- `@deepseek-ai/dsh-tool-subagent-control` — requires `tools` · `subagents` ([`packages/subagent/tool-subagent-control/src/index.ts`](../packages/subagent/tool-subagent-control/src/index.ts))
- `@deepseek-ai/dsh-user-questions` ([`packages/interaction/user-questions/src/index.ts`](../packages/interaction/user-questions/src/index.ts))
+- `@deepseek-ai/dsh-web-fetch-approval-policy` — requires `tools` · `sandboxPolicy` · `approval` ([`packages/web/web-fetch-approval-policy/src/index.ts`](../packages/web/web-fetch-approval-policy/src/index.ts))
- `@deepseek-ai/dsh-webhook` — requires `agents` · `agentDefaultModel` · `agentPresets` · `permissionPresets` · `sessionTitle` · `workspaceRegistry` ([`packages/webhook/webhook/src/index.ts`](../packages/webhook/webhook/src/index.ts))
- `@deepseek-ai/dsh-workspace` — requires `storageDomain` · `sessionPersistence` ([`packages/workspace/workspace/src/index.ts`](../packages/workspace/workspace/src/index.ts))
diff --git a/docs/config-catalog.zh.md b/docs/config-catalog.zh.md
index 39ba9d4836..8c80830295 100644
--- a/docs/config-catalog.zh.md
+++ b/docs/config-catalog.zh.md
@@ -3121,7 +3121,7 @@ export interface Config {
}
```
-来源:[`packages/web/web-fetch-http/src/index.ts:32`](../packages/web/web-fetch-http/src/index.ts)
+来源:[`packages/web/web-fetch-http/src/index.ts:33`](../packages/web/web-fetch-http/src/index.ts)
@@ -3330,6 +3330,7 @@ export interface Config {
- `@deepseek-ai/dsh-tool-cordis` — 需要 `tools` · `systemPrompt` · `dynamicCordisRunner` · `cordisInspect`([`packages/extensions/tool-cordis/src/index.ts`](../packages/extensions/tool-cordis/src/index.ts))
- `@deepseek-ai/dsh-tool-subagent-control` — 需要 `tools` · `subagents`([`packages/subagent/tool-subagent-control/src/index.ts`](../packages/subagent/tool-subagent-control/src/index.ts))
- `@deepseek-ai/dsh-user-questions`([`packages/interaction/user-questions/src/index.ts`](../packages/interaction/user-questions/src/index.ts))
+- `@deepseek-ai/dsh-web-fetch-approval-policy` — 需要 `tools` · `sandboxPolicy` · `approval`([`packages/web/web-fetch-approval-policy/src/index.ts`](../packages/web/web-fetch-approval-policy/src/index.ts))
- `@deepseek-ai/dsh-webhook` — 需要 `agents` · `agentDefaultModel` · `agentPresets` · `permissionPresets` · `sessionTitle` · `workspaceRegistry`([`packages/webhook/webhook/src/index.ts`](../packages/webhook/webhook/src/index.ts))
- `@deepseek-ai/dsh-workspace` — 需要 `storageDomain` · `sessionPersistence`([`packages/workspace/workspace/src/index.ts`](../packages/workspace/workspace/src/index.ts))
diff --git a/docs/event-producer-consumer.i18n.yaml b/docs/event-producer-consumer.i18n.yaml
index c9e637910e..170d7a0f5d 100644
--- a/docs/event-producer-consumer.i18n.yaml
+++ b/docs/event-producer-consumer.i18n.yaml
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write docs/event-producer-consumer.md
-event-producer-consumer.md: 2be5a84969b9f14823abf90cf289a0a41e48dd11
-event-producer-consumer.zh.md: 5bbae1be5d03c3e443d36093ce60dbf7e4b07971
+event-producer-consumer.md: 2563ce3281150589418c6eb9c384fc4f566b95ed
+event-producer-consumer.zh.md: 6c9883c22eaf63de78b88f4aa60b8be0718bc77d
diff --git a/docs/event-producer-consumer.md b/docs/event-producer-consumer.md
index 2be5a84969..2563ce3281 100644
--- a/docs/event-producer-consumer.md
+++ b/docs/event-producer-consumer.md
@@ -62,7 +62,7 @@ This matrix shows which packages dispatch each harness-owned event and which pac
| `tools/code-dispatch-log` | `waterfall` | [`packages/core/tools/src/index.ts:189`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | [`spill-policy`](../packages/spill/spill-policy) |
| `tools/execute` | `waterfall` | [`packages/core/tools/src/index.ts:163`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | [`session-checkpoint-policy`](../packages/session/session-checkpoint-policy), `timeout-policy` |
| `tools/post-execute` | `waterfall` | [`packages/core/tools/src/index.ts:175`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`hooks-codex`](../packages/hooks/hooks-codex), [`repeat-tool-reminder`](../packages/guard/repeat-tool-reminder), [`spill-policy`](../packages/spill/spill-policy), [`tool-fs-search`](../packages/fs/tool-fs-search) |
-| `tools/pre-execute` | `waterfall` | [`packages/core/tools/src/index.ts:152`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`hooks-codex`](../packages/hooks/hooks-codex), [`tool-jobs`](../packages/jobs/tool-jobs) |
+| `tools/pre-execute` | `waterfall` | [`packages/core/tools/src/index.ts:152`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`hooks-codex`](../packages/hooks/hooks-codex), [`tool-jobs`](../packages/jobs/tool-jobs), [`web-fetch-approval-policy`](../packages/web/web-fetch-approval-policy) |
| `tools/result` | `emit` | [`packages/core/tools/src/index.ts:197`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`events.dispatch`) | [`agent-instructions`](../packages/context/agent-instructions), [`subagent-in-process-driver`](../packages/subagent/subagent-in-process-driver) |
| `user-questions/request` | `waterfall` | [`packages/interaction/user-questions/src/types.ts:85`](../packages/interaction/user-questions/src/types.ts) | [`user-questions`](../packages/interaction/user-questions) (`waterfall`) | `remotes` |
| `webserver/index-inject` | `emit` | [`packages/host/webserver/src/index.ts:34`](../packages/host/webserver/src/index.ts) | `webserver` (`emit`) | `modules` |
diff --git a/docs/event-producer-consumer.zh.md b/docs/event-producer-consumer.zh.md
index 5bbae1be5d..6c9883c22e 100644
--- a/docs/event-producer-consumer.zh.md
+++ b/docs/event-producer-consumer.zh.md
@@ -64,7 +64,7 @@
| `tools/code-dispatch-log` | `waterfall` | [`packages/core/tools/src/index.ts:189`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | [`spill-policy`](../packages/spill/spill-policy) |
| `tools/execute` | `waterfall` | [`packages/core/tools/src/index.ts:163`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | [`session-checkpoint-policy`](../packages/session/session-checkpoint-policy), `timeout-policy` |
| `tools/post-execute` | `waterfall` | [`packages/core/tools/src/index.ts:175`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`hooks-codex`](../packages/hooks/hooks-codex), [`repeat-tool-reminder`](../packages/guard/repeat-tool-reminder), [`spill-policy`](../packages/spill/spill-policy), [`tool-fs-search`](../packages/fs/tool-fs-search) |
-| `tools/pre-execute` | `waterfall` | [`packages/core/tools/src/index.ts:152`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`hooks-codex`](../packages/hooks/hooks-codex), [`tool-jobs`](../packages/jobs/tool-jobs) |
+| `tools/pre-execute` | `waterfall` | [`packages/core/tools/src/index.ts:152`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`hooks-codex`](../packages/hooks/hooks-codex), [`tool-jobs`](../packages/jobs/tool-jobs), [`web-fetch-approval-policy`](../packages/web/web-fetch-approval-policy) |
| `tools/result` | `emit` | [`packages/core/tools/src/index.ts:197`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`events.dispatch`) | [`agent-instructions`](../packages/context/agent-instructions), [`subagent-in-process-driver`](../packages/subagent/subagent-in-process-driver) |
| `user-questions/request` | `waterfall` | [`packages/interaction/user-questions/src/types.ts:85`](../packages/interaction/user-questions/src/types.ts) | [`user-questions`](../packages/interaction/user-questions) (`waterfall`) | `remotes` |
| `webserver/index-inject` | `emit` | [`packages/host/webserver/src/index.ts:34`](../packages/host/webserver/src/index.ts) | `webserver` (`emit`) | `modules` |
diff --git a/docs/module-graph.i18n.yaml b/docs/module-graph.i18n.yaml
index b8a9b43bd1..ff21b1e0ab 100644
--- a/docs/module-graph.i18n.yaml
+++ b/docs/module-graph.i18n.yaml
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write docs/module-graph.md
-module-graph.md: d70aa9a7704a7de5b669928a6cafd8358fb2a3b0
-module-graph.zh.md: 2333d71e61bd935fa482fc766bb7d96bb75d56db
+module-graph.md: 36053a352250d382116ae5a6f370404f1b1080c7
+module-graph.zh.md: 41289d38390466cbf53be431ca4fac0c720428cf
diff --git a/docs/module-graph.md b/docs/module-graph.md
index d70aa9a770..36053a3522 100644
--- a/docs/module-graph.md
+++ b/docs/module-graph.md
@@ -74,6 +74,7 @@ flowchart TD
subgraph group_web["packages/web"]
pkg_tool_web["tool-web"]
pkg_web["web"]
+ pkg_web_fetch_approval_policy["web-fetch-approval-policy"]
pkg_web_fetch_http["web-fetch-http"]
pkg_web_search_deepseek["web-search-deepseek"]
pkg_web_search_exa["web-search-exa"]
@@ -830,6 +831,11 @@ flowchart TD
pkg_tool_web --> pkg_system_prompt
pkg_tool_web --> pkg_tools
pkg_tool_web --> pkg_web
+ pkg_web_fetch_approval_policy --> pkg_invariants
+ pkg_web_fetch_approval_policy --> pkg_sandbox_policy
+ pkg_web_fetch_approval_policy --> pkg_tools
+ pkg_web_fetch_approval_policy --> pkg_user_approval
+ pkg_web_fetch_approval_policy --> pkg_web_fetch_http
pkg_spill_policy --> pkg_invariants
pkg_spill_policy --> pkg_llm
pkg_spill_policy --> pkg_output_retention
@@ -1777,6 +1783,7 @@ flowchart TD
| [`tool-skill`](../packages/skill/tool-skill) | `skill` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`skill`](../packages/skill/skill), [`tools`](../packages/core/tools) |
| [`subagent`](../packages/subagent/subagent) | `subagent` | [`agent`](../packages/core/agent), [`agent-presets`](../packages/preset/agent-presets), [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`jobs`](../packages/jobs/jobs), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval) |
| [`tool-web`](../packages/web/tool-web) | `web` | [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`web`](../packages/web/web) |
+| [`web-fetch-approval-policy`](../packages/web/web-fetch-approval-policy) | `web` | [`invariants`](../packages/runtime-diagnostics/invariants), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval), [`web-fetch-http`](../packages/web/web-fetch-http) |
| [`spill-policy`](../packages/spill/spill-policy) | `spill` | [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`output-retention`](../packages/util/output-retention), [`session`](../packages/core/session), [`spill`](../packages/spill/spill), [`tools`](../packages/core/tools) |
| [`tool-todo`](../packages/todo/tool-todo) | `todo` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`tools`](../packages/core/tools) |
| [`plan-mode`](../packages/plan/plan-mode) | `plan` | [`agent`](../packages/core/agent), [`commands`](../packages/interaction/commands), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`user-questions`](../packages/interaction/user-questions) |
diff --git a/docs/module-graph.zh.md b/docs/module-graph.zh.md
index 2333d71e61..41289d3839 100644
--- a/docs/module-graph.zh.md
+++ b/docs/module-graph.zh.md
@@ -76,6 +76,7 @@ flowchart TD
subgraph group_web["packages/web"]
pkg_tool_web["tool-web"]
pkg_web["web"]
+ pkg_web_fetch_approval_policy["web-fetch-approval-policy"]
pkg_web_fetch_http["web-fetch-http"]
pkg_web_search_deepseek["web-search-deepseek"]
pkg_web_search_exa["web-search-exa"]
@@ -832,6 +833,11 @@ flowchart TD
pkg_tool_web --> pkg_system_prompt
pkg_tool_web --> pkg_tools
pkg_tool_web --> pkg_web
+ pkg_web_fetch_approval_policy --> pkg_invariants
+ pkg_web_fetch_approval_policy --> pkg_sandbox_policy
+ pkg_web_fetch_approval_policy --> pkg_tools
+ pkg_web_fetch_approval_policy --> pkg_user_approval
+ pkg_web_fetch_approval_policy --> pkg_web_fetch_http
pkg_spill_policy --> pkg_invariants
pkg_spill_policy --> pkg_llm
pkg_spill_policy --> pkg_output_retention
@@ -1779,6 +1785,7 @@ flowchart TD
| [`tool-skill`](../packages/skill/tool-skill) | `skill` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`skill`](../packages/skill/skill), [`tools`](../packages/core/tools) |
| [`subagent`](../packages/subagent/subagent) | `subagent` | [`agent`](../packages/core/agent), [`agent-presets`](../packages/preset/agent-presets), [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`jobs`](../packages/jobs/jobs), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval) |
| [`tool-web`](../packages/web/tool-web) | `web` | [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`web`](../packages/web/web) |
+| [`web-fetch-approval-policy`](../packages/web/web-fetch-approval-policy) | `web` | [`invariants`](../packages/runtime-diagnostics/invariants), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval), [`web-fetch-http`](../packages/web/web-fetch-http) |
| [`spill-policy`](../packages/spill/spill-policy) | `spill` | [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`output-retention`](../packages/util/output-retention), [`session`](../packages/core/session), [`spill`](../packages/spill/spill), [`tools`](../packages/core/tools) |
| [`tool-todo`](../packages/todo/tool-todo) | `todo` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`tools`](../packages/core/tools) |
| [`plan-mode`](../packages/plan/plan-mode) | `plan` | [`agent`](../packages/core/agent), [`commands`](../packages/interaction/commands), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`user-questions`](../packages/interaction/user-questions) |
diff --git a/docs/subsystems/approval.i18n.yaml b/docs/subsystems/approval.i18n.yaml
index a52cf9a865..b3bebef45e 100644
--- a/docs/subsystems/approval.i18n.yaml
+++ b/docs/subsystems/approval.i18n.yaml
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write docs/subsystems/approval.md
-approval.md: 7b12e7f766555fda09b5b2ac405129b8bfe17daf
-approval.zh.md: 7596f28d51ef6dfd4e883eaff8c155111e1d2f1c
+approval.md: 4459de130019b240c188928c0dc723c6fa533b1d
+approval.zh.md: 15522f4e207d58fbc07f90aceeeef2275d8910a6
diff --git a/docs/subsystems/approval.md b/docs/subsystems/approval.md
index 7b12e7f766..4459de1300 100644
--- a/docs/subsystems/approval.md
+++ b/docs/subsystems/approval.md
@@ -30,7 +30,7 @@ type ApprovalOutcome = 'allowed-once' | 'rejected' | 'cancelled' | 'unavailable'
## Per-session policy
-`ApprovalPolicy` determines what happens before interactive answerers run. `ask` delegates to the composed answerer chain, whose no-answer default is `unavailable`; `never` deterministically returns `rejected` without dispatching any answerer. The effective value is the last `approval/policy` event in the session log, falling back to the service config. `setApprovalPolicy(session, policy)` is the single write path, so replay reconstructs the override.
+`ApprovalPolicy` determines what happens before interactive answerers run. `ask` delegates to the composed answerer chain, whose no-answer default is `unavailable`; `never` deterministically returns `rejected` without dispatching any answerer. The effective value is the last `approval/policy` event in the session log, falling back to the service config. Consumers read it with `ctx.approval.effectivePolicy(session)`; `setApprovalPolicy(session, policy)` is the single write path, so replay reconstructs the override.
```ts type-equiv
/**
@@ -131,6 +131,15 @@ setPolicy(agent: Agent, policy: ApprovalPolicy): void
*/
async request(req: ApprovalRequest): Promise
+/**
+ * The session's effective policy: its own `approval/policy` fold, else the
+ * configured default (the schema already defaulted an omitted policy to
+ * `'ask'`; the `??` only narrows the optional-input TYPE).
+ * @param session - the exact accepted session whose policy applies.
+ * @returns the policy every ask for this session resolves under right now.
+ */
+effectivePolicy(session: Session): ApprovalPolicy
+
/**
* Read the session override without applying the configured default.
* @param session - session whose log supplies the override.
diff --git a/docs/subsystems/approval.zh.md b/docs/subsystems/approval.zh.md
index 7596f28d51..15522f4e20 100644
--- a/docs/subsystems/approval.zh.md
+++ b/docs/subsystems/approval.zh.md
@@ -30,7 +30,7 @@ type ApprovalOutcome = 'allowed-once' | 'rejected' | 'cancelled' | 'unavailable'
## 按会话策略
-`ApprovalPolicy` 决定在交互式应答者运行之前发生什么。`ask` 委托给组合的应答者链,链的无应答默认值为 `unavailable`;`never` 确定性地返回 `rejected`,不分发任何应答者。生效值为会话日志中最后一条 `approval/policy` 事件,回退到服务配置。`setApprovalPolicy(session, policy)` 是唯一的写入路径,因此回放能重建覆盖值。
+`ApprovalPolicy` 决定在交互式应答者运行之前发生什么。`ask` 委托给组合的应答者链,链的无应答默认值为 `unavailable`;`never` 确定性地返回 `rejected`,不分发任何应答者。生效值为会话日志中最后一条 `approval/policy` 事件,回退到服务配置。消费方通过 `ctx.approval.effectivePolicy(session)` 读取;`setApprovalPolicy(session, policy)` 是唯一的写入路径,因此回放能重建覆盖值。
```ts type-equiv
/**
@@ -131,6 +131,15 @@ setPolicy(agent: Agent, policy: ApprovalPolicy): void
*/
async request(req: ApprovalRequest): Promise
+/**
+ * The session's effective policy: its own `approval/policy` fold, else the
+ * configured default (the schema already defaulted an omitted policy to
+ * `'ask'`; the `??` only narrows the optional-input TYPE).
+ * @param session - the exact accepted session whose policy applies.
+ * @returns the policy every ask for this session resolves under right now.
+ */
+effectivePolicy(session: Session): ApprovalPolicy
+
/**
* Read the session override without applying the configured default.
* @param session - session whose log supplies the override.
diff --git a/docs/subsystems/web.i18n.yaml b/docs/subsystems/web.i18n.yaml
index 91854e163a..039bc1a5a5 100644
--- a/docs/subsystems/web.i18n.yaml
+++ b/docs/subsystems/web.i18n.yaml
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write docs/subsystems/web.md
-web.md: 72942a62759ce8a875540d4637a34d1d968632a0
-web.zh.md: 58fb351adf13d818a9c0191d7d869d707233b46e
+web.md: 3e694ec4fecbcfb5a93f61b30d9ea0a4af8f4a7c
+web.zh.md: 43de369c4a479543c935f401b212128df425057a
diff --git a/docs/subsystems/web.md b/docs/subsystems/web.md
index 72942a6275..3e694ec4fe 100644
--- a/docs/subsystems/web.md
+++ b/docs/subsystems/web.md
@@ -124,6 +124,12 @@ A provider's `available(): boolean` is a cheap LOCAL check (credential presence,
Selection never depends on registration, config, or HMR order: a capability has an explicit provider id (config `searchProvider`/`fetchProvider`, or the matching env var feeding the same field), or auto-selects when exactly one usable provider is registered; multiple usable providers with no configured id is `WEB_PROVIDER_AMBIGUOUS`, not first-wins.
+## Fetch permission
+
+[`dsh-web-fetch-approval-policy`](../../packages/web/web-fetch-approval-policy) listens on `tools/pre-execute` without changing the web service or tool schemas. `danger-full-access` delegates to later policies without asking. `read-only` and `workspace-write` require approval policy `ask`, validate that the current URL resolves only to public addresses, preserve any downstream denial, and return `ask` with the exact call id and full normalized URL. Approval policy `never` and agentless restricted calls deny without DNS or a prompt. Only `allowed-once` grants the pending call; there is no persistent domain or session authorization.
+
+Permission preflight and provider enforcement are separate. Preflight prevents a blocked destination from appearing in an approval prompt, but its DNS result is not reused as authorization. The HTTP provider resolves again for the actual request, pins that validated address set, and repeats enforcement for each same-origin redirect; a cross-origin redirect requires a new tool call and permission decision. `plan` remains collaboration state rather than a network mode, so products combine plan work with the desired sandbox and approval policies.
+
## Errors
`WebError extends HarnessError` ([core.md](core.md) error taxonomy) with a `code: string` (open, like every other seam's error — `LlmError`, `SubagentError`), not a closed union: a provider may raise its own codes without editing `dsh-web`, and consumers must tolerate an unknown code. The codes split by owner. Seam-neutral codes are raised by the shared `WebRuntime` contract: `WEB_PROVIDER_UNAVAILABLE`, `WEB_PROVIDER_CONFIGURED_MISSING`, `WEB_PROVIDER_CONFIGURED_UNAVAILABLE`, `WEB_PROVIDER_AMBIGUOUS`, `WEB_DUPLICATE_PROVIDER` (a registration-time programming error, the analogue of `LlmRuntime`'s `DUPLICATE_ADAPTER`), `WEB_ABORTED`, and `WEB_PROVIDER_ERROR` (the catch-all for a provider's own failure surfaced through the seam, including network/transport failure — DNS, connection refused, TLS). Fetch-transport codes are owned by the `dsh-web-fetch-http` implementation and a different fetch backend need not raise them: `WEB_INVALID_URL`, `WEB_BLOCKED_URL`, `WEB_REDIRECT_BLOCKED`, `WEB_FETCH_TOO_LARGE`, `WEB_FETCH_TIMEOUT`, `WEB_UNSUPPORTED_CONTENT_TYPE`.
diff --git a/docs/subsystems/web.zh.md b/docs/subsystems/web.zh.md
index 58fb351adf..43de369c4a 100644
--- a/docs/subsystems/web.zh.md
+++ b/docs/subsystems/web.zh.md
@@ -124,6 +124,12 @@ type WebFetchBody =
选择从不依赖注册顺序、配置顺序或 HMR(热模块替换)顺序:一项能力要么有显式的提供方 id(配置 `searchProvider`/`fetchProvider`,或填充同一字段的对应环境变量),要么在恰好只有一个可用提供方注册时自动选择;如果存在多个可用提供方却未配置 id,则抛出 `WEB_PROVIDER_AMBIGUOUS`,而不会选用最先注册的提供方。
+## 抓取权限
+
+[`dsh-web-fetch-approval-policy`](../../packages/web/web-fetch-approval-policy) 监听 `tools/pre-execute`,不改变 web 服务或工具 schema。`danger-full-access` 不询问并委托后续策略。`read-only` 与 `workspace-write` 要求审批策略为 `ask`,验证当前 URL 只解析到公开地址,保留下游拒绝,并返回携带精确 call id 与完整标准化 URL 的 `ask`。审批策略 `never` 和受限模式下的无 agent 调用不进行 DNS 解析或提示,直接拒绝。只有 `allowed-once` 允许该次 pending 调用;不存在按域名或 session 持久化的授权。
+
+权限预检与提供方强制执行彼此独立。预检防止被阻断的目的地址出现在审批提示中,但其 DNS 结果不会被复用为授权。HTTP 提供方为实际请求重新解析、固定该组已验证地址,并对每个同源重定向重复强制校验;跨源重定向需要新的工具调用与权限决策。`plan` 仍是协作状态,而不是网络 mode,因此产品应将 plan 工作与所需的 sandbox 和审批策略组合。
+
## 错误
`WebError extends HarnessError`([core.md](core.zh.md) 错误分类体系),带有 `code: string`(开放式,与其他 seam 的错误一致——`LlmError`、`SubagentError`),而非封闭联合类型:提供方可以在不修改 `dsh-web` 的情况下抛出自己的错误代码,消费方必须容忍未知错误代码。错误代码按所有者划分。共享的 `WebRuntime` 约定会抛出与 seam 无关的错误代码:`WEB_PROVIDER_UNAVAILABLE`、`WEB_PROVIDER_CONFIGURED_MISSING`、`WEB_PROVIDER_CONFIGURED_UNAVAILABLE`、`WEB_PROVIDER_AMBIGUOUS`、`WEB_DUPLICATE_PROVIDER`(注册时的编程错误,类似 `LlmRuntime` 的 `DUPLICATE_ADAPTER`)、`WEB_ABORTED`,以及 `WEB_PROVIDER_ERROR`(提供方自身故障经 seam 暴露时使用的兜底代码,包括 DNS、连接被拒绝、TLS 等网络或传输故障)。抓取传输层错误代码由 `dsh-web-fetch-http` 实现拥有,不同的抓取后端无需抛出它们:`WEB_INVALID_URL`、`WEB_BLOCKED_URL`、`WEB_REDIRECT_BLOCKED`、`WEB_FETCH_TOO_LARGE`、`WEB_FETCH_TIMEOUT`、`WEB_UNSUPPORTED_CONTENT_TYPE`。
diff --git a/examples/acp-agent/tests/acp.snapshot.ts b/examples/acp-agent/tests/acp.snapshot.ts
index c7030d14ef..a5a6da8143 100644
--- a/examples/acp-agent/tests/acp.snapshot.ts
+++ b/examples/acp-agent/tests/acp.snapshot.ts
@@ -350,10 +350,10 @@ const SCENARIOS: Scenario[] = [
prepareWorkspace: prepareEditingCordisSkillWorkspace,
},
{ name: 'lsp-definition', hasModelTurn: true, recorded: false, pinsHeader: true, headerClass: 'lsp', configPath: LSP_CONFIG },
- // web_fetch non-public-address rejection end to end: the real provider
- // resolves the recorded loopback target and the result pins the failed tool
- // call. The fixed URL is part of the recorded transcript; replay re-executes
- // the real network policy without opening a connection.
+ // web_fetch non-public-address rejection end to end: the permission policy
+ // resolves the recorded loopback target before asking and the result pins the
+ // failed tool call. The fixed URL is part of the recorded transcript; replay
+ // re-executes the real network policy without opening a connection.
{ name: 'web-fetch', hasModelTurn: true, recorded: true, pinsHeader: true, headerClass: 'web', configPath: WEB_CONFIG },
{
name: 'workspace-edit',
diff --git a/examples/acp-agent/web.cordis.snapshot.yml b/examples/acp-agent/web.cordis.snapshot.yml
index 18ecb3ed29..d02ce6ce26 100644
--- a/examples/acp-agent/web.cordis.snapshot.yml
+++ b/examples/acp-agent/web.cordis.snapshot.yml
@@ -1,12 +1,10 @@
-# Keyless replay counterpart to web.cordis.yml: the real provider rejects the
-# recorded loopback target; only the model adapter is replaced by replay.
+# Keyless replay counterpart to web.cordis.yml: permission preflight rejects
+# the recorded loopback target; only the model adapter is replaced by replay.
- id: llm-deepseek
name: '@deepseek-ai/dsh-llm-deepseek'
disabled: true
- insert:
- - id: web-fetch-http
- name: '@deepseek-ai/dsh-web-fetch-http'
- id: llm-replay
name: '@deepseek-ai/dsh-llm-replay'
config:
@@ -17,10 +15,8 @@
- id: deepseek-v4-flash
- id: deepseek-v4-pro
-- id: web
- name: '@deepseek-ai/dsh-web'
-
- id: tool-web
name: '@deepseek-ai/dsh-tool-web'
config:
search: false
+ fetch: true
diff --git a/examples/acp-agent/web.cordis.yml b/examples/acp-agent/web.cordis.yml
index 32b81ce514..99bc7769bd 100644
--- a/examples/acp-agent/web.cordis.yml
+++ b/examples/acp-agent/web.cordis.yml
@@ -1,16 +1,9 @@
-# Web-fetch composition for the web-fetch snapshot scenario: the web seam, the
-# real local HTTP fetch provider, and the model-facing web tools (fetch only,
-# so the pinned header carries exactly the surface under test). The recorded
-# loopback target exercises the provider's non-public-address rejection without
-# opening a network connection.
-- insert:
- - id: web-fetch-http
- name: '@deepseek-ai/dsh-web-fetch-http'
-
-- id: web
- name: '@deepseek-ai/dsh-web'
-
+# Web-fetch composition for the web-fetch snapshot scenario. The base bundle
+# supplies the web seam, public HTTP provider, and fetch permission policy; this
+# overlay narrows the model-facing tools to fetch only. The recorded loopback
+# target is rejected during permission preflight without opening a connection.
- id: tool-web
name: '@deepseek-ai/dsh-tool-web'
config:
search: false
+ fetch: true
diff --git a/packages/bundle/base/cordis.patch.yml b/packages/bundle/base/cordis.patch.yml
index 5fe58dc5e7..742d9f9bf1 100644
--- a/packages/bundle/base/cordis.patch.yml
+++ b/packages/bundle/base/cordis.patch.yml
@@ -405,25 +405,34 @@
thresholds: [3, 5, 8]
argumentsPreviewChars: 500
- # Every mode enables the stable model-facing web_search tool. DeepSeek search
- # resolves the same DEEPSEEK_API_KEY credential the Models page manages for
- # chat, at each search; its Messages endpoint is separate from the
- # chat-completions endpoint, so it takes its own base-URL override. Fetch stays
- # disabled and no fetch provider is mounted because the shipped permission
- # presets do not yet classify public network access; web_fetch otherwise runs
- # without approval. Search is a full auxiliary model request with server-side
- # retrieval, so this shipped DeepSeek route gets 60s while the provider-neutral
- # tool default remains 30s.
+ # Every mode enables the stable model-facing web_search tool. The Web app's
+ # per-agent presets additionally enable web_fetch; other products opt in by
+ # overriding tool-web. DeepSeek search resolves the same DEEPSEEK_API_KEY
+ # credential the Models page manages for chat, at each search; its Messages
+ # endpoint is separate from the chat-completions endpoint, so it takes its own
+ # base-URL override. Anonymous fetch accepts only public HTTP(S) destinations.
+ # Restricted modes preflight the destination and require one-shot approval;
+ # danger-full-access delegates directly, while the provider independently
+ # re-resolves and pins every actual connection. Search is a full auxiliary
+ # model request with server-side retrieval, so this shipped DeepSeek route
+ # gets 60s while the provider-neutral tool default remains 30s.
- id: web
name: '@deepseek-ai/dsh-web'
config:
searchProvider: deepseek-official
+ fetchProvider: http
- id: web-search-deepseek
name: '@deepseek-ai/dsh-web-search-deepseek'
config:
apiKeyEnv: DEEPSEEK_API_KEY
+ - id: web-fetch-http
+ name: '@deepseek-ai/dsh-web-fetch-http'
+
+ - id: web-fetch-approval-policy
+ name: '@deepseek-ai/dsh-web-fetch-approval-policy'
+
- id: tool-web
name: '@deepseek-ai/dsh-tool-web'
config:
diff --git a/packages/bundle/base/package.json b/packages/bundle/base/package.json
index 2d0977a727..ce89382b5b 100644
--- a/packages/bundle/base/package.json
+++ b/packages/bundle/base/package.json
@@ -116,6 +116,8 @@
"@deepseek-ai/dsh-user-approval": "workspace:^",
"@deepseek-ai/dsh-user-questions": "workspace:^",
"@deepseek-ai/dsh-web": "workspace:^",
+ "@deepseek-ai/dsh-web-fetch-approval-policy": "workspace:^",
+ "@deepseek-ai/dsh-web-fetch-http": "workspace:^",
"@deepseek-ai/dsh-web-search-deepseek": "workspace:^",
"@deepseek-ai/dsh-workflow-worker-thread": "workspace:^",
"@deepseek-ai/dsh-agent-instructions": "workspace:^"
diff --git a/packages/bundle/base/tests/base.spec.ts b/packages/bundle/base/tests/base.spec.ts
index 4fc16ead7c..d6d3f76dcc 100644
--- a/packages/bundle/base/tests/base.spec.ts
+++ b/packages/bundle/base/tests/base.spec.ts
@@ -41,8 +41,14 @@ describe('dsh-base bundle', () => {
})
expect(rows.filter(row => row.id === 'subagent-codex')).toHaveLength(0)
expect(rows.filter(row => row.id === 'subagent-claude-code')).toHaveLength(0)
+ expect(rows.find(row => row.id === 'web')?.config).toMatchObject({ fetchProvider: 'http' })
+ expect(rows.find(row => row.id === 'web-fetch-http')).toBeDefined()
+ expect(rows.find(row => row.id === 'web-fetch-approval-policy')).toBeDefined()
+ expect(rows.find(row => row.id === 'tool-web')?.config).toMatchObject({ fetch: false })
expect(manifest.dependencies).not.toHaveProperty('@deepseek-ai/dsh-subagent-codex')
expect(manifest.dependencies).not.toHaveProperty('@deepseek-ai/dsh-subagent-claude-code')
+ expect(manifest.dependencies).toHaveProperty('@deepseek-ai/dsh-web-fetch-http')
+ expect(manifest.dependencies).toHaveProperty('@deepseek-ai/dsh-web-fetch-approval-policy')
})
it('gates each shell stack by platform with a symmetric disabled expression', () => {
diff --git a/packages/extensions/tool-cordis/src/api-catalog.ts b/packages/extensions/tool-cordis/src/api-catalog.ts
index 6f7d362aa8..f747d9c0dc 100644
--- a/packages/extensions/tool-cordis/src/api-catalog.ts
+++ b/packages/extensions/tool-cordis/src/api-catalog.ts
@@ -406,6 +406,12 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [
returns: 'the closed outcome; `\'allowed-once\'` is the only grant.',
throws: ['when no turn is open or either audit event fails before the session append commit point.'],
},
+ {
+ signature: 'effectivePolicy(session: Session): ApprovalPolicy',
+ description: 'The session\'s effective policy: its own `approval/policy` fold, else the configured default (the schema already defaulted an omitted policy to `\'ask\'`; the `??` only narrows the optional-input TYPE).',
+ parameters: [{ name: 'session', description: 'the exact accepted session whose policy applies.' }],
+ returns: 'the policy every ask for this session resolves under right now.',
+ },
{
signature: 'overrideOf(session: Session): ApprovalPolicy | undefined',
description: 'Read the session override without applying the configured default.',
diff --git a/packages/interaction/user-approval/README.i18n.yaml b/packages/interaction/user-approval/README.i18n.yaml
index ba340c5273..0b628bd02c 100644
--- a/packages/interaction/user-approval/README.i18n.yaml
+++ b/packages/interaction/user-approval/README.i18n.yaml
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/interaction/user-approval/README.md
-README.md: 0cf5d458863194e29f8c84168a6f089baabbf3d2
-README.zh.md: a93f9c17c89ea50622e354eb7729547660e877e2
+README.md: 75658be9f2c5222ab66f5f05d23cf3f0832b0618
+README.zh.md: b7ab3c0b6fc3f65e66d59cb610ec9c4502327d7b
diff --git a/packages/interaction/user-approval/README.md b/packages/interaction/user-approval/README.md
index 0cf5d45886..75658be9f2 100644
--- a/packages/interaction/user-approval/README.md
+++ b/packages/interaction/user-approval/README.md
@@ -8,7 +8,7 @@ Each request must belong to an open agent turn. The service appends a paired `ap
Answerers are `approval/request` waterfall listeners. Return an outcome to answer for an owned agent or call `next()` to delegate. Agent-scoped listeners receive only that agent's requests; compose one terminal answerer per deployment because sibling listener order is not a policy priority mechanism. The ACP automation bridge supplies one-shot machine decisions for sessions it owns.
-`ApprovalPolicy` is `'ask'` or `'never'`. The effective value is the last `approval/policy` event, falling back to config; `setApprovalPolicy()` is the write path. `'never'` rejects before interactive dispatch. Both policies contribute their complete current meaning to the cache-safe runtime-context snapshot.
+`ApprovalPolicy` is `'ask'` or `'never'`. The effective value is the last `approval/policy` event, falling back to config; `effectivePolicy()` is the request-time read and `setApprovalPolicy()` is the write path. `'never'` rejects before interactive dispatch. Both policies contribute their complete current meaning to the cache-safe runtime-context snapshot.
The tools pipeline routes `ask` decisions through this seam and fails closed when it is absent; the sandboxed bash tool also uses it for escalated retries. The ACP automation bridge answers calls for its own agents through the client's machine policy. Audit events remain log-only, so the model sees only the asking consumer's result. See the [approval-seam Agent Note](../../../.agents/notes/implemented/feature/2026-07-06-approval-seam.md) and [sandbox Agent Note](../../../.agents/notes/implemented/feature/2026-07-06-sandbox.md).
diff --git a/packages/interaction/user-approval/README.zh.md b/packages/interaction/user-approval/README.zh.md
index a93f9c17c8..b7ab3c0b6f 100644
--- a/packages/interaction/user-approval/README.zh.md
+++ b/packages/interaction/user-approval/README.zh.md
@@ -8,7 +8,7 @@
应答者是 `approval/request` waterfall(瀑布式事件)监听器。要回答其负责的 agent 请求,请返回一个结果;否则调用 `next()` 委托。限定到 agent 的监听器只接收该 agent 的请求;每项部署应当组合一个最终应答者,因为同级监听器的顺序不是策略优先级机制。ACP(Agent Client Protocol)自动化桥接层为其负责的会话提供一次性机器决定。
-`ApprovalPolicy` 为 `'ask'` 或 `'never'`。实际值取最后一条 `approval/policy` 事件,并回退到配置;`setApprovalPolicy()` 是写入路径。`'never'` 会在交互式分发之前拒绝请求。两种策略都会将各自完整的当前含义贡献给缓存安全的运行时上下文快照。
+`ApprovalPolicy` 为 `'ask'` 或 `'never'`。实际值取最后一条 `approval/policy` 事件,并回退到配置;`effectivePolicy()` 是逐请求读取路径,`setApprovalPolicy()` 是写入路径。`'never'` 会在交互式分发之前拒绝请求。两种策略都会将各自完整的当前含义贡献给缓存安全的运行时上下文快照。
工具流水线通过此 seam 路由 `ask` 决定,并在该 seam 缺失时以拒绝方式关闭;沙箱 bash 工具也会将它用于升权重试。ACP 自动化桥接层根据客户端的机器策略,回答其自有 agent 的调用。审计事件仍只写入日志,因此模型只会看到发起请求的消费方所返回的结果。详见[审批 seam Agent Note](../../../.agents/notes/implemented/feature/2026-07-06-approval-seam.zh.md)和[沙箱 Agent Note](../../../.agents/notes/implemented/feature/2026-07-06-sandbox.zh.md)。
diff --git a/packages/interaction/user-approval/src/index.ts b/packages/interaction/user-approval/src/index.ts
index 5d03b3186c..f33e4c4276 100644
--- a/packages/interaction/user-approval/src/index.ts
+++ b/packages/interaction/user-approval/src/index.ts
@@ -247,7 +247,7 @@ export class ApprovalService extends Service {
* @param session - the exact accepted session whose policy applies.
* @returns the policy every ask for this session resolves under right now.
*/
- private effectivePolicy(session: Session): ApprovalPolicy {
+ effectivePolicy(session: Session): ApprovalPolicy {
return this.overrideOf(session) ?? this.config.policy ?? 'ask'
}
diff --git a/packages/preset/agent-presets/presets/code/agent.cordis.yml b/packages/preset/agent-presets/presets/code/agent.cordis.yml
index 3333a980c0..9fa2b2fa00 100644
--- a/packages/preset/agent-presets/presets/code/agent.cordis.yml
+++ b/packages/preset/agent-presets/presets/code/agent.cordis.yml
@@ -249,7 +249,7 @@
- id: tool-web
name: '@deepseek-ai/dsh-tool-web'
config:
- fetch: false
+ fetch: true
searchTimeoutMs: 60000
# ── presentation ────────────────────────────────────────────────────────────
diff --git a/packages/preset/agent-presets/presets/cordis/agent.cordis.yml b/packages/preset/agent-presets/presets/cordis/agent.cordis.yml
index f23907c655..c7b2935137 100644
--- a/packages/preset/agent-presets/presets/cordis/agent.cordis.yml
+++ b/packages/preset/agent-presets/presets/cordis/agent.cordis.yml
@@ -236,7 +236,7 @@
- id: tool-web
name: '@deepseek-ai/dsh-tool-web'
config:
- fetch: false
+ fetch: true
searchTimeoutMs: 60000
# ── self-modification ───────────────────────────────────────────────────────
diff --git a/packages/preset/agent-presets/presets/standard/agent.cordis.yml b/packages/preset/agent-presets/presets/standard/agent.cordis.yml
index 5cb19e1e24..408c0184a0 100644
--- a/packages/preset/agent-presets/presets/standard/agent.cordis.yml
+++ b/packages/preset/agent-presets/presets/standard/agent.cordis.yml
@@ -248,5 +248,5 @@
- id: tool-web
name: '@deepseek-ai/dsh-tool-web'
config:
- fetch: false
+ fetch: true
searchTimeoutMs: 60000
diff --git a/packages/preset/agent-presets/tests/shipped-root.spec.ts b/packages/preset/agent-presets/tests/shipped-root.spec.ts
index 30b974aae8..9ecc8546d4 100644
--- a/packages/preset/agent-presets/tests/shipped-root.spec.ts
+++ b/packages/preset/agent-presets/tests/shipped-root.spec.ts
@@ -9,13 +9,14 @@
* suite: the derived writable root is resolved in the constructor.
*/
-import { mkdtemp } from 'node:fs/promises'
+import { mkdtemp, readFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { dirname, join } from 'node:path'
import { fileURLToPath, pathToFileURL } from 'node:url'
import { Context } from '@deepseek-ai/cordis'
import Loader from '@deepseek-ai/cordis-plugin-loader'
-import Include from '@deepseek-ai/cordis-plugin-include'
+import Include, { entryListSchema } from '@deepseek-ai/cordis-plugin-include'
+import * as yaml from 'js-yaml'
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import AgentPresets, { SHIPPED_PRESET_ROOT, type Config } from '@deepseek-ai/dsh-agent-presets'
@@ -87,4 +88,15 @@ describe('the shipped preset root', () => {
const minimal = (await ctx.agentPresets.list()).find(preset => preset.id === 'minimal')
expect(minimal?.path.startsWith(SYSTEM_ROOT)).toBe(true)
})
+
+ it('enables web_fetch in each tool-bearing Web app preset', async () => {
+ for (const id of ['cordis', 'code', 'standard']) {
+ const source = await readFile(join(SHIPPED_PRESET_ROOT, id, 'agent.cordis.yml'), 'utf8')
+ const entries = yaml.load(source, { schema: entryListSchema })
+ if (!Array.isArray(entries)) throw new TypeError(`${id} preset must contain a Cordis entry list`)
+ const toolWeb = entries.find((entry): entry is { id: string; config: { fetch?: boolean } } =>
+ typeof entry === 'object' && entry !== null && entry.id === 'tool-web')
+ expect(toolWeb?.config.fetch, id).toBe(true)
+ }
+ })
})
diff --git a/packages/web/README.i18n.yaml b/packages/web/README.i18n.yaml
index 06a41eba22..d26c59f345 100644
--- a/packages/web/README.i18n.yaml
+++ b/packages/web/README.i18n.yaml
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/web/README.md
-README.md: fc37d7cdead59138db149b5a86f0a0c031d40037
-README.zh.md: 40a64e09b85b0655739f73abe6388d6cc2b40a0d
+README.md: 2475cb7f6d23e2b189915d93ad6eaa4ac459abb1
+README.zh.md: 14ee4354ed02b57b2a56041c14d2bde51c1eb080
diff --git a/packages/web/README.md b/packages/web/README.md
index fc37d7cdea..2475cb7f6d 100644
--- a/packages/web/README.md
+++ b/packages/web/README.md
@@ -11,8 +11,9 @@ This family provides provider-neutral web search and fetch operations plus the m
| [`web-search-perplexity/`](web-search-perplexity/README.md) | Provides web search through Perplexity | registers on `ctx.web` |
| [`web-search-deepseek/`](web-search-deepseek/README.md) | Provides native DeepSeek web search | registers on `ctx.web` |
| [`web-fetch-http/`](web-fetch-http/README.md) | Fetches public HTTP and HTTPS resources | registers on `ctx.web` |
+| [`web-fetch-approval-policy/`](web-fetch-approval-policy/README.md) | Applies sandbox- and approval-aware one-shot fetch permission | listens on `tools/pre-execute` |
| [`tool-web/`](tool-web/README.md) | Exposes web search and fetch to the model | registers on `ctx.tools` |
The [web capability decision](../../.agents/notes/implemented/architecture/2026-06-24-web-capability-seam.md) records why search and fetch share one provider-selection service.
-The subsystem reference — search/fetch requests and results, availability, `WebError` — is [docs/subsystems/web.md](../../docs/subsystems/web.md); rationale (including deferred SSRF protection) in the [web capability seam Agent Note](../../.agents/notes/implemented/architecture/2026-06-24-web-capability-seam.md).
+The subsystem reference — search/fetch requests and results, availability, `WebError`, and fetch permission — is [docs/subsystems/web.md](../../docs/subsystems/web.md); rationale is in the [web capability seam Agent Note](../../.agents/notes/implemented/architecture/2026-06-24-web-capability-seam.md).
diff --git a/packages/web/README.zh.md b/packages/web/README.zh.md
index 40a64e09b8..14ee4354ed 100644
--- a/packages/web/README.zh.md
+++ b/packages/web/README.zh.md
@@ -11,8 +11,9 @@
| [`web-search-perplexity/`](web-search-perplexity/README.zh.md) | 通过 Perplexity 提供 web 搜索 | 注册到 `ctx.web` |
| [`web-search-deepseek/`](web-search-deepseek/README.zh.md) | 提供 DeepSeek 原生 web 搜索 | 注册到 `ctx.web` |
| [`web-fetch-http/`](web-fetch-http/README.zh.md) | 抓取公共 HTTP 和 HTTPS 资源 | 注册到 `ctx.web` |
+| [`web-fetch-approval-policy/`](web-fetch-approval-policy/README.zh.md) | 按 sandbox 与审批策略实施单次抓取权限 | 监听 `tools/pre-execute` |
| [`tool-web/`](tool-web/README.zh.md) | 向模型公开 web 搜索和抓取 | 注册到 `ctx.tools` |
[web 能力决策](../../.agents/notes/implemented/architecture/2026-06-24-web-capability-seam.zh.md)记录了搜索和抓取共用一项提供方选择服务的原因。
-子系统参考——搜索/抓取请求与结果、可用性、`WebError`——见 [docs/subsystems/web.md](../../docs/subsystems/web.zh.md);依据(含延后的 SSRF 防护)见 [web 能力 seam Agent Note](../../.agents/notes/implemented/architecture/2026-06-24-web-capability-seam.zh.md)。
+子系统参考——搜索/抓取请求与结果、可用性、`WebError` 和抓取权限——见 [docs/subsystems/web.md](../../docs/subsystems/web.zh.md);依据见 [web 能力 seam Agent Note](../../.agents/notes/implemented/architecture/2026-06-24-web-capability-seam.zh.md)。
diff --git a/packages/web/web-fetch-approval-policy/README.i18n.yaml b/packages/web/web-fetch-approval-policy/README.i18n.yaml
new file mode 100644
index 0000000000..3d3f2268be
--- /dev/null
+++ b/packages/web/web-fetch-approval-policy/README.i18n.yaml
@@ -0,0 +1,6 @@
+# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
+# side as of the last confirmed-consistent state. Both languages carry equal authority;
+# after editing either side, bring the other along and re-record with:
+# pnpm run verify-translation-pairing --write packages/web/web-fetch-approval-policy/README.md
+README.md: 3e8e39586fff655245481275f83f44c8450feb62
+README.zh.md: ec0d6926beb585c4ca480d73f58ad3392b8d79fb
diff --git a/packages/web/web-fetch-approval-policy/README.md b/packages/web/web-fetch-approval-policy/README.md
new file mode 100644
index 0000000000..3e8e39586f
--- /dev/null
+++ b/packages/web/web-fetch-approval-policy/README.md
@@ -0,0 +1,36 @@
+# @deepseek-ai/dsh-web-fetch-approval-policy
+
+English | [中文](README.zh.md)
+
+A `tools/pre-execute` policy for one-shot `web_fetch` permission decisions. It combines the calling session's sandbox mode with its approval policy and uses [`dsh-web-fetch-http`](../web-fetch-http/README.md) to reject non-public destinations before asking the user.
+
+## Decisions
+
+| Sandbox mode | Approval policy | `web_fetch` decision |
+|---|---|---|
+| `danger-full-access` | any | Delegate without asking. |
+| `read-only` or `workspace-write` | `ask` | Resolve and require a public destination, then request one-shot approval. |
+| `read-only` or `workspace-write` | `never` | Deny without DNS or a prompt. |
+
+An agentless restricted call is denied because it has no session for policy lookup or approval audit. Malformed arguments delegate to the tool's own schema validation. This plugin never grants a call itself: unrestricted calls delegate to later policies, and restricted calls preserve any downstream `ask` or `deny` result.
+
+The approval request carries the exact tool `callId` and a reason containing the complete normalized URL, sandbox mode, and single-call scope. Only the existing `allowed-once` outcome permits execution; rejection, cancellation, or an unavailable answerer fails closed. Session/domain persistence and permanent grants are outside this package.
+
+## SSRF separation
+
+Permission preflight parses the URL and resolves its complete address set before displaying a prompt. A non-public destination is always rejected and cannot be authorized through `allowed-once`.
+
+Preflight is not a network authorization token. The HTTP provider resolves the hostname again immediately before each connection, rejects any non-public answer, pins the validated addresses, and repeats the check for every followed same-origin redirect. Cross-origin redirects require a new `web_fetch` call and a new permission decision.
+
+## Model Experience
+
+Indirectly, through `dsh-tools` and `dsh-user-approval`, which pause restricted calls for one-shot approval and return denial through the existing tool-error path.
+
+#### KV Cache effect
+
+None. The policy changes execution, not model-visible schemas or prompt text.
+
+## Known Limitations and Deferred Work
+
+- There is no session- or domain-scoped persistent grant.
+- `plan` is collaboration state, not a sandbox mode. Products that want plan work to use restricted web access compose it with `read-only` or `workspace-write` and approval policy `ask`.
diff --git a/packages/web/web-fetch-approval-policy/README.zh.md b/packages/web/web-fetch-approval-policy/README.zh.md
new file mode 100644
index 0000000000..ec0d6926be
--- /dev/null
+++ b/packages/web/web-fetch-approval-policy/README.zh.md
@@ -0,0 +1,36 @@
+# @deepseek-ai/dsh-web-fetch-approval-policy
+
+[English](README.md) | 中文
+
+一个为 `web_fetch` 作单次权限决策的 `tools/pre-execute` 策略。它组合调用会话的 sandbox mode 与审批策略,并使用 [`dsh-web-fetch-http`](../web-fetch-http/README.zh.md) 在询问用户前拒绝非公开目的地址。
+
+## 决策
+
+| Sandbox mode | 审批策略 | `web_fetch` 决策 |
+|---|---|---|
+| `danger-full-access` | 任意 | 不询问并委托后续策略。 |
+| `read-only` 或 `workspace-write` | `ask` | 解析并要求目的地址公开,然后请求单次审批。 |
+| `read-only` 或 `workspace-write` | `never` | 不进行 DNS 解析或提示,直接拒绝。 |
+
+受限模式下的无 agent 调用会被拒绝,因为它没有可用于策略查询和审批审计的 session。格式错误的参数交给工具自身的 schema 校验。此插件从不自行授予调用:不受限的调用会委托后续策略,受限调用也会保留下游的 `ask` 或 `deny` 结果。
+
+审批请求携带精确的工具 `callId`,其 reason 包含完整的标准化 URL、sandbox mode 与单次调用范围。只有现有的 `allowed-once` 结果允许执行;拒绝、取消或无可用回答方都会 fail closed。按 session/域名持久化和永久授权不属于此包。
+
+## SSRF 分离
+
+权限预检会在显示提示前解析 URL 及其完整地址集合。非公开目的地址始终被拒绝,不能通过 `allowed-once` 授权。
+
+预检不是网络授权令牌。HTTP 提供方会在每次实际连接前重新解析 hostname,拒绝任何非公开解析结果,固定已验证地址,并对每个被跟随的同源重定向重复校验。跨源重定向需要新的 `web_fetch` 调用和新的权限决策。
+
+## 模型体验
+
+通过 `dsh-tools` 与 `dsh-user-approval` 间接影响;它们让受限调用等待单次审批,并通过既有工具错误路径返回拒绝结果。
+
+#### KV Cache 影响
+
+无。该策略改变执行,不改变面向模型的 schema 或提示词文本。
+
+## 已知限制与暂缓事项
+
+- 不存在按 session 或域名限定的持久授权。
+- `plan` 是协作状态,不是 sandbox mode。希望 plan 工作采用受限 Web 访问的产品,应将其与 `read-only` 或 `workspace-write` 以及审批策略 `ask` 组合。
diff --git a/packages/web/web-fetch-approval-policy/package.json b/packages/web/web-fetch-approval-policy/package.json
new file mode 100644
index 0000000000..77e84c1c7b
--- /dev/null
+++ b/packages/web/web-fetch-approval-policy/package.json
@@ -0,0 +1,53 @@
+{
+ "name": "@deepseek-ai/dsh-web-fetch-approval-policy",
+ "description": "Sandbox- and approval-aware one-shot permission policy for the DeepSeek Harness web_fetch tool",
+ "version": "0.1.1-rc.2",
+ "publishConfig": {
+ "access": "public"
+ },
+ "repository": {
+ "type": "git",
+ "url": "git+https://github.com/deepseek-ai/deepseek-harness.git",
+ "directory": "packages/web/web-fetch-approval-policy"
+ },
+ "type": "module",
+ "main": "lib/index.js",
+ "types": "lib/types/index.d.ts",
+ "exports": {
+ ".": {
+ "types": "./lib/types/index.d.ts",
+ "default": "./lib/index.js"
+ },
+ "./invariant": {
+ "types": "./lib/types/invariant.d.ts",
+ "default": "./lib/invariant.js"
+ },
+ "./src/*": "./src/*",
+ "./package.json": "./package.json"
+ },
+ "files": [
+ "lib/index.js",
+ "lib/invariant.js",
+ "lib/types/**/*.d.ts"
+ ],
+ "license": "MIT",
+ "peerDependencies": {
+ "@deepseek-ai/cordis": "workspace:^",
+ "@deepseek-ai/dsh-invariants": "workspace:^",
+ "@deepseek-ai/dsh-sandbox-policy": "workspace:^",
+ "@deepseek-ai/dsh-tools": "workspace:^",
+ "@deepseek-ai/dsh-user-approval": "workspace:^",
+ "@deepseek-ai/dsh-web-fetch-http": "workspace:^"
+ },
+ "devDependencies": {
+ "@deepseek-ai/cordis": "workspace:^",
+ "@deepseek-ai/dsh-agent": "workspace:^",
+ "@deepseek-ai/dsh-invariants": "workspace:^",
+ "@deepseek-ai/dsh-llm": "workspace:^",
+ "@deepseek-ai/dsh-sandbox-policy": "workspace:^",
+ "@deepseek-ai/dsh-system-prompt": "workspace:^",
+ "@deepseek-ai/dsh-tools": "workspace:^",
+ "@deepseek-ai/dsh-user-approval": "workspace:^",
+ "@deepseek-ai/dsh-web-fetch-http": "workspace:^"
+ }
+}
diff --git a/packages/web/web-fetch-approval-policy/src/index.ts b/packages/web/web-fetch-approval-policy/src/index.ts
new file mode 100644
index 0000000000..13d372953e
--- /dev/null
+++ b/packages/web/web-fetch-approval-policy/src/index.ts
@@ -0,0 +1,60 @@
+/**
+ * Per-call permission policy for the `web_fetch` tool. Restricted sandbox
+ * modes require one-shot user approval after a public-address preflight;
+ * danger-full-access delegates without asking. The HTTP provider independently
+ * repeats resolution and pins the validated addresses for the actual request.
+ *
+ * @module @deepseek-ai/dsh-web-fetch-approval-policy
+ */
+
+import type { Context } from '@deepseek-ai/cordis'
+import type { PreToolDecision, ToolExecution } from '@deepseek-ai/dsh-tools'
+import type {} from '@deepseek-ai/dsh-sandbox-policy'
+import type {} from '@deepseek-ai/dsh-user-approval'
+import { preflightPublicFetchUrl } from '@deepseek-ai/dsh-web-fetch-http'
+
+/** Cordis plugin name used by loader diagnostics. */
+export const name = 'web-fetch-approval-policy'
+
+/** Services used to decide each `web_fetch` execution. */
+export const inject = ['tools', 'sandboxPolicy', 'approval']
+
+/** Return the URL argument that can reach `web_fetch`, or undefined for a call its own schema will reject. */
+function fetchUrlOf(exec: ToolExecution): string | undefined {
+ const args = exec.arguments
+ if (typeof args !== 'object' || args === null || !('url' in args)) return undefined
+ return typeof args.url === 'string' ? args.url : undefined
+}
+
+/** Register sandbox- and approval-aware one-shot permission policy for `web_fetch`. */
+export function apply(ctx: Context): void {
+ ctx.on('tools/pre-execute', async (exec, next): Promise => {
+ if (exec.name !== 'web_fetch') return next()
+
+ const agent = exec.agent
+ if (agent === undefined) {
+ return { kind: 'deny', reason: 'web_fetch requires an agent-scoped permission decision' }
+ }
+
+ const mode = ctx.sandboxPolicy.resolve({ session: agent.session }).mode
+ if (mode === 'danger-full-access') return next()
+
+ if (ctx.approval.effectivePolicy(agent.session) === 'never') {
+ return {
+ kind: 'deny',
+ reason: `web_fetch is not pre-approved in ${mode} mode and approval prompts are disabled`,
+ }
+ }
+
+ const rawUrl = fetchUrlOf(exec)
+ if (rawUrl === undefined) return next()
+ const url = await preflightPublicFetchUrl(rawUrl, exec.signal)
+
+ const downstream = await next()
+ if (downstream.kind !== 'allow') return downstream
+ return {
+ kind: 'ask',
+ reason: `Allow web_fetch to access ${url.toString()} in ${mode} mode? This permission applies only to this tool call.`,
+ }
+ })
+}
diff --git a/packages/web/web-fetch-approval-policy/src/invariant.ts b/packages/web/web-fetch-approval-policy/src/invariant.ts
new file mode 100644
index 0000000000..922503cd00
--- /dev/null
+++ b/packages/web/web-fetch-approval-policy/src/invariant.ts
@@ -0,0 +1,27 @@
+/**
+ * Package-owned invariant companion for `@deepseek-ai/dsh-web-fetch-approval-policy`.
+ * @module @deepseek-ai/dsh-web-fetch-approval-policy/invariant
+ */
+
+/* jscpd:ignore-start */
+import type { Context } from '@deepseek-ai/cordis'
+import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants'
+
+const PACKAGE_NAME = '@deepseek-ai/dsh-web-fetch-approval-policy'
+
+/** Cordis companion plugin name. */
+export const name = 'web-fetch-approval-policy-invariant'
+/** Service required before the companion can reserve package ownership. */
+export const inject = ['invariants']
+
+/** No runtime invariant: the tool pipeline owns approval dispatch and audit relationships. */
+const install: InvariantInstaller = () => {}
+
+/**
+ * Register this package's invariant companion.
+ * @param ctx - Cordis context carrying the invariant service.
+ * @returns the installed registration's disposer after setup succeeds.
+ */
+export const apply = (ctx: Context): Promise<() => void> =>
+ Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install))
+/* jscpd:ignore-end */
diff --git a/packages/web/web-fetch-approval-policy/tests/approval-policy.spec.ts b/packages/web/web-fetch-approval-policy/tests/approval-policy.spec.ts
new file mode 100644
index 0000000000..1c5972ef50
--- /dev/null
+++ b/packages/web/web-fetch-approval-policy/tests/approval-policy.spec.ts
@@ -0,0 +1,230 @@
+import { afterEach, describe, expect, it, vi } from 'vitest'
+import { Context } from '@deepseek-ai/cordis'
+import type { Agent } from '@deepseek-ai/dsh-agent'
+import { CallId } from '@deepseek-ai/dsh-llm'
+import SandboxPolicyService from '@deepseek-ai/dsh-sandbox-policy'
+import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
+import ToolRuntime, { defineTool, type PreToolDecision } from '@deepseek-ai/dsh-tools'
+import ApprovalService, { type ApprovalOutcome, type ApprovalRequest } from '@deepseek-ai/dsh-user-approval'
+import * as approvalPolicy from '../src/index.ts'
+import { publicHttpNetwork } from '../../web-fetch-http/src/network.ts'
+
+const signal = new AbortController().signal
+
+afterEach(() => {
+ vi.restoreAllMocks()
+})
+
+function fakeAgent(): Agent {
+ return {
+ session: {
+ header: { cwd: process.cwd() },
+ events: [{ type: 'turn/start' }],
+ append: () => ({}),
+ },
+ } as unknown as Agent
+}
+
+async function setup(
+ mode: 'read-only' | 'workspace-write' | 'danger-full-access' = 'workspace-write',
+ approval: 'ask' | 'never' = 'ask',
+): Promise<{ ctx: Context; calls: { count: number } }> {
+ const ctx = new Context()
+ await ctx.plugin(SystemPrompt)
+ await ctx.plugin(ToolRuntime)
+ await ctx.plugin(SandboxPolicyService, { mode })
+ await ctx.plugin(ApprovalService, { policy: approval })
+ await ctx.plugin(approvalPolicy)
+ const calls = { count: 0 }
+ ctx.tools.register(defineTool({
+ name: 'web_fetch',
+ description: 'test web fetch',
+ parameters: { url: { type: 'string', required: true } },
+ output: {
+ schema: { type: 'string' },
+ render: (_args, value) => [{ type: 'text', text: value }],
+ },
+ async execute() {
+ calls.count += 1
+ return 'fetched'
+ },
+ }))
+ ctx.tools.register(defineTool({
+ name: 'echo',
+ description: 'unrelated test tool',
+ parameters: {},
+ output: {
+ schema: { type: 'string' },
+ render: (_args, value) => [{ type: 'text', text: value }],
+ },
+ async execute() { return 'echoed' },
+ }))
+ return { ctx, calls }
+}
+
+function executeFetch(ctx: Context, agent: Agent | null = fakeAgent(), arguments_: unknown = { url: 'https://example.com/path?q=1' }) {
+ return ctx.tools.execute({
+ callId: CallId('fetch-call'),
+ name: 'web_fetch',
+ arguments: arguments_,
+ ...agent === null ? {} : { agent },
+ signal,
+ })
+}
+
+describe('web_fetch approval policy', () => {
+ it.each(['read-only', 'workspace-write'] as const)('asks once after public-address preflight in %s mode', async (mode) => {
+ const { ctx, calls } = await setup(mode)
+ const resolve = vi.spyOn(publicHttpNetwork, 'resolve').mockResolvedValue([{ address: '8.8.8.8', family: 4 }])
+ const requests: ApprovalRequest[] = []
+ ctx.on('approval/request', (request) => {
+ requests.push(request)
+ return Promise.resolve('allowed-once')
+ })
+
+ await expect(executeFetch(ctx)).resolves.toMatchObject({ isError: false, value: 'fetched' })
+
+ expect(resolve).toHaveBeenCalledWith('example.com', signal)
+ expect(requests).toHaveLength(1)
+ expect(requests[0]).toMatchObject({
+ toolName: 'web_fetch',
+ callId: 'fetch-call',
+ reason: `Allow web_fetch to access https://example.com/path?q=1 in ${mode} mode? This permission applies only to this tool call.`,
+ })
+ expect(calls.count).toBe(1)
+ resolve.mockRestore()
+ })
+
+ it('does not dispatch when the user rejects the one-shot request', async () => {
+ const { ctx, calls } = await setup()
+ vi.spyOn(publicHttpNetwork, 'resolve').mockResolvedValue([{ address: '8.8.8.8', family: 4 }])
+ ctx.on('approval/request', () => Promise.resolve('rejected'))
+
+ await expect(executeFetch(ctx)).resolves.toMatchObject({
+ isError: true,
+ content: [{ type: 'text', text: 'Error: the user rejected tool "web_fetch"' }],
+ })
+ expect(calls.count).toBe(0)
+ })
+
+ it('delegates danger-full-access without DNS preflight or approval', async () => {
+ const { ctx, calls } = await setup('danger-full-access')
+ const resolve = vi.spyOn(publicHttpNetwork, 'resolve')
+ const approval = vi.fn(() => Promise.resolve('rejected'))
+ ctx.on('approval/request', approval)
+
+ await expect(executeFetch(ctx)).resolves.toMatchObject({ isError: false, value: 'fetched' })
+ expect(resolve).not.toHaveBeenCalled()
+ expect(approval).not.toHaveBeenCalled()
+ expect(calls.count).toBe(1)
+ })
+
+ it('fails closed under approval never without DNS or a prompt', async () => {
+ const { ctx, calls } = await setup('workspace-write', 'never')
+ const resolve = vi.spyOn(publicHttpNetwork, 'resolve')
+ const approval = vi.fn(() => Promise.resolve('allowed-once'))
+ ctx.on('approval/request', approval)
+
+ await expect(executeFetch(ctx)).resolves.toMatchObject({
+ isError: true,
+ content: [{ type: 'text', text: 'Error: web_fetch is not pre-approved in workspace-write mode and approval prompts are disabled' }],
+ })
+ expect(resolve).not.toHaveBeenCalled()
+ expect(approval).not.toHaveBeenCalled()
+ expect(calls.count).toBe(0)
+ })
+
+ it('rejects a non-public destination before presenting approval', async () => {
+ const { ctx, calls } = await setup()
+ const approval = vi.fn(() => Promise.resolve('allowed-once'))
+ ctx.on('approval/request', approval)
+
+ const result = await executeFetch(ctx, fakeAgent(), { url: 'http://127.0.0.1/private' })
+ expect(result).toMatchObject({
+ isError: true,
+ error: { info: { code: 'WEB_BLOCKED_URL' } },
+ })
+ expect(approval).not.toHaveBeenCalled()
+ expect(calls.count).toBe(0)
+ })
+
+ it('preserves a downstream denial after preflight', async () => {
+ const { ctx, calls } = await setup()
+ vi.spyOn(publicHttpNetwork, 'resolve').mockResolvedValue([{ address: '8.8.8.8', family: 4 }])
+ const approval = vi.fn(() => Promise.resolve('allowed-once'))
+ ctx.on('approval/request', approval)
+ ctx.on('tools/pre-execute', async (_exec, _next): Promise => ({
+ kind: 'deny',
+ reason: 'denied downstream',
+ }))
+
+ await expect(executeFetch(ctx)).resolves.toMatchObject({
+ isError: true,
+ content: [{ type: 'text', text: 'Error: denied downstream' }],
+ })
+ expect(approval).not.toHaveBeenCalled()
+ expect(calls.count).toBe(0)
+ })
+
+ it('delegates malformed arguments to the tool schema without DNS or approval', async () => {
+ const { ctx, calls } = await setup()
+ const resolve = vi.spyOn(publicHttpNetwork, 'resolve')
+ const approval = vi.fn(() => Promise.resolve('allowed-once'))
+ ctx.on('approval/request', approval)
+
+ await expect(executeFetch(ctx, fakeAgent(), { url: 7 })).resolves.toMatchObject({ isError: true })
+ await expect(executeFetch(ctx, fakeAgent(), null)).resolves.toMatchObject({ isError: true })
+ await expect(executeFetch(ctx, fakeAgent(), {})).resolves.toMatchObject({ isError: true })
+ expect(resolve).not.toHaveBeenCalled()
+ expect(approval).not.toHaveBeenCalled()
+ expect(calls.count).toBe(0)
+ })
+
+ it('denies an agentless restricted call without DNS', async () => {
+ const { ctx, calls } = await setup()
+ const resolve = vi.spyOn(publicHttpNetwork, 'resolve')
+
+ await expect(executeFetch(ctx, null)).resolves.toMatchObject({
+ isError: true,
+ content: [{ type: 'text', text: 'Error: web_fetch requires an agent-scoped permission decision' }],
+ })
+ expect(resolve).not.toHaveBeenCalled()
+ expect(calls.count).toBe(0)
+ })
+
+ it('maps resolver and aborted preflight failures to structured web errors', async () => {
+ const { ctx } = await setup()
+ const resolve = vi.spyOn(publicHttpNetwork, 'resolve').mockRejectedValueOnce(new Error('dns failed'))
+
+ await expect(executeFetch(ctx)).resolves.toMatchObject({
+ isError: true,
+ error: { info: { code: 'WEB_PROVIDER_ERROR' } },
+ })
+
+ const controller = new AbortController()
+ resolve.mockImplementationOnce(async () => {
+ controller.abort('stop')
+ throw new Error('aborted')
+ })
+ await expect(ctx.tools.execute({
+ callId: CallId('aborted-preflight'),
+ name: 'web_fetch',
+ arguments: { url: 'https://example.com/' },
+ agent: fakeAgent(),
+ signal: controller.signal,
+ })).resolves.toMatchObject({
+ isError: true,
+ error: { info: { code: 'WEB_ABORTED' } },
+ })
+ })
+
+ it('ignores unrelated tools', async () => {
+ const { ctx } = await setup()
+ const resolve = vi.spyOn(publicHttpNetwork, 'resolve')
+
+ await expect(ctx.tools.execute({
+ callId: CallId('echo-call'), name: 'echo', arguments: {}, agent: fakeAgent(), signal,
+ })).resolves.toMatchObject({ isError: false, value: 'echoed' })
+ expect(resolve).not.toHaveBeenCalled()
+ })
+})
diff --git a/packages/web/web-fetch-approval-policy/tsconfig.json b/packages/web/web-fetch-approval-policy/tsconfig.json
new file mode 100644
index 0000000000..17cfe6fed1
--- /dev/null
+++ b/packages/web/web-fetch-approval-policy/tsconfig.json
@@ -0,0 +1,30 @@
+{
+ "extends": "../../../tsconfig.base.json",
+ "compilerOptions": {
+ "rootDir": "src",
+ "outDir": "lib/types"
+ },
+ "include": [
+ "src"
+ ],
+ "references": [
+ {
+ "path": "../../../vendor/cordis"
+ },
+ {
+ "path": "../../core/tools"
+ },
+ {
+ "path": "../../interaction/user-approval"
+ },
+ {
+ "path": "../../runtime-diagnostics/invariants"
+ },
+ {
+ "path": "../../sandbox/sandbox-policy"
+ },
+ {
+ "path": "../web-fetch-http"
+ }
+ ]
+}
diff --git a/packages/web/web-fetch-http/README.i18n.yaml b/packages/web/web-fetch-http/README.i18n.yaml
index ae32a21bfa..5150a4d6c2 100644
--- a/packages/web/web-fetch-http/README.i18n.yaml
+++ b/packages/web/web-fetch-http/README.i18n.yaml
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/web/web-fetch-http/README.md
-README.md: 13ff12861b8573a4d60b3300aa33f9b47d7ab7da
-README.zh.md: 1670a8a2855effdd93216e7f1b952a13fa5d0516
+README.md: 271ca640d421cbe6fb92273273afd4c88bf53f1b
+README.zh.md: cf8c3d12cbe145cc2b499275edba02bc62845dc2
diff --git a/packages/web/web-fetch-http/README.md b/packages/web/web-fetch-http/README.md
index 13ff12861b..271ca640d4 100644
--- a/packages/web/web-fetch-http/README.md
+++ b/packages/web/web-fetch-http/README.md
@@ -4,7 +4,7 @@ English | [中文](README.zh.md)
An anonymous public HTTP(S) `WebFetchProvider` for the harness [web capability seam](../web/README.md) (`ctx.web`). It retrieves a concrete URL and returns a status code plus bounded decoded content.
-This is an **implementation** package: it registers a provider into `ctx.web`, it does not own the key and it does not register a model-facing tool. It is a function/namespace plugin (`inject: ['web']`).
+This is an **implementation** package: it registers a provider into `ctx.web`, it does not own the key and it does not register a model-facing tool. It is a function/namespace plugin (`inject: ['web']`). The separate [`dsh-web-fetch-approval-policy`](../web-fetch-approval-policy/README.md) plugin consumes its public-destination preflight before asking users about restricted `web_fetch` calls.
## Responsibility split
@@ -24,6 +24,8 @@ A shipping web-tool deployment sets the provider backstop above the tool budget,
- Sends an explicit product `User-Agent`, never a browser disguise.
- Rejects unsupported (e.g. binary) content types with `WEB_UNSUPPORTED_CONTENT_TYPE`.
+`preflightPublicFetchUrl()` exposes the URL syntax and public-address check to permission consumers. Its result is advisory, not authorization: the provider always resolves again and pins the actual connection, so DNS changes between approval and execution cannot bypass the destination policy.
+
## Config
| Key | Default | Meaning |
diff --git a/packages/web/web-fetch-http/README.zh.md b/packages/web/web-fetch-http/README.zh.md
index 1670a8a285..cf8c3d12cb 100644
--- a/packages/web/web-fetch-http/README.zh.md
+++ b/packages/web/web-fetch-http/README.zh.md
@@ -4,7 +4,7 @@
一个匿名公共 HTTP(S) `WebFetchProvider`,用于 harness [web 能力 seam](../web/README.zh.md)(`ctx.web`)。它获取具体 URL,返回状态码和长度受限的解码内容。
-这是一个**实现**包:它向 `ctx.web` 注册提供方,不拥有该键,也不注册面向模型的工具。它是函数/命名空间插件(`inject: ['web']`)。
+这是一个**实现**包:它向 `ctx.web` 注册提供方,不拥有该键,也不注册面向模型的工具。它是函数/命名空间插件(`inject: ['web']`)。独立的 [`dsh-web-fetch-approval-policy`](../web-fetch-approval-policy/README.zh.md) 插件会在询问用户是否允许受限的 `web_fetch` 调用前,使用此包的公开目的地址预检。
## 职责拆分
@@ -24,6 +24,8 @@
- 发送显式的产品 `User-Agent`,绝不伪装成浏览器。
- 不受支持的内容类型(例如二进制)以 `WEB_UNSUPPORTED_CONTENT_TYPE` 拒绝。
+`preflightPublicFetchUrl()` 向权限消费方暴露 URL 语法和公开地址校验。其结果只供预检,不构成授权:提供方始终会重新解析并固定实际连接,因此从审批到执行之间的 DNS 变化无法绕过目的地址策略。
+
## 配置
| 配置键 | 默认值 | 含义 |
diff --git a/packages/web/web-fetch-http/src/index.ts b/packages/web/web-fetch-http/src/index.ts
index a3ce03c9b2..cd0334f1fb 100644
--- a/packages/web/web-fetch-http/src/index.ts
+++ b/packages/web/web-fetch-http/src/index.ts
@@ -18,6 +18,7 @@ export {
HttpFetchProvider,
} from './provider.ts'
export type { HttpFetchLimits } from './provider.ts'
+export { preflightPublicFetchUrl } from './preflight.ts'
/** Default `User-Agent`: an explicit product agent, never a browser disguise. */
export const DEFAULT_USER_AGENT = 'deepseek-harness/0.0.1 (+https://github.com/deepseek-ai)'
diff --git a/packages/web/web-fetch-http/src/policy.ts b/packages/web/web-fetch-http/src/policy.ts
index dcd5239f88..4a8b91000b 100644
--- a/packages/web/web-fetch-http/src/policy.ts
+++ b/packages/web/web-fetch-http/src/policy.ts
@@ -12,19 +12,14 @@ import { WebError } from '@deepseek-ai/dsh-web'
export type FetchableKind = 'html' | 'text'
/**
- * Validate a request URL against the basic transport hygiene the provider
- * enforces before any network access: http(s) only, no embedded credentials,
- * bounded length. Returns the parsed `URL`. Throws {@link WebError} otherwise.
- * Public-address resolution and connection pinning run after this syntax check.
+ * Parse a request URL and enforce network-independent transport restrictions:
+ * HTTP(S) only and no embedded credentials. Both permission preflight and the
+ * provider use this function before resolving a destination.
*
* @param input - the raw URL string from the fetch request.
- * @param maxUrlLength - inclusive upper bound on `input`'s length.
* @returns the parsed `URL`.
*/
-export function validateFetchUrl(input: string, maxUrlLength: number): URL {
- if (input.length > maxUrlLength) {
- throw new WebError(`URL exceeds the maximum length of ${maxUrlLength}`, 'WEB_INVALID_URL')
- }
+export function parseFetchUrl(input: string): URL {
let url: URL
try {
url = new URL(input)
@@ -40,6 +35,22 @@ export function validateFetchUrl(input: string, maxUrlLength: number): URL {
return url
}
+/**
+ * Validate a request URL against the provider's complete pre-network policy:
+ * bounded length plus the restrictions enforced by {@link parseFetchUrl}.
+ * Public-address resolution and connection pinning run after this check.
+ *
+ * @param input - the raw URL string from the fetch request.
+ * @param maxUrlLength - inclusive upper bound on `input`'s length.
+ * @returns the parsed `URL`.
+ */
+export function validateFetchUrl(input: string, maxUrlLength: number): URL {
+ if (input.length > maxUrlLength) {
+ throw new WebError(`URL exceeds the maximum length of ${maxUrlLength}`, 'WEB_INVALID_URL')
+ }
+ return parseFetchUrl(input)
+}
+
/**
* Two URLs are same-origin when scheme, hostname, and port match. A redirect
* that crosses origins is refused so each new origin requires a fresh tool call
diff --git a/packages/web/web-fetch-http/src/preflight.ts b/packages/web/web-fetch-http/src/preflight.ts
new file mode 100644
index 0000000000..165f469692
--- /dev/null
+++ b/packages/web/web-fetch-http/src/preflight.ts
@@ -0,0 +1,32 @@
+/**
+ * Public-destination preflight shared with permission consumers. This check is
+ * advisory: the provider independently resolves and pins the actual request.
+ *
+ * @module @deepseek-ai/dsh-web-fetch-http/preflight
+ */
+
+import { WebError } from '@deepseek-ai/dsh-web'
+import { publicHttpNetwork } from './network.ts'
+import { parseFetchUrl } from './policy.ts'
+
+/**
+ * Parse an HTTP(S) URL and require its current DNS answer set to contain only
+ * public unicast addresses. A successful result does not authorize a later
+ * connection; callers must use a provider that repeats and enforces the check.
+ * @param rawUrl - URL proposed for a public fetch.
+ * @param signal - cancellation for hostname resolution.
+ * @returns the parsed URL after successful public-address resolution.
+ */
+export async function preflightPublicFetchUrl(rawUrl: string, signal: AbortSignal): Promise {
+ const url = parseFetchUrl(rawUrl)
+ try {
+ await publicHttpNetwork.resolve(url.hostname, signal)
+ } catch (error: unknown) {
+ if (error instanceof WebError) throw error
+ if (signal.aborted) {
+ throw new WebError('web fetch aborted during permission preflight', 'WEB_ABORTED', { cause: error })
+ }
+ throw new WebError(`web fetch hostname resolution failed: ${String(error)}`, 'WEB_PROVIDER_ERROR', { cause: error })
+ }
+ return url
+}
diff --git a/packages/web/web-fetch-http/tests/fetch-http.spec.ts b/packages/web/web-fetch-http/tests/fetch-http.spec.ts
index 284ea7456a..0ff18580ae 100644
--- a/packages/web/web-fetch-http/tests/fetch-http.spec.ts
+++ b/packages/web/web-fetch-http/tests/fetch-http.spec.ts
@@ -7,7 +7,7 @@ import { HttpFetchProvider, LOCAL_FETCH_PROVIDER_ID } from '@deepseek-ai/dsh-web
import type { HttpFetchLimits } from '@deepseek-ai/dsh-web-fetch-http'
import * as fetchPlugin from '@deepseek-ai/dsh-web-fetch-http'
import { createPinnedLookup, isPublicIpAddress, publicHttpNetwork, requestPinned, resolvePublicAddresses } from '../src/network.ts'
-import { classifyContentType, decoderForCharset, isSameOrigin, parseCharset, validateFetchUrl } from '../src/policy.ts'
+import { classifyContentType, decoderForCharset, isSameOrigin, parseCharset, parseFetchUrl, validateFetchUrl } from '../src/policy.ts'
const limits: HttpFetchLimits = {
maxUrlLength: 2048,
@@ -47,6 +47,7 @@ function provider(overrides: Partial = {}): HttpFetchProvider {
describe('policy helpers', () => {
it('validates scheme, credentials, and length', () => {
+ expect(parseFetchUrl('https://example.com/preflight').pathname).toBe('/preflight')
expect(validateFetchUrl('https://example.com/x', 2048).hostname).toBe('example.com')
expect(() => validateFetchUrl('ftp://example.com', 2048)).toThrow(expect.objectContaining({ code: 'WEB_INVALID_URL' }))
expect(() => validateFetchUrl('not a url', 2048)).toThrow(expect.objectContaining({ code: 'WEB_INVALID_URL' }))
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 6e60469e14..b84887e03b 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -1459,6 +1459,12 @@ importers:
'@deepseek-ai/dsh-web':
specifier: workspace:^
version: link:../../web/web
+ '@deepseek-ai/dsh-web-fetch-approval-policy':
+ specifier: workspace:^
+ version: link:../../web/web-fetch-approval-policy
+ '@deepseek-ai/dsh-web-fetch-http':
+ specifier: workspace:^
+ version: link:../../web/web-fetch-http
'@deepseek-ai/dsh-web-search-deepseek':
specifier: workspace:^
version: link:../../web/web-search-deepseek
@@ -9238,6 +9244,36 @@ importers:
specifier: workspace:^
version: link:../../llm/llm
+ packages/web/web-fetch-approval-policy:
+ devDependencies:
+ '@deepseek-ai/cordis':
+ specifier: workspace:^
+ version: link:../../../vendor/cordis
+ '@deepseek-ai/dsh-agent':
+ specifier: workspace:^
+ version: link:../../core/agent
+ '@deepseek-ai/dsh-invariants':
+ specifier: workspace:^
+ version: link:../../runtime-diagnostics/invariants
+ '@deepseek-ai/dsh-llm':
+ specifier: workspace:^
+ version: link:../../llm/llm
+ '@deepseek-ai/dsh-sandbox-policy':
+ specifier: workspace:^
+ version: link:../../sandbox/sandbox-policy
+ '@deepseek-ai/dsh-system-prompt':
+ specifier: workspace:^
+ version: link:../../core/system-prompt
+ '@deepseek-ai/dsh-tools':
+ specifier: workspace:^
+ version: link:../../core/tools
+ '@deepseek-ai/dsh-user-approval':
+ specifier: workspace:^
+ version: link:../../interaction/user-approval
+ '@deepseek-ai/dsh-web-fetch-http':
+ specifier: workspace:^
+ version: link:../web-fetch-http
+
packages/web/web-fetch-http:
dependencies:
'@deepseek-ai/schemastery':
diff --git a/scripts/gen-doc-graphs.ts b/scripts/gen-doc-graphs.ts
index 79407ece40..39089baad4 100644
--- a/scripts/gen-doc-graphs.ts
+++ b/scripts/gen-doc-graphs.ts
@@ -535,8 +535,8 @@ const SERVICE_ROLES: ServiceRole[] = [
title: 'Web access provider registry',
mode: 'seam',
implementations: ['web-search-exa', 'web-search-perplexity', 'web-search-deepseek', 'web-fetch-http'],
- consumers: ['tool-web'],
- note: 'Search and fetch providers register into one ctx.web seam; tool-web owns the stable model-facing names.',
+ consumers: ['tool-web', 'web-fetch-approval-policy'],
+ note: 'Search and fetch providers register into one ctx.web seam; tool-web owns the stable model-facing names, and web-fetch-approval-policy applies one-shot consent before restricted fetch calls.',
},
{
key: 'spillStore',
diff --git a/scripts/verify-package-readme-model-experience.ts b/scripts/verify-package-readme-model-experience.ts
index 1fb184af82..c1f43a223e 100644
--- a/scripts/verify-package-readme-model-experience.ts
+++ b/scripts/verify-package-readme-model-experience.ts
@@ -174,6 +174,7 @@ const SENTENCE_MODEL_EXPERIENCE: Readonly> = {
'packages/util/output-retention': { kind: 'indirect', reason: 'Only retention consumers render retained content and omission metadata.' },
'packages/util/native-command': { kind: 'none', reason: 'The host-side subprocess runner registers nothing model-facing.' },
'packages/web/web': { kind: 'indirect', reason: 'The provider registry delegates model rendering to dsh-tool-web.' },
+ 'packages/web/web-fetch-approval-policy': { kind: 'indirect', reason: 'The policy delegates model-visible approval and denial rendering to dsh-tools and dsh-user-approval.' },
'packages/web/web-fetch-http': { kind: 'indirect', reason: 'The provider backend delegates model rendering to dsh-tool-web.' },
'packages/web/web-search-exa': { kind: 'indirect', reason: 'The provider backend delegates model rendering to dsh-tool-web.' },
'packages/workflow/workflow': { kind: 'indirect', reason: 'The service delegates parent and child model rendering to its consumer and engine.' },
diff --git a/tsconfig.host.json b/tsconfig.host.json
index b64992cdf1..4cd7000ca2 100644
--- a/tsconfig.host.json
+++ b/tsconfig.host.json
@@ -251,6 +251,7 @@
{ "path": "./packages/web/web-search-perplexity" },
{ "path": "./packages/web/web-search-deepseek" },
{ "path": "./packages/web/web-fetch-http" },
+ { "path": "./packages/web/web-fetch-approval-policy" },
{ "path": "./packages/web/tool-web" },
{ "path": "./packages/spill/spill" },
{ "path": "./packages/spill/spill-local" },