Skip to content

✨ 安全重构 ScriptCat MCP 桥接:sctl 本地守护进程、分级授权与人工确认#1573

Draft
cyfung1031 wants to merge 51 commits into
scriptscat:mainfrom
cyfung1031:pr/ScriptCat-MCP-Bridge
Draft

✨ 安全重构 ScriptCat MCP 桥接:sctl 本地守护进程、分级授权与人工确认#1573
cyfung1031 wants to merge 51 commits into
scriptscat:mainfrom
cyfung1031:pr/ScriptCat-MCP-Bridge

Conversation

@cyfung1031

@cyfung1031 cyfung1031 commented Jul 12, 2026

Copy link
Copy Markdown
Collaborator

Important

本描述写于 Native Messaging 方案时期,部分内容已与当前实现不符。
传输层已改为独立的 Go 守护进程 sctl(WebSocket,127.0.0.1:8643),随之失效的条目包括
「商店构建隔离」「Windows installer」「Native Host CI 检查」等。

评审请以这条评论为准#1573 (comment)
—— 其中说明了当前架构、本轮联调发现并修复的三个缺陷,以及真机端到端验证结果。
本描述会另行整体重写。


This description predates the switch to the sctl daemon and is partly out of date.
The transport is now a standalone Go daemon (sctl, WebSocket on 127.0.0.1:8643); the
store-build isolation, Windows installer, and Native Host CI items no longer apply.
Please review against this comment instead.

Checklist / 检查清单

  • Fixes mentioned issues / 修复或回应已提及的问题(回应 feat(native-messaging): Add Native Messaging Host with MCP Server for external script management #1465 的安全、权限与商店审核风险)
  • Code reviewed by human / 代码通过人工检查
  • Automated test coverage added / 已补充自动化测试覆盖
  • CI checks added for native host and build-profile isolation / 已补充 Native Host 与构建 Profile 隔离的 CI 检查
  • Pull request CI passed / 本 PR 的 CI 已全部通过
  • Manual end-to-end smoke test completed / 已完成真实浏览器、Native Host 与 MCP 客户端的全链路冒烟测试
  • Windows installer manually verified / 已在 Windows 环境实际验证安装、卸载及回滚脚本

背景 / Background

本 PR 是对 #1465 所提出的 ScriptCat MCP / Native Messaging 能力的一次完整安全重构,而不是在原实现上追加几个校验条件。

PR #1465 首先证明了这个方向的价值:AI 助手、CLI 和其他 MCP 客户端确实可以帮助用户查看、维护和安装用户脚本。感谢 @icacaca 提出最初方案、协议与使用场景;本 PR 延续这个产品方向,并保留对原作者的明确致谢。

PR #1465 demonstrated that the product direction is valuable: AI assistants, CLI tools, and other MCP clients can help users inspect and manage userscripts. Many thanks to @icacaca for the original proposal, protocol work, and end-to-end prototype. This PR continues that direction, but redesigns the trust boundary and write path from the ground up.

#1465 的讨论同时指出了一个不能靠局部补丁解决的根本问题:原方案在 127.0.0.1:3333 暴露 HTTP/SSE 服务,并将安装、启用、禁用和删除脚本等高权限能力直接连接到扩展。即使监听地址是 localhost,这仍然构成一个本机控制面;网页、被提示词注入的 agent、同用户进程或错误配置的 MCP 客户端都有机会滥用它。主商店构建直接增加 nativeMessaging 权限,也会扩大 Chrome Web Store / Edge Add-ons 的审核与信任风险。

The blocker identified in #1465 is architectural, not cosmetic. A localhost HTTP/SSE endpoint is still a local control plane. When that endpoint can directly install or enable browser-executed code, CORS mistakes, DNS rebinding, prompt injection, a confused agent, or another same-user process can become a code-execution path. Adding nativeMessaging to normal store builds also creates an unnecessary permission and review burden for users who never use MCP.

因此,本 PR 的目标不是“让原桥接可以通过审核”,而是重新定义安全边界:

  1. 网页不能接触桥接入口
  2. 客户端必须先配对并获得最小权限
  3. 写操作永远只能提出请求,不能直接修改脚本
  4. 用户批准的必须是经过绑定和复核的确切内容
  5. 商店构建完全不包含该权限、UI 或后台集成代码
  6. 每个允许、拒绝、撤销和状态变化都有可审计记录

The design goals are therefore:

  1. web pages must have no route to the bridge;
  2. every client must pair and receive least-privilege scopes;
  3. write tools can only create requests, never mutate scripts directly;
  4. the user approves the exact content and target state that will be applied;
  5. store builds contain neither the permission nor compiled MCP integration code;
  6. allowed, denied, revoked, and transitioned operations remain auditable.

本次改动 / What changed

1. 移除 localhost HTTP 服务,改为 stdio + OS 本地 IPC

新的数据流为:

AI client (MCP stdio)
  -> scriptcat-mcp shim
  -> Unix domain socket / Windows named pipe
  -> native messaging host
  -> Chrome Native Messaging
  -> ScriptCat extension

整个功能中不再存在 HTTP 监听器、TCP 端口或 CORS 配置。这样不是“缓解”网页访问 localhost 的风险,而是从架构上删除 CORS、端口扫描和 DNS rebinding 这一整类攻击面。

The new bridge has no HTTP listener at all. MCP remains stdio-facing, while the shim and host communicate through an OS-local Unix socket or Windows named pipe. This removes the browser-to-localhost threat class by construction rather than relying on CORS correctness.

2. 引入交互式配对、挑战应答和最小权限 scope

每个 MCP 客户端在调用工具前必须完成配对:

  • 终端与 ScriptCat 同时显示 8 位验证码,用户必须核对;
  • socket 会话先完成 HMAC-SHA-256 challenge-response;
  • token 只显示一次,主机只持久化 SHA-256 hash;
  • 原始 token 不进入 URL、命令行、环境变量、日志或扩展存储;
  • scope 没有“完全访问”通配符,按能力拆分为:
    • scripts:list
    • scripts:metadata:read
    • scripts:source:read
    • scripts:install:request
    • scripts:toggle:request
    • scripts:delete:request

客户端在 tools/list 中甚至看不到未授权工具。主机先做 AuthZ,扩展再根据自己的客户端记录独立复核一次,避免单一组件被攻破后自行扩大权限。

Each client must pair interactively. Tool visibility itself is scope-filtered, and authorization is checked independently by both the native host and the extension. There is no catch-all full-access scope.

3. 所有写操作改成“两阶段请求 + 人工批准”

以下工具不再直接执行变更:

  • request_script_install
  • request_script_toggle
  • request_script_delete

它们只会创建一个有 5 分钟 TTL 的 pending operation,并返回 operationId。实际安装、启用、禁用或删除,只能在 ScriptCat 自己的 install.html / mcp_confirm.html 中由用户明确批准后执行。

Every write tool now creates a pending operation only. The agent can poll or cancel it, but cannot approve it. The actual mutation exists exclusively behind ScriptCat's human-facing approval UI.

安装脚本还有额外默认保护:即使用户批准安装,新脚本仍默认为禁用,除非用户在同一个批准界面中主动选择启用。

Approved installs are disabled by default unless the human explicitly opts in to enabling them in the same review surface.

4. 通过内容 hash 和目标状态复核解决 TOCTOU

pending operation 会绑定:

  • 待安装代码的 contentHash
  • 目标脚本请求时的 existingCodeHash
  • 请求客户端;
  • 操作类型和参数;
  • 过期时间。

在用户点击批准的最后一刻,扩展会重新验证:

  • 操作仍处于 awaiting_user 且未过期;
  • staged code 仍与用户审查时的 hash 一致;
  • enable / disable / delete 的目标脚本仍存在,且代码没有在等待期间变化;
  • 客户端尚未被撤销;
  • 决策只能执行一次。

这避免了“用户看到 A,实际执行 B”或等待批准期间目标脚本已变化的竞态。

The approval path re-verifies the exact staged content and target script state immediately before mutation. This closes the review-to-execution TOCTOU gap: the user cannot be shown one payload while another is applied.

5. 将源码读取与普通元数据读取分开

脚本源码可能包含 API key、私有端点或商业逻辑,因此 scripts:source:read 不与普通 list / metadata 权限混在一起:

  • 配对时默认不勾选;
  • 即使客户端拥有该 scope,每个客户端首次读取每个脚本的源码,仍需要用户选择:拒绝、仅允许一次、或永久允许该客户端读取此脚本;
  • agent 收到的脚本字段均使用 structured JSON 和 contentTrust 标记,不把用户脚本控制的内容拼进 Markdown、工具描述或提示文本。

Source disclosure is treated separately from metadata. It is off by default, and the first source read per client/script requires an additional disclosure decision. Script-controlled text is returned only as structured, trust-tagged data, never interpolated into tool descriptions or Markdown.

6. 开发者构建专用,商店构建在产物层面强制排除

nativeMessaging 虽然保留在源 manifest 中供 developer profile 使用,但打包流程会:

  • store-stable / store-beta 移除 nativeMessaging
  • 不生成 MCP 确认页面入口;
  • 使用模块替换移除 Tools 页的 MCP UI 实现,而不只依赖运行时隐藏;
  • 扫描构建产物,若商店 profile 中仍出现 native host 集成字符串则直接构建失败;
  • 在 CI 中分别打包 store-stable、store-beta 和 developer profile 验证以上约束。

The feature is developer-build only. Store profiles remove the permission and compile out the UI/background integration. Packaging fails loudly if MCP native-host code leaks into a store artifact. This is a build-output guarantee, not merely a runtime flag.

这直接回应了 #1465 中最重要的商店审核担忧:普通商店用户不会因为此功能获得额外权限,也不会收到隐藏但仍编译存在的桥接代码。

This directly addresses the store-review concern raised in #1465: normal store users receive neither the permission nor dormant compiled bridge code.

7. 安装器不再提交机器相关路径或固定扩展 ID

新增 macOS/Linux 与 Windows 安装/卸载脚本:

  • manifest.template.json 在安装时生成真实 native host manifest;
  • 写入实际 Node 路径和用户提供的扩展 ID;
  • allowed_origins 使用精确 extension ID,不使用通配;
  • 支持版本并存、rollback 和卸载;
  • 对配置目录、socket 和凭据文件设置用户级权限;
  • 主机启动时再次验证调用 origin。

Installers now generate the native-host manifest at install time. No developer-specific absolute path or hardcoded extension origin is committed. The runtime additionally validates the launching extension origin.

8. 审计、限流、撤销和紧急停止

新增:

  • 每客户端读取与写请求限流;
  • 配对尝试和认证失败限流;
  • 500 条 extension-side audit ring buffer;
  • 按客户端筛选、清除和导出 JSON;
  • 单客户端即时撤销;
  • “撤销全部客户端并停止桥接”的 kill switch;
  • 关闭运行时开关即断开 native messaging port。

The bridge adds rate limits, source-free audit events, immediate per-client revocation, and a one-click revoke-all-and-stop kill switch.

9. 补齐协议、威胁模型、使用指南和商店审核说明

新增并交叉链接:

  • packages/native-messaging-host/PROTOCOL.md
  • packages/native-messaging-host/THREAT-MODEL.md
  • packages/native-messaging-host/README.md
  • packages/native-messaging-host/README_zh-CN.md
  • docs/mcp-bridge-guide.md
  • docs/mcp-bridge-guide_zh-CN.md
  • docs/store-review/mcp.md

文档分别说明协议、资产/攻击者/残余风险、安装配对流程、权限表、用户同意界面、token 处理、撤销、kill switch 与已知限制,避免把安全判断只留在代码审查者脑中。

The PR documents the protocol, threat model, operational guide, consent surfaces, store-build exclusion, residual risks, and known follow-ups in both English and Simplified Chinese where most useful.

为什么这比 #1465 更安全 / Why this is safer than #1465

关注点 #1465 本 PR
对外入口 localhost HTTP/SSE :3333 MCP stdio + OS-local socket/pipe;无 HTTP/TCP listener
客户端认证 无明确认证模型 交互式配对、验证码、challenge-response、hashed token
权限模型 连接后可调用全部操作 最小权限 scopes;工具目录按 scope 过滤;主机与扩展双重检查
安装/启停/删除 外部请求可直接执行 只创建 pending request;每次必须在 ScriptCat UI 人工批准
安装后状态 可直接进入可运行状态 默认 disabled;启用需用户明确选择
TOCTOU 未绑定用户审查内容 contentHash / existingCodeHash 在批准时重新验证
源码隐私 get_script 直接返回源码 source 独立 scope + 每客户端/每脚本首次披露批准
Prompt injection 脚本数据可进入 agent 上下文 structured JSON + contentTrust;工具描述为静态常量
商店权限 主 manifest 加 nativeMessaging store profile 移除权限并编译排除代码;产物断言 + CI
原生主机清单 含环境特定路径/来源 安装时生成;精确 origin;多平台安装/卸载/rollback
追踪与止损 无完整审计/撤销模型 audit log、限流、即时撤销、revoke-all kill switch

这并不表示本 PR 能防御“同一操作系统用户账户已经完全失陷”的情况。威胁模型明确记录:同用户恶意进程最终仍可能读取客户端凭据文件或调试浏览器。这里的目标是阻止网页直接访问、未经配对客户端、越权客户端、被提示词注入的合法 agent,以及在用户没有看到并批准确切操作时发生脚本变更。

This PR does not claim to defend an already-compromised OS user account. A same-user malicious process may ultimately read the shim credential file or debug the browser. The intended boundary is narrower and explicit: no web-page entry point, no unauthenticated client, no scope escalation, and no script mutation without the human reviewing and approving the exact operation.

实现考虑 / Design considerations

为什么不用“localhost + token”作为最小修改?

因为 token 只能解决部分未认证访问,不能删除浏览器可到达本机 HTTP 服务所带来的 CORS、DNS rebinding、端口探测与错误暴露风险。既然 MCP 客户端天然支持 stdio,就没有必要保留一个网页协议入口。

A bearer token on localhost would reduce unauthenticated access but would retain the browser-reachable HTTP attack surface. Since MCP clients already support stdio, keeping HTTP provides risk without a necessary product benefit.

为什么写 scope 仍不能直接写?

scope 表示“允许客户端提出这类请求”,不是“允许 agent 代替用户最终决定”。AI agent 可能被网页内容提示词注入,也可能错误理解上下文。对于会让代码进入浏览器执行环境的操作,长期 token 不应等同于长期执行授权。

A write scope authorizes requesting a capability, not exercising final authority. Agents can be prompt-injected or simply wrong; a durable token must not become durable permission to install browser-executed code.

为什么源码读取也需要额外批准?

元数据和源代码的敏感级别不同。脚本名称、类型和启用状态适合低权限自动化;完整源码可能包含密钥和私有逻辑。将两者拆开可以让只需要 inventory 的客户端保持最小权限。

Metadata and full source have different confidentiality levels. Separating them allows inventory clients to operate without receiving secrets or proprietary code.

为什么使用独立 native-host package 和独立 lockfile?

native host 是在扩展外运行的独立可信组件,运行时依赖与浏览器 bundle 不同。独立 package 让依赖面、Node 版本、构建、测试和分发边界更清楚,也便于精确 pin MCP SDK / zod 并单独执行跨平台 CI。

The host is a separately executed trusted component with a different runtime and distribution boundary from the extension. A standalone package makes its dependency, build, test, and release surface explicit.

为什么 store profile 要做“编译排除”而不只隐藏 UI?

运行时条件隐藏无法保证 bundler 不把代码和字符串放进共享 chunk。商店审核与权限承诺应该针对最终产物,因此本 PR 使用模块替换、manifest 处理、产物扫描和 CI pack assertions 建立可验证的不变量。

Runtime hiding is insufficient because bundlers may retain code in shared chunks. Store guarantees must be made against the produced artifact, so this PR combines module replacement, manifest transformation, artifact scanning, and CI assertions.

测试 / Tests

本分支新增或扩展了以下自动化覆盖:

  • native messaging framing、channel 与 origin 验证;
  • socket / named-pipe IPC;
  • pairing、challenge-response、token store 与 scope gate;
  • rate limiting、session ownership 与 revocation;
  • strict MCP tool input schemas 与协议 conformance;
  • URL 安装策略(scheme、私网/loopback、redirect、大小限制);
  • pending operation、用户批准、过期、单次决策和 TOCTOU conflict;
  • source disclosure:deny / allow once / allow for client;
  • bridge/controller/service worker 生命周期与运行时开关;
  • 配对、批准、Tools 设置和安装 banner UI;
  • build profile / manifest / compiled-output assertions;
  • native host 在 Ubuntu、macOS、Windows 的 build/test CI matrix。

Automated coverage includes native framing, IPC, authentication, scopes, sessions, rate limits, strict schemas, URL policy, approval/TOCTOU behavior, source disclosure, lifecycle behavior, UI flows, and build-profile assertions. The native-host package is configured to build and test on Linux, macOS, and Windows in CI.

尚待完成 / Still required before ready for merge

  • 本 PR 的 GitHub Actions 全部通过;
  • maintainer / human review of all modified files;
  • 使用真实 Chrome developer build、真实安装的 native host 和真实 MCP client 完成手工端到端 smoke test;
  • 实际执行并验证 Windows install.ps1 / uninstall.ps1 / rollback 流程;
  • 为配对、安装确认、删除长按确认、撤销和 kill switch 补充截图或演示录屏。

The PR intentionally does not claim that the real-browser/manual and Windows-installer verification has already happened. These remain explicit pre-merge review items.

已知限制 / Known limitations

  • 当前仅支持 Chromium 的 connectNative 路径;Firefox event-page 生命周期尚未验证,UI 与 controller 会明确隐藏/跳过;
  • native host 目前以 Node.js package 形式分发,signed binary / single-file packaging 留待后续;
  • 扩展层无法获得可靠 DNS resolution API,因此 URL 策略能拒绝字面 private/loopback 地址并逐跳检查 redirect,但不能完全阻止“公开 hostname 在请求时解析到私网”的 DNS rebinding;
  • 同一 OS 用户账户完全失陷不在防御范围内;
  • 当前 PR 提供 store-review 说明材料,但并不代表实际商店提交已经完成。

建议审查重点 / Suggested review focus

  • 是否接受“write scope 只允许 request、每次写入必须人工批准”的权限语义;
  • host-side 和 extension-side 双重 scope / client / session 检查是否完整;
  • approval 时的 contentHash / existingCodeHash / TTL / single-shot 不变量;
  • source disclosure 的 once/permanent grant 是否存在绕过路径;
  • store-stable / store-beta 的最终产物是否确实不含权限、UI 和 native-host integration code;
  • IPC、token 文件、配置目录与日志中是否仍有 secret 泄漏可能;
  • URL policy 的 redirect、size cap 与 private-target 限制是否符合预期;
  • UI 是否清楚表达“agent 只能请求,用户才有最终执行权”。

参考 / References

再次感谢 @icacaca#1465 中完成的探索。这个 PR 并不是否定原方向,而是把原型中已证明有价值的能力,放进一个更适合浏览器扩展、AI agent 和应用商店分发场景的安全模型里。

Thanks again to @icacaca for the exploration in #1465. This PR does not reject the original direction; it takes the useful capability demonstrated by that prototype and places it behind a trust model suitable for a browser extension, AI agents, and store distribution.

icaca and others added 30 commits May 24, 2026 10:42
… external script management

- Add NativeMessageHandler in Service Worker to handle Native Messaging connections
  - Supports 6 operations: list_scripts, get_script, install_script, uninstall_script, enable_script, disable_script
  - Proactively connects to native host on startup via chrome.runtime.connectNative()
  - Handles bidirectional message passing with proper error handling

- Add packages/native-messaging-host: unified Native Host + MCP Server process
  - NativeHost: stdio protocol (4-byte LE length-prefixed JSON) for browser communication
  - MCP Server: HTTP+SSE transport on port 3333 for AI/CLI integration
  - Internal message bus via EventEmitter for bridging both protocols
  - Port conflict handling (EADDRINUSE graceful skip)

- Add ScriptService.getScriptAndCode() for retrieving script metadata + source code

- Add nativeMessaging permission to manifest.json

- Add PROTOCOL.md: complete JSON message protocol documentation

This enables external tools (AI assistants, CLI tools) to manage ScriptCat
user scripts through the MCP protocol, while the native messaging bridge
maintains secure communication with the browser extension.
The pr/secure-MCP prelim combined an unauthenticated loopback HTTP+SSE
MCP server with direct installByUrl/installByCode/deleteScript/
enableScript calls from inbound native-messaging requests, a hardcoded
Windows manifest path, and an unconditional connectNative() on every
service worker init. Per the security redesign in
workspace/.ref-docs/00-README.md, this is being replaced (not patched)
by a stdio MCP bridge with OS-IPC transport, two-phase human-approved
writes, and build/runtime gating — landing in following commits.

Removes packages/native-messaging-host/{src/index.ts,manifest.json,
install.ps1,launch.js,launch.mjs,PROTOCOL.md} and
src/app/service/service_worker/native_msg.ts, plus the unconditional
NativeMessageHandler wiring in service_worker/index.ts. The
nativeMessaging permission stays in src/manifest.json (kept in source,
stripped by pack.js per build profile — matches the existing debugger/
agent pattern) and packages/native-messaging-host/package.json is left
in place, to be rewritten when the host package is reconstructed.
Mirrors the existing EnableAgent build-gate pattern, but with reversed
polarity: EnableMCP (src/app/const.ts) defaults off even on local
developer builds, requiring an explicit SC_ENABLE_MCP=true. This is
the first of the two gates required by the security redesign in
workspace/.ref-docs/01-implementation-plan.md D3 — the second,
runtime opt-in gate is mcp_enabled below.

scripts/build-config.js gains resolveMcpEnabled/applyMcpManifest
alongside the existing agent equivalents: resolveMcpEnabled derives
the flag from build profile (developer -> on, store-* -> off) unless
SC_ENABLE_MCP explicitly overrides it; applyMcpManifest strips the
nativeMessaging permission when disabled. Deliberately does not
special-case "store profile with explicit override" here — that
combination is caught as a hard build-time assertion in scripts/pack.js
(store artifacts must never contain nativeMessaging), landing in a
later commit once there is MCP code for the assertion to guard.

Adds the runtime opt-in flag mcp_enabled: registered in
STORAGE_LOCAL_KEYS (device-local, never chrome.storage.sync, matching
enable_script/vscode_url) with SystemConfig.getMcpEnabled/
setMcpEnabled (default false).
Adds the normative wire protocol for the MCP bridge (workspace/.ref-
docs/03-protocol-spec.md §2-3, gitignored reference doc):
packages/native-messaging-host/src/shared/protocol.ts is the canonical
source — native-messaging envelope types, the McpBridgeRequest/
McpBridgeResponse envelope, the 6 scopes, 9 bridge actions, 12 error
codes, operation kinds/statuses, and per-action input/result schemas.

src/app/service/service_worker/mcp/types.ts is an independently
maintained mirror, not an import: the host package is standalone (own
lockfile, own CI job, not a pnpm workspace member) so it can't share a
build graph with the extension. protocol.conformance.test.ts guards
against the two copies drifting apart by comparing their literal
unions and the action->scope mapping; verified the test actually
catches drift by temporarily injecting a mismatched action into the
extension copy and confirming the test failed, then reverting.

WS-0 in workspace/.ref-docs/01-implementation-plan.md §3 — blocks the
extension services and native host work landing in following commits.
Implements the extension-side half of WS-A (workspace/.ref-docs/
05-extension-implementation.md §2-4), TDD-first with Chinese BDD
titles per repo convention. McpController + service_worker/index.ts
wiring land in a follow-up commit — this one is self-contained and
independently testable.

- src/app/repo/mcp.ts: McpClientDAO / McpOperationDAO / McpAuditDAO
  (Repo<T> pattern, entity+DAO colocated per convention).
  McpAuditDAO.append prunes to a 500-event ring buffer.
- mcp/url_policy.ts: validateInstallUrl + fetchInstallSourceWithPolicy
  (doc 04 §5) — https-only, rejects embedded credentials and
  syntactically local/private/loopback/link-local/multicast targets,
  2 MiB stream-abort cap. Documented residual limitation: the Fetch
  API forces redirect:"manual" responses to be opaque, so true
  per-hop redirect revalidation isn't achievable from an extension
  service worker; this validates the initial and final (post-redirect)
  URL instead (doc 04 §2 asset A3 residual risk).
- mcp/errors.ts: McpBridgeError, the stable-code error type threaded
  through approval and bridge.
- mcp/approval.ts: McpApprovalService owns the McpOperation lifecycle
  and every doc 04 §4 TOCTOU invariant — re-verifies staged/target
  code hashes immediately before mutation, single-shot decisions,
  installs always start disabled, request idempotency, lazy expiry
  sweep, per-client ownership on get/list/cancel. Depends on a narrow
  McpScriptMutator interface (install/enable/delete only), not the
  full ScriptService. Extends InstallSource with "mcp" and ScriptInfo
  with an optional `mcp` staging marker (same extension point the
  skillScript flow uses at script.ts:957-966).
- mcp/bridge.ts: McpBridge — strict manual allow-list input validation
  per action (unknown fields and malformed UUIDs rejected), re-checks
  scope from McpClientDAO independent of whatever the host already
  checked (doc 04 §3 defense in depth), gates write actions on the
  session write flag, dispatches reads directly and writes through
  McpApprovalService, writes exactly one audit event per request.
  Script-derived strings (name/description) pass through untouched as
  JSON fields, never formatted into prose — verified with a test using
  a script named "Ignore previous instructions..." as the injection
  probe.
- sha256OfText added to pkg/utils/crypto.ts (existing crypto-js dep).

pnpm typecheck clean; full suite green (3179 tests, up from 3161).
…wiring

Completes the extension-side half of WS-A (workspace/.ref-docs/05-
extension-implementation.md §4.2, §4.5-4.6). MCP is now fully wired
behind EnableMCP + mcp_enabled, mirroring the removed prelim's
connectNative-availability check.

- protocol.ts / mcp/types.ts: added the "hello" native-message type
  (doc 03 §6 versioning describes the host announcing its version but
  the layer-1 message table in the same doc omitted it — added to
  both copies, conformance test re-verified green).
- mcp/controller.ts: McpController owns the chrome.runtime.connectNative
  port lifecycle — connects only when mcp_enabled flips true, capped
  exponential backoff (1s*2^n, 60s cap, 5 attempts) before giving up
  at status "host_unreachable", routes hello/ping/bridge.request,
  refuses to dispatch bridge calls below MIN_HOST_VERSION (status
  "host_outdated"), and owns the session-only write-mode flag in
  chrome.storage.session (never SystemConfig, so it never survives a
  restart).
- mcp/service.ts: McpUIService, the page-facing Group endpoints
  (status/setWriteSession/clients/revokeClient/revokeAllAndStop/
  operation/operationDecision/audit/auditClear). Deliberately omits
  setEnabled (mcp_enabled already flows through the generic
  SystemConfig get/set every other device-local setting uses) and
  auditExport (would just re-serialize what `audit` already returns)
  to avoid duplicate endpoints; pairingDecision is deferred to the
  commit that adds the pairing dialog, since nothing calls it yet.
- client.ts: MCPClient, mirroring PermissionClient's pattern. Named
  distinctly in a comment from the pre-existing unrelated AgentClient
  .mcpApi (ScriptCat's agent acting as an MCP *client* of external
  servers — the opposite direction from this feature, which makes
  ScriptCat itself an MCP *server*).
- approval.ts: getOperationForUI — like getOperation but without the
  clientId ownership gate, for the human-facing approval pages that
  reach an operation only via a URL the extension itself generated.
- bridge.ts: setWriteSessionChecker setter, so McpController and
  McpBridge can be constructed without a circular reference (each
  needs the other) while keeping both `const` at the call site.
- index.ts: constructs McpClientDAO/McpApprovalService/McpBridge/
  McpController/McpUIService behind
  `EnableMCP && typeof chrome.runtime?.connectNative === "function"`.

Verified: pnpm typecheck clean; full suite green (3197 tests, up from
3179); `pnpm build` and `SC_ENABLE_MCP=true pnpm build` both compile
cleanly (only pre-existing monaco-worker warnings, unrelated).
…ules

Starts WS-B (workspace/.ref-docs/06-native-host-and-installers.md):
the standalone packages/native-messaging-host package, own lockfile,
own pnpm install/build/test, not part of the root pnpm workspace or
the extension bundle (doc 06 §7). Adds @modelcontextprotocol/sdk
(1.29.0, exact pin, latest v1 production line) and zod (3.25.76,
exact pin, SDK peer) as this package's own dependencies — the root
extension's dependency tree is untouched.

Excludes the package from the root tsconfig.json and vitest.config.ts:
it uses ESM "bundler" module resolution (no .js extensions on
relative imports) where the root project uses "nodenext" (requires
them), and its tests need its own local node_modules for the SDK/zod
— running them from the root suite would break on a fresh clone
before anyone runs `pnpm install` inside the package. Root eslint
still covers it directly (`pnpm exec eslint packages/native-messaging-
host/src/` — verified clean), matching doc 06 §7's "lint only" line.

Core security modules landing in this commit, each with real tests
(69 total, package runs standalone via `cd packages/native-messaging-
host && pnpm test`):

- shared/limits.ts: doc 04 §7 rate/size constants; resolveLimits only
  allows a config override to tighten a limit, never loosen it.
- shared/logging.ts: stderr-only structured logger (doc 04 §10 "stdout
  is exclusively the native-messaging channel") + URL/secret redaction
  helpers (doc 04 §8-9 — tokens and query-string credentials never
  reach a log line).
- shared/config.ts: per-platform config dir resolution, symlink-
  resolved group/world-writable rejection (doc 04 §8), atomic
  temp-file-then-rename writes with 0600 perms.
- native/framing.ts: the 4-byte-LE native-messaging frame codec.
  Explicit regression test against the prelim's `buf = Buffer.alloc(0)`
  bug on oversize messages, which discarded buffered bytes belonging
  to the NEXT message and permanently desynchronized the stream (doc
  03 §2, doc 08 §6) — this decoder drops only the oversize message and
  stays aligned, and switches to a streaming-skip mode instead of
  buffering a multi-chunk oversize body in memory.
- native/origin.ts: exact-match caller-origin verification against
  Chrome's argv-passed chrome-extension:// origin (doc 04 §3 A6).
- auth/scopes.ts, token-store.ts, pairing.ts: scope visibility
  filtering for tools/list, the hashed-token client registry (raw
  token never persisted — verified in a test that greps the persisted
  file for it), and the pairing state machine (8-char code from an
  unambiguous alphabet, 2 min TTL, 3/hour global rate limit, 1 pending
  per connection).

Verified: package's own `pnpm test` (69/69) and `tsc --noEmit` clean;
root `pnpm typecheck` and `pnpm vitest run` (296 files / 3197 tests)
unaffected and clean; root `pnpm exec eslint
packages/native-messaging-host/src/` clean.
…lockout)

Adds packages/native-messaging-host/src/broker/rate-limit.ts, the
three doc 04 §7 limiter primitives the broker will apply per client
connection: WindowedRateLimiter (read 60/min, write 10/hour),
ConcurrencyLimiter (4 in-flight calls/client), and AuthFailureLockout
(3 failures/min/endpoint -> 5 min lockout). Kept as three focused
single-purpose classes rather than one generic configurable bucket,
matching the distinct units doc 04 §7 specifies.

12 new tests (84/84 package total); package's own tsc --noEmit,
prettier, and root eslint all clean.
…n state machine

Adds the pieces that turn the auth/rate-limit primitives from the
previous two commits into an actually-running broker (doc: workspace/
.ref-docs/06-native-host-and-installers.md §1, doc 03 §4):

- auth/challenge.ts: HMAC-SHA-256 challenge-response. The host only
  ever persists tokenHash = SHA-256(token) (doc 04 §8), never the raw
  token, so the MAC is keyed on tokenHash rather than the token itself
  — HMAC_SHA256(key=tokenHash, nonce + "|" + endpointName). This
  reconciles an ambiguity between doc 03 §4 (which shows
  HMAC_SHA256(token, ...)) and doc 04 §8 (host never stores the raw
  token): a verifier that only has tokenHash cannot compute an HMAC
  keyed on the raw token, so tokenHash-as-key is the only construction
  that satisfies both constraints simultaneously. Documented inline.
  Endpoint name bound into the MAC blocks replay against a different
  socket.
- broker/messages.ts: Layer-2 shim<->host message shapes (doc 03 §4)
  — internal to this package, not mirrored to the extension.
- broker/ipc.ts: creates the Unix domain socket (POSIX) / named pipe
  (Windows) endpoint with a random name, chmod 600 on POSIX. Peer-UID
  verification (SO_PEERCRED) has no portable Node core API without a
  native addon, so it's not implemented — documented as a residual
  limitation; the enforced boundary is filesystem permissions (0700
  containing directory + 0600 socket file) instead.
- broker/session.ts: the per-connection protocol state machine —
  hello/challenge/auth/ready handshake, pairing (delegates to
  PairingManager), and steady-state call dispatch gated by scope
  (checked by the caller), write-session, and the rate/concurrency
  limiters from the previous commit.
- broker/server.ts: accepts connections on an IpcEndpoint, decodes the
  doc 03 §4 line-delimited JSON framing (max 4 MiB/line), and routes
  parsed messages into a SessionHandler per connection — same
  don't-desynchronize-the-stream discipline as native/framing.ts, for
  the socket side of the protocol.

44 new tests (116/116 package total), including end-to-end handshake
and call dispatch over a real Unix domain socket (this dev machine is
POSIX; Windows-only paths are behind `describe.skipIf` and exercised
by the Windows leg of the CI matrix once that job exists). Package's
own tsc --noEmit clean; root pnpm typecheck and root eslint over the
package both clean.
- native/channel.ts: host-side duplex channel over native messaging
  (doc 03 §2, doc 02 §6). request()/response correlation on top of the
  framing codec — random (not sequential) requestId, bounded pending
  map with per-request 30s timeout, rejectAllPending() so a closed
  stdin doesn't leave callers hanging forever. Unsolicited
  extension-initiated messages (pair.decision, client.revoke,
  operations.changed, pong) flow to onMessage listeners.
- shim/socket-client.ts: shim-side counterpart to broker/session.ts —
  connects to the broker's socket, does the hello/challenge/auth
  handshake (or pair for first-run), then call()/requestPairing(). All
  incoming bytes flow through exactly one line-buffer + dispatch path;
  originally wrote authenticate() with a second raw socket listener
  and caught it in review before committing — a single TCP chunk
  spanning a handshake message and the start of the next message
  would have been parsed by two independent buffers, risking dropped
  or duplicated bytes at the boundary. Reworked to have authenticate()
  observe handshake messages through the same onEvent dispatch every
  other message type uses.

14 new tests (130/130 package total), including true end-to-end
coverage: SocketClient talking to a real BrokerServer over a real Unix
domain socket for the full handshake, auth-failure, unauthenticated-
call, pairing, and concurrent-non-interleaving-calls cases. Package's
own tsc --noEmit clean; root eslint over the package clean.
…ring

Completes WS-B's shim layer (doc: workspace/.ref-docs/06-native-host-
and-installers.md §1, doc 03 §5) on the official
@modelcontextprotocol/sdk rather than hand-rolled JSON-RPC:

- shim/tools.ts: the full tool catalog with zod .strict() input
  schemas mirroring doc 03 §3 exactly (unknown fields rejected),
  scope-filtered visibility (visibleTools), compile-time-constant
  descriptions where every write tool states the human-approval
  contract up front, and toToolResult — the structured
  content/structuredContent wrapper that replaces the prelim's
  Markdown-templated executeToolCall (the injection vector doc 04 §6
  targets). Verified with an explicit injection probe: a script named
  "Ignore all previous instructions..." passes through as an untouched
  JSON string, never formatted into prose.
- shim/resources.ts: scriptcat://scripts/<uuid>/source URI build/parse,
  independent of the SDK's ResourceTemplate wiring so it's directly
  testable.
- shim/server.ts: buildMcpServer wires McpServer + tool registration +
  the source resource (only when scripts:source:read is granted) onto
  the real SDK types. callBridge correlates through SocketClient.call.

19 new tests (155/155 package total). Package's own tsc --noEmit
clean against the real SDK types; root eslint over the package clean.
Deep protocol-level tools/list dispatch is the SDK's own
responsibility (requires a connected Transport) and intentionally not
re-tested here — this package owns and verifies schema validation,
scope filtering, description content, and injection-safe output
shaping, all independent of the transport.
Wires everything built so far into the two actual binaries doc 06 §1
declares (scriptcat-native-host, scriptcat-mcp):

- shared/host-config.ts / shared/shim-config.ts: config.json and
  credentials.json read/write on top of shared/config.ts's atomic
  writer, plus path helpers (doc 06 §2).
- broker/pairing-decision.ts: extracted the pair.decision handling
  (mint token on approval, persist to TokenStore, resolvePairing) into
  its own testable module rather than burying it in the CLI
  entrypoint — host.ts is now just wiring. Caught and fixed a test-
  fixture bug while writing this: the mock session didn't replicate
  SessionHandler's real side effect of resolving the pairing in the
  shared PairingManager, which silently let a pairingId "survive" a
  second approval in the test but never in production.
- host.ts: origin verification -> load token store -> create IPC
  endpoint -> publish its name to config.json -> native channel on
  stdio -> BrokerServer wired to dispatch through the channel ->
  20s ping keepalive -> graceful shutdown on stdin close or
  bridge.shutdown. `--doctor` diagnostic mode.
- shim.ts: `--pair` flow (prints the verification code, saves
  credentials on approval) and the normal run path (load credentials,
  discover the endpoint, authenticate, build and connect the MCP
  server over its own stdio).

Fixed a real bug surfaced by actually running the built output:
package.json declares "type": "module" (doc 06 §1), but
moduleResolution "bundler" doesn't require or add the .js extensions
Node's ESM loader needs on relative imports — `node dist/host.js`
failed immediately with ERR_MODULE_NOT_FOUND. Switched to "nodenext"
(matching "module": "nodenext") and added .js to every relative
production import; several implicit-`any` errors elsewhere turned out
to be cascading from the same broken resolution and disappeared once
fixed. Verified by actually building and running both binaries:
`node dist/host.js --doctor` and `node dist/shim.js` both produce the
correct real output (confirmed, then cleaned up, the incidental
directories they created outside the repo during that verification).

8 new tests (168/168 package total). Package's own tsc --noEmit
clean; root pnpm typecheck and root eslint over the package both
clean.
Completes WS-B's installer surface (doc: workspace/.ref-docs/06-
native-host-and-installers.md §5):

- manifest.template.json: committed template with empty path/
  allowed_origins — the prelim's committed manifest had a hardcoded
  C:\Users\Administrator\... path and a BOM; this stays a template,
  the real manifest is generated at install time.
- installers/lib/manifest-gen.ts: typed manifest generation (object ->
  JSON.stringify, never string replacement). Strictly validates every
  extension ID against ^[a-p]{32}$ — the prelim's default
  "fomrtutthjerocmw" is not a valid ID and is explicitly tested as
  rejected, so an installer can't reproduce that mistake. No BOM,
  trailing newline, allowed_origins never contains a wildcard.
- host.ts: `--print-manifest --extension-id <id>... --host-path
  <path>` prints the generated manifest JSON for the installer scripts
  to consume; verified against real output.
- installers/install.sh + uninstall.sh (macOS/Linux): copies versioned
  files, pins the resolved node binary's absolute path in a launcher
  script (PATH-hijack guard, doc 06 §6), generates the manifest via
  the typed generator (not string replacement), atomic write (temp +
  rename) into each browser's NativeMessagingHosts directory,
  verifies by re-reading and parsing, also writes the same extension
  origins into the host's own config.json (doc 04 §3 defense in depth
  — the host never trusts the registered manifest's allowed_origins
  alone), writes install-metadata.json for uninstall. Syntax-checked
  with `bash -n` (clean); not executed against this dev machine's real
  browser registrations, since that would modify actual system state
  outside the repo.
- installers/install.ps1 + uninstall.ps1 (Windows): registry keys
  under HKCU per browser (Chrome/Edge/Chromium/Brave), icacls to
  restrict the config dir to the current user, same launcher-pinning
  and typed-manifest-generation approach as the POSIX script. No
  PowerShell interpreter is available in this environment to execute
  or syntax-check it — written carefully against the doc 06 §5 spec
  and reviewed, but unverified by execution; flagging this honestly
  rather than claiming a check that didn't happen. The Windows leg of
  the CI matrix (not yet wired) is where this gets real verification.

11 new tests (184/184 package total, all in manifest-gen.ts — the
shell/PowerShell scripts themselves aren't unit-testable in this
environment). Package's own tsc --noEmit clean; root pnpm typecheck
and root eslint over the package both clean; verified --print-manifest
against real built output.
… bug fix

Adds the MCP Bridge settings card (workspace/.ref-docs/07-ux-spec.md
§1-2, §4, §6) and its i18n, and fixes a real gap discovered while
verifying store-build cleanliness end-to-end rather than assuming it.

- src/locales/{en-US,zh-CN}/mcp.json + registration in each locale's
  index.ts and locales.ts's NS array — mirrors the existing agent.json
  precedent (ships in every build; other locales via docs/
  translation.md workflow, not touched here since only en-US/zh-CN are
  authored this PR per doc 05 §6).
- McpSection.tsx: status pill (off/connecting/connected/host_unreachable/
  host_outdated), first-enable warning dialog before mcp_enabled flips
  true, write-session switch, paired-client list with per-client
  Popconfirm-gated revoke, audit log list with client-side JSON export
  and Popconfirm-gated clear, and the emergency "revoke all & stop"
  action. Registered into Tools/index.tsx and the sidebar category list
  behind EnableMCP, matching how EnableAgent gates AgentMenu.
- 8 new tests mirroring DevToolsSection.test.tsx's mocking convention.

While verifying the store-build exclusion claim end-to-end (not just
trusting the doc's "tree-shaking removes the mcp/ service graph" line)
found two real gaps:

1. rspack.config.ts's DefinePlugin never had an entry for
   process.env.SC_ENABLE_MCP (only SC_DISABLE_AGENT existed) — so
   EnableMCP was never actually a build-time constant in the bundle;
   `SC_ENABLE_MCP=true` vs default builds were byte-identical for this
   flag. Added the matching DefinePlugin entry.
2. Even with that fixed, JSX-conditional tree-shaking across module
   boundaries (`{EnableMCP && <McpSection/>}`) did not eliminate
   McpSection's code from the shared options-page chunk — confirmed by
   grepping the actual built output before and after. Rather than
   trust minifier behavior further, added a NormalModuleReplacementPlugin
   that deterministically swaps McpSection.tsx for a trivial stub
   (McpSection.stub.tsx) at module-resolution time when MCP is
   disabled, so the real component and its transitive MCPClient/mcp-
   repo-type imports are never compiled into a non-MCP build at all.

Verified with concrete build evidence, not assumption: default
`pnpm build` now has zero occurrences of MCP UI strings in dist/ext;
`SC_ENABLE_MCP=true pnpm build` has them. Full suite green (297 files /
3205 tests); pnpm typecheck and eslint over the touched files clean.
Completes the packaging half of workspace/.ref-docs/05-extension-
implementation.md §1.3 and doc 08 §5.

- scripts/pack.js: --profile <store-stable|store-beta|developer> (env
  SC_PACK_PROFILE, default store-stable; new `pnpm pack:dev` script
  sets developer + SC_ENABLE_MCP=true for local convenience). Threads
  SC_ENABLE_MCP into the child build exactly like SC_DISABLE_AGENT
  already is. Applies applyMcpManifest beside applyAgentManifest for
  both Chrome and Firefox manifests — Firefox stays MCP-disabled
  unconditionally this PR, matching doc 01's non-goal.
- scripts/build-config.js: checkMcpPackProfileCompliance, a pure,
  fully unit-tested decision function (store profiles must have
  neither the nativeMessaging permission nor MCP code compiled into
  the bundle; developer-with-MCP-enabled must have both). pack.js
  itself only does the I/O (scanning the built dist/ext .js files for
  the literal string "com.scriptcat.native_host" — a minification-
  resistant proxy for "MCP host-integration code actually got
  compiled in", not just present in source).

Did not execute the full pack.js against this working tree to verify
end-to-end: it rewrites src/manifest.json's version field to match
package.json (a file this task has no reason to touch) and needs a
signing key that isn't present here. Instead verified the two things
that actually matter can be checked independently: the decision logic
via the new unit tests (8 cases covering all three profiles x
compliant/non-compliant), and the underlying signal it depends on via
the concrete build-output greps already done in the previous commit
(zero occurrences of MCP strings in a default build, one occurrence
with SC_ENABLE_MCP=true) — the same DefinePlugin fix from that commit
is exactly what checkMcpPackProfileCompliance's nativeHostCompiledIn
check is designed to catch a regression of.

34 total build-config tests green; pnpm typecheck and full vitest
suite (297 files / 3212 tests) both clean.
Extends the existing install.html flow to carry MCP-sourced install
requests through to human approval (doc: workspace/.ref-docs/
05-extension-implementation.md §5.1, doc 07 §5, doc 04 §4).

- store/features/script.ts: mcpClient = new MCPClient(message),
  alongside the existing scriptClient/subscribeClient/agentClient.
- useInstallData.ts: InstallView.mcp carries the staged
  {operationId, requestingClientName, contentHash} through to the
  page. install() branches on info.mcp — for MCP-sourced requests it
  calls mcpClient.decideOperation({approved: true, enable}) and never
  calls scriptClient.install() directly, keeping the actual mutation
  server-side in McpApprovalService.decide (which re-verifies the
  staged code hash immediately before installing — the TOCTOU
  invariant only holds if the page never performs the install itself).
  New rejectMcp() sends decideOperation({approved: false}) — kept
  separate from the existing close(), which continues to make no
  decision at all (doc 04 §4 invariant 7: closing the window is
  neither approval nor rejection).
- McpBanner.tsx: "Requested by «client»" + source + truncated content
  hash + the enable-defaults-off note, using the warning design
  tokens. Renders script-controlled/client-controlled strings as
  plain text (verified with an injection probe using an <img
  onerror> payload as the client name — no HTML is parsed).
- InstallActions.tsx: onMcpReject prop renders an explicit Reject
  button (distinct from Close) only for MCP-sourced requests.
- Non-MCP install flow verified unaffected: existing 21 useInstallData
  tests plus 10 existing InstallActions tests all still pass
  unmodified, plus a new explicit regression test confirming a
  non-MCP install still calls scriptClient.install and never touches
  mcpClient.

16 new tests across useInstallData/McpBanner/InstallActions (137/137
install-page tests total). Full suite green (298 files / 3223 tests);
typecheck, eslint, and both build profiles (default and
SC_ENABLE_MCP=true) all clean.
Human-facing confirmation page opened by McpApprovalService.decide()'s
caller (mcp/approval.ts already navigates to mcp_confirm.html?op=<id>).
Reads the operation via mcpClient.getOperation, renders per doc 07 §5:
enable/disable get a standard approve/reject pair, delete requires a
press-and-hold confirm to avoid one-click destructive approval.

Scoped to kind "enable" | "disable" | "delete" only. "source_disclosure"
is not rendered here because no backend pending-operation flow exists
for it yet — scripts.source.get in mcp/bridge.ts reads and returns
source directly with no consent gate, unlike install/enable/disable/
delete which go through McpApprovalService. Documented as a known
follow-up rather than building UI for a flow the backend can't emit.

Wired into rspack.config.ts: both the entry and the HtmlRspackPlugin
registration are conditional on enableMCP, so store profile builds
never emit mcp_confirm.html/.js — verified via before/after build output
inspection (file absent under default build, present under
SC_ENABLE_MCP=true).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Implements the pairing flow doc 05 §5.4 describes: McpController now
handles native `pair.request` (stores it as a 2-minute-TTL pending
pairing, broadcasts mcpPairingRequested, opens mcp_confirm.html?pairing=
<id>) and `client.sync` (mirrors the host's authoritative client list —
including tokenHash, which the extension never mints itself — into
McpClientDAO). decidePairing() sends `pair.decision` to the host, which
mints the token/clientId on approval and reports back via client.sync.

McpUIService exposes `pendingPairing`/`pairingDecision` endpoints
(previously deferred with "would have nothing to call yet" — this
commit is that call). mcp_confirm/App.tsx gets a new McpPairingView:
client name, 8-char verification code, a scope checklist (read scopes
pre-checked only if requested, write scopes and source-read always
unchecked by default per doc 07 §3), and a 2-minute countdown that
auto-rejects at zero.

Scope trim, documented in McpController.onPairRequest: this commit
always opens the mcp_confirm popup for pairing, never the in-page
options-tab dialog doc 05 §5.4 also describes — detecting an open
options tab needs its own chrome.tabs plumbing for no security benefit
over the popup, so it's deliberately deferred rather than building a
second surface for the same decision.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
… wire CI

Rewrites packages/native-messaging-host/PROTOCOL.md and THREAT-MODEL.md
from scratch against what's actually implemented in this branch (the
prelim PROTOCOL.md described a different, HTTP-based design that no
longer exists). Adds docs/store-review/mcp.md, assembling the data-flow
diagram, tool privilege table, consent-surface descriptions, token/audit
model, and kill-switch/rollback story a store reviewer would need —
explicitly marks the two items this session couldn't produce (consent
screenshots, a demo recording) as not yet captured rather than claiming
they exist.

Adds a "Build profiles & MCP gate" subsection to docs/develop.md and
indexes all three new docs in docs/README.md and the doc-maintenance
"Doc set & responsibilities" table, per docs/DOC-MAINTENANCE.md's own
checklist. Verified every new relative link resolves.

CI: adds `native-host` (ubuntu/macos/windows matrix, Node 20 — builds
and tests the standalone packages/native-messaging-host package with
its own lockfile) and `pack-profiles` (asserts store-stable/store-beta
builds contain no nativeMessaging/MCP strings and the developer profile
does, using a throwaway signing key generated in-job — no real signing
secret is available or needed for this verification-only pack run).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…Firefox

Closes two doc 08 gaps found in review: doc 08 §9's "no-listener"
structural proof (assert the native host never opens a TCP port — only
a Unix domain socket / named pipe) and §7's "no outbound network" static
check (no http/https imports, no fetch() calls in production source).
Added ipc.test.ts case asserting server.address() returns a string path
rather than a {port} object, plus a new structural.test.ts doing
source-level grep-style checks across the whole package.

Also fixes a real gap: doc 01's non-goals note that Firefox exposes
chrome.runtime.connectNative too, so the MCP controller "must degrade
gracefully (feature hidden)" there, but the existing gate only checked
`typeof chrome.runtime?.connectNative === "function"` — true on Firefox
as well. Added an explicit isFirefox() check to the service-worker
construction gate and to both places the Tools settings card is offered
(categories.ts, Tools/index.tsx), with new tests for the categories
gating logic.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Coverage was previously auth 86.84%/broker 91.11%/native 88.23% branch
— under doc 08 §11's ≥90% line+branch target for the security-core
modules. Closed the real gaps found by inspection, not just chasing the
number:

- token-store.ts: load()'s non-ENOENT rethrow was untested (a real
  permission-denied error was silently swallowed as "no file" without
  a test proving otherwise); touchLastUsed/updateScopes on a missing
  clientId had no test for the early-return/no-persist path.
- challenge.ts: verifyMac's catch block (Buffer.from throwing on a
  runtime-mismatched candidateMac, e.g. parsed-JSON null) was
  unreachable by any existing test — the "invalid hex string" test
  actually exercises the length-mismatch branch, not the catch.
- pairing.ts: resolve() on an unknown/already-resolved pairingId had
  no test for its no-op branch.
- broker/server.ts: getSession() — used by host.ts and
  pairing-decision.ts — had zero test coverage of its own.
- native/channel.ts: feed() dropping a PARSE_ERROR/OVERSIZE frame
  without disrupting the stream, and double-unsubscribing from
  onMessage(), were both untested.
- broker/ipc.ts: the Windows named-pipe branch only runs on win32;
  added ipc.win32.test.ts (separate file — vi.mock("node:net") is
  file-scoped and would have broken ipc.test.ts's real Unix-socket
  tests) so it's covered on every OS, not only the Windows CI leg.

auth/broker/native now at 97.36%/92.22%/94.11% branch respectively.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Neither entrypoint had any test coverage — every module they call is
unit-tested individually, but main() itself runs unconditionally at
module load (main().catch(...) at the bottom of each file), so
importing either file in-process would launch the real host/shim
rather than let a test exercise one code path in isolation.

Exercised as real subprocesses against the built dist/host.js and
dist/shim.js instead — this is the automated equivalent of doc 09 §2's
reviewer script (`node dist/host.js --doctor`) and §3's manual smoke
test step 1, with each run isolated to a fresh HOME/LOCALAPPDATA temp
dir so it never touches the developer machine's real ScriptCat config.
Covers host.ts's --print-manifest (valid, missing-extension-id, and
invalid-extension-id cases) and --doctor (all four check lines), plus
shim.ts's "no credentials yet" early-exit path — the one shim.ts branch
testable without a live broker socket; the rest of shim.ts's behavior
(--pair, authenticated run) is already integration-tested at the
SessionHandler/BrokerServer/SocketClient level.

Both test files skip cleanly (describe.skipIf) when dist/ hasn't been
built yet, so a plain `pnpm test` without a prior `pnpm build` doesn't
fail confusingly — the native-host CI job already runs build before
test, matching doc 08 §10.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Two real bugs found while writing installer.test.ts (doc 08 §8, doc 09
row 15 — install.sh/uninstall.sh had zero behavioral test coverage
before this commit, only manifest-gen.ts's pure function was tested):

1. install.sh used `declare -A` (bash 4+) for its per-browser directory
   map. macOS ships bash 3.2 as /bin/bash (and therefore as whatever
   `#!/usr/bin/env bash` resolves to for most users) for licensing
   reasons and hasn't updated it in over a decade — so this installer
   would fail outright on stock macOS, exactly the platform doc 06 §5
   "macOS (install.sh)" targets. Replaced with a `case`-based
   browser_dir() function, portable back to bash 3.x.

2. --rollback (doc 06 §5 "Upgrades": "keep previous version dir for
   rollback (--rollback restores prior manifest)") was documented but
   never implemented in either install.sh or install.ps1. Implemented
   for both: installing over an existing install-metadata.json now
   records the superseded version as `previous` (version/installDir/
   launcher [+manifests/browsers on Windows, where each browser's
   manifest lives at a version-specific path]); `--rollback` restores
   each registered manifest to point at the previous launcher (POSIX:
   regenerated from the extension IDs recovered from the current
   manifest's allowed_origins, since the shared per-browser manifest
   path gets overwritten on every install; Windows: the previous
   manifest file was never overwritten in the first place, so this is
   just re-pointing the registry value) and never deletes the newer
   version's install dir.

installer.test.ts drives the real scripts as subprocesses against a
fake HOME — install/uninstall happy paths, invalid extension ID,
unknown browser, permissions (0600 manifest, 0700 install dir), no-op
uninstall, and the full upgrade-records-previous / rollback-restores
round trip (seeded with a synthetic "previous version" fixture, since
install.sh's VERSION is read from this package's own package.json with
no override flag). install.ps1/uninstall.ps1 aren't covered here — no
PowerShell interpreter in this environment — but exercised by the
Windows leg of the native-host CI matrix building/running this package.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…90% bar

The extension-side security-core modules doc 08 §11 names alongside
auth/framing were never actually checked in this pass until now:
bridge.ts was at 51% branch coverage, approval.ts 78.57%, url_policy.ts
86.04% — well under the ≥90% target, with real gaps, not just numbers:

- bridge.ts: the per-action VALIDATORS table (doc 03 §1's "unknown
  properties rejected", doc 08 §2's INVALID_REQUEST matrix) was almost
  entirely untested on its rejection paths — non-object input, unknown
  fields, malformed enable/url/code/operationId — across 9 actions.
  scripts.toggle.request/delete.request/operations.list/cancel had no
  dispatch coverage at all (only list/metadata.get/source.get/
  install.prepare were exercised). Added a parameterized matrix plus
  per-action edge cases and the four missing dispatch tests.
- approval.ts: the URL-based install path only had "rejected before
  fetch" coverage — a successful URL fetch, a mid-fetch policy
  violation (redirect to a private host), an oversize download, and a
  non-UrlPolicyViolation error (network failure, must propagate
  unwrapped rather than being mis-wrapped as a URL rejection) were all
  untested. Also added: decide/getOperation/getOperationForUI/
  cancelOperation NOT_FOUND on a nonexistent operationId,
  cancelOperation CONFLICT on an already-decided operation, a staged
  install's TempStorageDAO entry vanishing before approval (CONFLICT),
  a toggle target script deleted entirely before approval (CONFLICT,
  not treated as a hash match), and the executeApproved default branch
  for a kind with no real create path (source_disclosure/update).
- url_policy.ts: IPv6 multicast (ff00::/8) and a public/global IPv6
  address were untested; fetchInstallSourceWithPolicy's non-streaming-
  body fallback (resp.text()) had no coverage at all, including its
  own oversize check.

bridge.ts/approval.ts/url_policy.ts now at 84%/94.04%/95.34% branch.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
… gate

Closes the one remaining backend gap acknowledged since the pairing
commit: scripts.source.get previously read and returned script source
directly once a client held scripts:source:read, with no per-client
consent step — contrary to doc 02 §4.2's "first use per client" model
and the tool description in packages/native-messaging-host/src/shim/
tools.ts, which already promised this behavior.

McpApprovalService.checkSourceDisclosure() gates every source read: a
client with a permanent "allow for this client" grant (McpClient.
sourceDisclosureAllowed) or a just-approved one-shot operation for the
exact (clientId, uuid) pair proceeds; everyone else gets a new pending
McpOperation (kind "source_disclosure", same TOCTOU/TTL/idempotency
machinery as install/toggle/delete) and the bridge call returns
USER_APPROVAL_REQUIRED with the operationId instead of the source.
decide()'s new `rememberChoice: "once" | "client"` option controls
whether the grant is one-shot (consumed by the very next read via
checkSourceDisclosure's own expiry-on-consume) or persisted to the
client record.

McpConfirmView (mcp_confirm/App.tsx) renders the "source_disclosure"
kind with the three options doc 07 §5 specifies — Deny / Allow once /
Allow for this client — reusing i18n keys that were already present in
the locale files from the pairing-commit pass. No native-host or shim
changes needed: USER_APPROVAL_REQUIRED and the source_disclosure kind
already existed in the shared protocol/types, and the shim's generic
error-to-tool-result mapping already surfaces operationId for any
bridge error code.

Updated two bridge.test.ts cases whose old behavior (unconditional
direct source return) was exactly the bug being fixed, plus
PROTOCOL.md and docs/store-review/mcp.md to describe the real gate
instead of listing it as a known gap.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Closes the other deliberately-deferred item from the pairing commit:
doc 05 §5.4 says "if the options page is open, show dialog in place;
otherwise open a focused popup window" — the earlier commit always
opened the popup, on the reasoning that detecting an open options tab
needed its own chrome.tabs plumbing for no clear security benefit.
Implemented properly now since correctness relative to the spec
outweighs that plumbing cost.

McpController.onPairRequest queries chrome.tabs for an open
src/options.html tab before opening the mcp_confirm.html popup; if one
is open, it skips the popup and relies solely on the existing
mcpPairingRequested broadcast, which now has a real in-page listener.

Extracted the pairing decision state machine (fetch, scope defaults,
countdown, decide()) out of mcp_confirm/App.tsx's McpPairingView into a
shared usePendingPairing hook, plus the scope-checklist/code/countdown
JSX into PairingFields.tsx — both the standalone popup and the new
McpSection.tsx in-page McpPairingDialog (a Dialog, not a full page)
render from the same hook and field components rather than duplicating
the logic. The hook's decide() now returns a promise and accepts an
onDecided callback so each surface can react its own way (close the
popup window vs. dismiss the dialog and refresh the client list).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Task-oriented companion to PROTOCOL.md/THREAT-MODEL.md/store-review —
those explain the design and security rationale, this explains how to
actually get an AI agent connected and using it: build with
SC_ENABLE_MCP=true, build+register the native host (install.sh usage,
--doctor verification, upgrade/rollback), enable the bridge, pair a
client (--pair/--scopes flags, verifying the code, the scope
checklist), register it in an MCP client config, and turn on write
mode. Includes a tool table and five worked case studies grounded in
the actual approval flows: read-only listing, the source-disclosure
gate (once vs. permanent), toggle approval with TOCTOU re-verification,
hold-to-confirm delete, and client revocation — plus a troubleshooting
table for the states the settings card can show.

Every concrete claim (CLI flags, default pairing scopes, credential
paths, audit-event fields, hold duration) was checked against the
actual source rather than written from the design docs' aspirational
language. Linked from docs/README.md and docs/DOC-MAINTENANCE.md's
doc-set table.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…omments

workspace/.ref-docs/ is gitignored planning material that never ships
in this repo's history — comments citing "doc 03 §4", "doc 04 §7",
etc. point PR readers at files they can't open. Rewrote every such
comment across src/app/service/service_worker/mcp/, src/app/repo/mcp.ts,
the install/mcp_confirm/Tools UI, and scriptInstall.ts to state the
rationale inline instead of citing a section number. Two were also
stale beyond the citation and got corrected along the way: types.ts's
"entities move to repo/mcp.ts in the next commit" (they already moved)
and url_policy.ts's claim that install fetches go through
`fetchScriptBody` (they call the global fetch directly — verified by
reading the code, not assumed).

No behavior change; comment-only.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…uction code

Same cleanup as the extension-side pass, applied to
packages/native-messaging-host/src/{shared,auth,broker,native,shim,
installers/lib}/*.ts and the host.ts/shim.ts entrypoints (test files
still to follow). Every comment citing "doc 03 §4", "doc 04 §7", etc.
now states its rationale inline. Also softened a couple of "the
prelim's ..." references (framing.ts, manifest-gen.ts) encountered
along the way — they described a previous, removed implementation
that no longer exists in this repo's history either, so citing it was
equally unhelpful to a PR reader.

protocol.ts's header now points at packages/native-messaging-host/
PROTOCOL.md — a real, committed spec doc — instead of the gitignored
planning file it previously cited.

No behavior change; comment-only. Verified with a full build + test
run (27 files / 214 tests) after the edits.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
@CodFrm CodFrm added the P0 🚑 需要紧急处理的内容 label Jul 16, 2026
@CodFrm

CodFrm commented Jul 17, 2026

Copy link
Copy Markdown
Member

准备按以下方案重构:

MCP 客户端 ─ stdio ─→ sctl mcp ─┐
CLI(sctl scripts / install …)─┤→ sctl serve(Go 常驻进程,WS 仅监听 127.0.0.1:8643)
                               ↑ WebSocket(扩展作为 client 主动连接)
                        ScriptCat(offscreen WS client → SW 审批/授权)
  • 本地进程用 Go 单二进制 sctl(新仓库),定位是通用 CLI 工具,MCP 只是其中一个前端;单文件分发,不再需要安装脚本和 NM manifest
  • 扩展只做 WS client,自身不监听端口、不加 nativeMessaging 权限;因此商店版可以直接内置该功能(默认关闭),本 PR 的商店构建隔离机制也就不需要了
  • 安全边界重建:扩展↔sctl 用一次性配对码建立互信,之后每次连接双向 HMAC 挑战应答;MCP 客户端的配对 / scope / token hash / 限流 / 撤销沿用本 PR 的模型,移入 Go daemon
  • 扩展侧审批模型基本原样保留(两阶段确认、批准瞬间哈希复核、源码披露独立授权、安装默认禁用),另增加全局写策略设置:需人工审批(默认)/ 直接允许,可按客户端覆盖
  • 写操作改为纯阻塞:调用挂起直到用户在确认页决策;请求方断开/超时即自动作废、确认页失效,因此 operations.get/list/cancel 移除,bridge action 从 9 个缩到 6 个
  • v1 范围:仅 loopback(远程 wss 后续单独设计)、单扩展实例配对

@CodFrm

CodFrm commented Jul 17, 2026

Copy link
Copy Markdown
Member

这是干什么? 改错了?

23861a3

@cyfung1031

cyfung1031 commented Jul 17, 2026

Copy link
Copy Markdown
Collaborator Author

这是干什么? 改错了?

23861a3

之前的 test 不通过呀
可能是merge main 问题吧

Screenshot 2026-07-17 at 18 32 05

All four failing jobs (lint, test-shards, tests, e2e) now run Setup pnpm (corepack enable/install) before Use Node.js/actions/setup-node@v6, so pnpm is on PATH when setup-node's cache: pnpm option tries to resolve the store path. YAML parses cleanly.

Root cause: In .github/workflows/test.yaml, the lint, test-shards, tests, and e2e jobs ran actions/setup-node@v6 with cache: "pnpm" before the Setup pnpm step that runs corepack enable && corepack install. That option needs pnpm on PATH to resolve the cache directory, but pnpm wasn't installed yet — hence Unable to locate executable file: pnpm. The two passing jobs (native-host, pack-profiles) already had the correct order (pnpm/action-setup before setup-node), which is why they succeeded.

Fix: reordered the Setup pnpm step to run before Use Node.js in all four broken jobs, matching the working jobs' pattern. Verified the YAML still parses. Not pushed — let me know if you want this committed/pushed to the PR branch.

CodFrm added 4 commits July 17, 2026 17:38
Task#3(删 native host 包 + 商店隔离)与 Task#4(协议层重写)在类型层耦合,合并提交:

删除(Task#3):
- packages/native-messaging-host/ 全量(68 文件)
- 商店构建隔离:rspack 模块替换/pack profile/build-config/CI 矩阵/store-review 文档
- EnableMCP 编译开关 → 纯运行时 mcp_enabled;manifest 去 nativeMessaging

协议层重写(Task#4):
- 新增 src/app/service/service_worker/mcp/protocol.json 作常量单源(与 sctl 镜像)
- BRIDGE_ACTIONS 9→6:去 operations.get/list/cancel,install.prepare→install.request
- envelope 类型:+auth.challenge/response/ok +bridge.cancel,-operations.changed(→14)
- HelloPayload hostVersion→daemonVersion+protocolVersion
- bridge.ts 去 operations.* 校验/分发/OWNERSHIP_GATED,scope 一律校验
- approval.ts 去客户端轮询 getOperation/listOperations/cancelOperation(保留确认页 decide/getOperationForUI)
- conformance 测试改为比对 protocol.json(替代已删的 host 包双模块比对)
- 测试同步:去 operations.* 用例、install.prepare→install.request、hello 字段名

138 个 mcp 测试全过;controller 传输层(connectNative→WS)留待 Task#5
…saging

- 新增 offscreen/mcp-connect.ts:offscreen 独占到 sctl 的 WS 连接(epoch 重连+指数退避),
  WebCrypto 双向 HMAC 握手(会话/配对双模)、HKDF 派生、AES-GCM 解密下发密钥、ping/pong;
  crypto 常量取自 protocol.json 单源;放 offscreen 以在数分钟审批期保活连接(MV3 会休眠 SW)
- controller.ts:去 connectNative/NATIVE_HOST_NAME,改为驱动 offscreen client 并消费转发信封;
  bridge.request→原 bridge.ts 分发不变;新增 pair(code) 与配对密钥持久化
- types.ts:NativeEnvelope→WSEnvelope、NATIVE_MESSAGE_TYPES→WS_MESSAGE_TYPES、
  MIN_HOST_VERSION→MIN_DAEMON_VERSION,补 auth.* 载荷类型;conformance 随改(值不变)
- config:新增设备本地 mcp_url / mcp_write_policy / mcp_pairing(均入 STORAGE_LOCAL_KEYS)
- offscreen/client.ts + service_worker/client.ts:SW↔offscreen 双向中继(fire-and-forget)
- typecheck 洁净,mcp+offscreen+config 190 测试全过;握手线格式与 sctl 侧 node:crypto 对拍逐字节一致

Task #5。bridge.cancel 作废、写策略接入留待 Task #6;设置页/对话框 UI 留待 Task #7
写审批阻塞化(Task#6):
- approval.ts 事件驱动 response(不悬挂 SW Promise)、decide/cancel 单闸串行仲裁先到先得、
  present/reopen 串行展示 + 误关重开;bridge.ts 写策略分支(approval 挂起 / allow 立即+通知,
  源码披露不豁免);controller.ts bridge.cancel 作废 + sendBridgeResponse 回发;source.ts 共享读源码
- repo/mcp.ts McpOperation.requestId + byRequestId/awaitingUser 查询

sctl-cli 内建身份(§3.1/§5/§6):
- bridge.ts 认 clientId="sctl-cli" 为全量 scope 合成身份(不落库→不可撤销 / 不入 client.sync),
  写操作仍过写策略 + 写会话 + 确认页;approval.ts 源码披露对 sctl-cli 豁免;附单测

设置 UI(Task#2):
- McpSection:本地桥接(地址 + sctl 配对入口)、写会话 / 写策略双开关(允许直连琥珀警示)、
  待确认重开列表、客户端 scope 徽章 / 撤销、审计导出 / 清空、全部撤销并停止;设计令牌 + notify
- 新增 pair / getPendingOperations / reopenOperation SW 端点接线;config mcp_url / mcp_write_policy

i18n:
- 清理原 Native Messaging 死键(host_* / doctor_hint / update_host);补齐 7 语言包 mcp.json
  (de/ja/ru/tr/vi/zh-TW,与 en-US 逐键对齐,zh-TW 台湾术语)
- 顺带修 Tools 导航 5→6(MCP 卡片常显)+ index.test.tsx MCPClient mock

typecheck 洁净;mcp+mcp_confirm+Tools+config+locales 264 测试全过。
写会话开关与 mcp_write_policy 暂正交保留(是否合并待用户定);弹窗 §9 restyle + 768px 为后续跟进。
- mcp-bridge-guide.md + _zh-CN:重写为 sctl/WS 流程——装 sctl 二进制、sctl serve、
  扩展↔daemon 配对(sctl pair 填码)、MCP 客户端配对(sctl mcp pair --name)、CLI 动词(退出码 0/1/2/3)、
  6 个 MCP 工具(scripts_*)、写模式 + 写策略、阻塞语义、版本门槛(ldflags 0.1.0)、案例与排障
- README.md 索引:MCP 分区去掉「SC_ENABLE_MCP 构建限定」失效表述与 4 条死链
  (native-messaging-host/*、store-review/mcp.md 均已删),改指 sctl 仓 PROTOCOL/THREAT-MODEL + 本仓 protocol.json
- develop.md:「构建门」小节改写为运行时单门(mcp_enabled),不再有 SC_ENABLE_MCP / pack:dev / 商店构建隔离
- DOC-MAINTENANCE.md 文档清单:删去 store-review/mcp.md 与 native-messaging-host/README 两条死条目

全仓 docs 再无 native-messaging-host / SC_ENABLE_MCP / scriptcat-native-host 等失效引用,新链接全部可解析。
@CodFrm CodFrm added this to the 2026七月 Milestone milestone Jul 18, 2026
@daiaji

daiaji commented Jul 19, 2026

Copy link
Copy Markdown

这还挺好的,tampermonkey的MCP虽然非常废物,但改改还是能跑通工作流。
让AI帮你写脚本,调试脚本、修BUG,还是很爽的。

CodFrm and others added 4 commits July 20, 2026 19:08
# Conflicts:
#	docs/DOC-MAINTENANCE.md
#	pnpm-lock.yaml
#	src/app/service/service_worker/index.ts
#	src/pages/options/routes/Tools/index.test.tsx
保留 cyfung1031 的提交历史,冲突按「WS 方案为准」解决:

- 8 个 mcp.json 取 WS 版本:PR head 侧是 Native Messaging 时代的旧版(69 keys,
  含 update_host / host_not_found / setup_instructions 等安装器文案),缺 22 个
  写策略相关新 key,与重写后的 en-US 对不上;
- service_worker/index.ts 取 WS 版本:PR head 的 McpBridge 构造是重写前的签名;
- DOC-MAINTENANCE.md 取 WS 版本:PR head 引用的 store-review/mcp.md 与
  packages/native-messaging-host/README.md 均已随方案删除,合入会留死链;
- test.yaml 取 main 版本:scriptscat#1575 已统一 Corepack/setup-node 配置。

顺带清掉 WS 重构遗留的死配置:tsconfig 的 exclude 与 vitest 的 BASE_EXCLUDE
仍指向已删除的 packages/native-messaging-host。
CI 按 lockfile 装 prettier@3.6.2,与本地 node_modules 里被抬升到的 3.9.5 对联合
类型换行的意见相反,故本地 pre-commit 未拦下。
@daiaji

daiaji commented Jul 20, 2026

Copy link
Copy Markdown

https://github.com/daiaji/scriptcat/tree/local/mcp-bridge
我姑且在firefox尝试了一下,确实工作。
合并了Firefox MV3 支持就是了,为了双手离开键盘,对Agent开放了更多能力。
但具体是否合理就另外一说。

@cyfung1031

Copy link
Copy Markdown
Collaborator Author

https://github.com/daiaji/scriptcat/tree/local/mcp-bridge 我姑且在firefox尝试了一下,确实工作。 合并了Firefox MV3 支持就是了,为了双手离开键盘,对Agent开放了更多能力。 但具体是否合理就另外一说。

这 PR 提供了一个完整的安全设计架构
CodFrm 则不想引入 nativeMessaging 权限; 扩展只做 WS client, MCP Server 端 用Golang .

要结合两者才行喔
不是一个随便的 MCP 架构就行。那是非常危险的做法

@daiaji

daiaji commented Jul 20, 2026

Copy link
Copy Markdown

https://github.com/daiaji/scriptcat/tree/local/mcp-bridge 我姑且在firefox尝试了一下,确实工作。 合并了Firefox MV3 支持就是了,为了双手离开键盘,对Agent开放了更多能力。 但具体是否合理就另外一说。

这 PR 提供了一个完整的安全设计架构 CodFrm 则不想引入 nativeMessaging 权限; 扩展只做 WS client, MCP Server 端 用Golang .

要结合两者才行喔 不是一个随便的 MCP 架构就行。那是非常危险的做法

行吧。

CodFrm added 4 commits July 20, 2026 23:03
PROTOCOL §4/§5 规定 requestId 属于 envelope 层,bridge.request 的 payload 不含
该字段、bridge.cancel 的 payload 是空对象。扩展侧却从 payload 读它,取到
undefined,回发的 bridge.response 便带着空 requestId —— daemon 在 pending map
里匹配不到挂起的调用,静默丢弃,调用方一直阻塞到 TTL(5 分钟)才报「操作已
作废或超时」。CLI 与 MCP 客户端的所有读写动作因此全部不可用。

真实链路验证:修复前 `sctl scripts list` 30s 超时无响应;修复后 1.6s 返回结果,
写动作能正确弹出确认页并让 CLI 阻塞等待裁决。

单元测试此前全绿是因为测试同时在 envelope 与 payload 两处放了 requestId,
从哪读都对,恰好掩盖了这个契约错误;现改为按真实线格式构造(payload 不含
requestId),并补了两条钉住该契约的用例。

顺带删除 BridgeCancelPayload —— 它声明的 { requestId } 与协议不符,且已无引用。
bridge.handle 对 sctl-cli 走合成身份,但 approval.decide 直接查 McpClientDAO。
CLI 身份从不落库,于是人点了批准之后写操作反被判成 client revoked,
每一次 sctl install/enable/disable/rm 都在审批之后失败。

把身份解析收敛到 client_identity.ts 的 resolveMcpClient,请求准入与批准
链路共用一处,确认页/待确认列表也因此能显示请求方为 sctl (CLI)。
写会话只有 setWriteSession 写端点,没有读端点:页面 useState(false) 起手,
刷新后开关显示为关而写权限实际仍开着——用户会据此以为自己已经收回了授权。
补 writeSession 读端点,权威来源是 chrome.storage.session。

确认页的批准按钮对 enable/disable 复用同一个 enable_confirm,禁用请求读作
「禁用该脚本?[启用]」。改为按 kind 取 confirm_enable_action /
confirm_disable_action,10 个语言包同步补键。
@CodFrm CodFrm changed the title ✨ 安全重构 ScriptCat MCP 桥接:分级授权、人工确认与商店构建隔离 ✨ 安全重构 ScriptCat MCP 桥接:sctl 本地守护进程、分级授权与人工确认 Jul 20, 2026
@CodFrm

CodFrm commented Jul 20, 2026

Copy link
Copy Markdown
Member

让AI完成了框架,还会进行大幅调整


传输层已改为 sctl 本地守护进程(WebSocket),描述待更新

⚠️ PR 描述仍是 Native Messaging 时期的版本,与当前实现不符,评审时请以本条为准,描述会另行更新。

主要变化:

  • 不再使用 Native Messaging,改为独立的 Go 守护进程 sctl(仓库 scriptscat/sctl),监听 127.0.0.1:8643,扩展侧由 offscreen 持有 WebSocket 客户端。
  • 因此描述里的「商店构建隔离」「Windows installer 手动验证」「Native Host CI 检查」等条目均已不适用——单文件 Go 二进制,不需要安装器,也不需要按构建产物做隔离。
  • 认证为双向 HMAC-SHA-256 挑战应答:配对码经 HKDF 派生 Kp_mac/Kp_enc,配对成功后 daemon 用 AES-256-GCM 下发长期密钥,此后走 session 模式。
  • 协议单源:protocol.json 在两个仓库中逐字节一致,由 protocol.conformance.test.ts 与 sctl 侧的 protocol-drift CI 双向盯着。

本轮联调发现并修复的问题

用真实扩展 + 真实 daemon 跑端到端(不是单测 mock)时,发现三个此前没有覆盖到的缺陷:

问题 根因 影响
CLI 写操作在人点了批准之后失败 bridge.handlesctl-cli 走合成身份,但 approval.decide 直接查 McpClientDAO;CLI 身份从不落库,于是被判成 client revoked sctl install/enable/disable/rm 全部在审批后失败
调用永不返回,一直等到 5 分钟 TTL requestId 在 envelope 层(PROTOCOL §4),扩展却从 payload 读,取到 undefined,daemon 匹配不上挂起调用 所有 bridge 调用超时
写会话开关刷新后显示为「关」 只有 setWriteSession 写端点,没有读端点,页面从 useState(false) 起手 安全方向错误:实际写权限仍开着,用户会以为已经收回

另外两处 UI 缺陷一并修掉:配对成功后状态胶囊不刷新(没订阅 mcpStatusChanged)、禁用确认页的批准按钮文案显示为「启用」(enable/disable 复用了同一个 i18n key)。

前两个问题单测没抓到,原因是既有用例都用库里存在的配对客户端(client-1),并且把 requestId 同时放在了 envelope 和 payload 两处——两条 CLI 实际走的路都没被覆盖。已补上对应的失败测试。

验证

本地端到端验证(真实扩展 + 真实 daemon,含录像与截图):

  • sctl scripts list 读路径正常返回
  • sctl disable <uuid> → 确认页自动弹出 → 点批准 → exit=0,且回查 scripts list 确认 enabled 真的变为 false
  • sctl rm <uuid> → 长按确认(1.5s)→ exit=0,且脚本真的从列表消失
  • 审计日志完整记录 5 条 sctl (CLI) 事件
  • 写会话开关刷新后仍为开,与实际权限一致

断言刻意不只看 CLI 退出码,而是回查 scripts list——否则「回了个 ok 但没真正执行」同样会是绿的。

单测 3681 passed / 321 files;pnpm lint(prettier + tsc + check:i18n + eslint)全过。

daemon 侧 Phase 2 已完成并发布到 scriptscat/sctl:安全审计事件环形缓冲(记录扩展看不到的握手失败/限流/配对失败)、goreleaser 发布流水线、protocol.json 单源比对 CI。

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

Labels

P0 🚑 需要紧急处理的内容

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants