✨ 安全重构 ScriptCat MCP 桥接:分级授权、人工确认与商店构建隔离#1573
Conversation
… 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>
… files Completes the citation cleanup in packages/native-messaging-host — every describe()/it() title and comment across the package's test suite that cited "doc 03 §4", "doc 04 §7", etc. now either drops the citation (where the title was already self-explanatory) or states the rationale inline. Also touched two adjacent "prelim"-referencing comments in manifest-gen.test.ts (a previous, removed implementation no PR reader has access to) and corrected a factual slip introduced along the way: the invalid test ID "fomrtutthjerocmw" is invalid because of its r/t/u/w characters specifically, not the f/o/h/c/m ones my first pass wrongly listed. Verified with a full build + test run in both the native-host package (27 files / 214 tests) and the root extension workspace (301 files / 3313 tests) after the edits. No behavior change; comment-only. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Final sweep of installer scripts, docs, build configs, and test files missed by the earlier three cleanup commits, plus four leftover references to the removed prelim implementation branch. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…guide Adds a developer README (+ zh-CN translation) for the previously undocumented native-messaging-host package, a zh-CN translation of the MCP bridge usage guide, and a GPLv3 LICENSE pointer for the package. Wires all four into docs/README.md and DOC-MAINTENANCE.md's doc-set table, and adds a language switcher to the English bridge guide. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
|
我想用go来做本地的进程,考虑复用vscode的websocket,这样也方便提供远程的 |
用 node 会比较好吧。同一套ts 代码 node 跟 python3 都是可以在用户本地环境容易跑起来
我看错了。有 go 版 https://github.com/modelcontextprotocol/go-sdk 代码提交了。你拿来用吧。改成 go 或是什么的都可以。你喜欢吧。 |
AI 不建议复用直接 websocket. websocket 只是一个简单的协定,不够安全。 我觉得你可以先接受本地的吧 不然 MCP控制ScriptCat 这一块会一直是个缺口 老实说,用远程去直接控制你的本地ScriptCat不是很理想 有远程的话,你的电脑网络保安做不好的话,就会被直接攻击起来 结论维护者的原话其实是一条架构偏好,不是两个明确的 blocking review:
其中“我想用”“考虑”都比“必须修改”为弱。建议在开始大规模重写前,先把三个方案的边界说明清楚。 按可合并、保留现有安全语义的实现估算:
当前 PR 自身已经接近 140 个 changed files。因此无论选择哪一种,都应避免在现有 PR 上继续叠加没有拆分的重构。 情况一:只做(1)——本地进程改用 Go保持的架构也就是只更换实现语言,不改变当前安全边界。 当前实现是一个 Node/TypeScript 包,同时提供两个命令:
并使用官方 TypeScript MCP SDK。 需要修改的部分大约有 8 个工作组:
扩展侧的以下部分基本可以不动:
前提是 Go 实现严格兼容现有协议。 预计改动量
主要原因是当前 对当前 PR 设计的影响这是三种方案中风险最低的,因为当前 PR 的主要安全模型不依赖 TypeScript:
当前 Node 方案的缺点1. 分发不是单文件当前命令指向编译后的 JavaScript,用户仍需要可工作的 Node 环境或完整封装。 Go 可以提供每个平台的单一可执行文件,安装路径和 Native Messaging manifest 更容易稳定下来。 2. 安装器复杂当前安装器必须处理:
Go binary 可以明显简化这一部分。 3. Node 本地 IPC 无法方便地检查 peer UID当前代码明确记录:Node core 没有跨平台 peer credential API,因此 Unix socket 只能依靠目录 Go 在 Unix 平台更容易调用 4. 两个 Node 进程和多跳转发目前存在: 实现可靠,但部署、日志和生命周期相对复杂。 只做(1)的不足它完全不解决远程能力。 当前设计刻意没有 TCP listener,host 甚至声明不使用网络;这是安全设计,不是偶然限制。 因此,维护者若把“方便提供远程”视为最终目标,仅改 Go 只能解决运行时和分发问题。 判断适合作为当前 PR 的收敛方案。 它满足维护者对 Go 的偏好,同时最大程度保留已经完成的安全工作。但是需要接受这仍是一场较大的代码移植,而不是几处修改。 情况二:只做(2)——保留 Node,但复用 VS Code WebSocket这里要先明确“复用”的含义。 最可能的架构是: 这意味着 WebSocket 替换:
否则,如果只把 shim 与 host 之间的 Unix socket 改成 WebSocket,但仍保留 Native Messaging,实际上没有获得真正的远程能力。 现有 VS Code WebSocket 能复用多少现有 ScriptCat 侧连接器已经具备:
但其协议目前只处理 VS Code 端目前:
所以可以复用的是:
不能直接复用的是安全协议和 MCP 操作协议。 安全可合并版本需要的修改至少有 10 个工作组:
预计改动量在保留当前安全模型的情况下:
若只是把现有
最大缺点:安全边界发生根本变化当前 PR 的关键声明是:
这被列为“通过设计消除”的攻击入口。 改成 WebSocket 后,该结论不再成立。 网页也可以尝试连接本地 WebSocket 服务。WebSocket 的
因此必须重新建立安全模型,而不能仅说“WebSocket 不是 HTTP,所以安全”。 远程模式新增的问题1. 初次配对需要安全信道当前初次配对发生在用户级本地 socket 上,token 通过本地受权限保护的 channel 传输。 如果改为远程 WebSocket,首次 token 或 pairing 信息会经过网络。至少需要以下之一:
单纯 8 位显示码不足以承担完整的远程 MITM 防护。 2. WSS 证书体验自托管远程服务常见的是:
浏览器扩展不能随意忽略证书错误,因此需要明确的证书和部署方案。 3. 商店构建边界需要重审当前 PR 将 MCP 功能从 store build 编译排除,以避免普通用户获得 WebSocket 不需要
判断如果维护者真正想要远程能力,这个方向合理,但不能把它称为简单“复用”。 它实际上是:
情况三:(1)+(2)——Go 本地进程,同时复用 WebSocket并支持远程最合理的目标架构是: 这可以删除:
需要的工作组大约 14 个:
预计改动量
优点1. 分发体验最好Go 单文件 binary 适合:
2. 本地和远程可以共用协议只需两种安全 profile: 业务消息和审批语义保持一致。 3. 去掉 Native Messaging 权限若 WebSocket 由扩展主动建立,可以不使用 缺点1. 这是重新设计,不是处理两条小评论它会同时改变:
在一个已有约 140 个 changed files 的 PR 上继续做,审查难度会非常高。 2. 当前安全证明大部分需要重写当前 threat model 的核心前提是“没有 TCP listener”。 采用 WebSocket 后,需要重新证明:
3. 两个仓库产生协议耦合复用 VS Code WebSocket 意味着
否则任一仓库先发布都可能破坏另一个。 判断这是长期架构最统一的方案,但不适合作为现有 PR 的追加修改。 更合理的是单独做新的 architecture PR 或 RFC。 当前提交设计本身的主要缺点即使不考虑维护者意见,现有实现有以下真实代价。 1. 进程链较长优点是安全边界清楚,缺点是部署、诊断和生命周期更复杂。 2. Node 运行时和 npm 分发依赖当前 host/shim 都是 JS entrypoint,并依赖 MCP SDK 和 Zod。 这比单个 Go binary 更容易开发,但安装和供应链面更大。 3. Native Messaging host 受浏览器生命周期控制host 由 这引入了:
4. 暂不支持远程这是明确的设计结果,不是遗漏。 5. scope 动态变化尚未完整通知 MCP clientshim 收到 这可能导致:
这是当前实现里一个具体的待完善点。 6. 大 PR 审查风险PR 描述自身仍列出:
在此基础上再加入 Go 和远程 WebSocket,会显著增加无法有效审查的风险。 推荐决策当前 PR 最稳妥的处理方式选择(1)only,或者保持当前 Node 实现并把 Go 迁移拆成后续 PR。 原因是:
长期方案将(2)拆成独立设计:
不建议不建议为了减少改动,直接扩展现有 VS Code 的 |
|
考虑go是方便跨平台,做cli之类的工具,自己也熟悉一些 另外远程的话,扩展只能作为client,不是server,而且一般默认连接的是localhost,很难出现网络问题 其次考虑ws也是不想增加nativeMessaging权限 等周一再看看 |
Checklist / 检查清单
背景 / 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
nativeMessagingto normal store builds also creates an unnecessary permission and review burden for users who never use MCP.因此,本 PR 的目标不是“让原桥接可以通过审核”,而是重新定义安全边界:
The design goals are therefore:
本次改动 / What changed
1. 移除 localhost HTTP 服务,改为 stdio + OS 本地 IPC
新的数据流为:
整个功能中不再存在 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 客户端在调用工具前必须完成配对:
scripts:listscripts:metadata:readscripts:source:readscripts:install:requestscripts:toggle:requestscripts: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_installrequest_script_togglerequest_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且未过期;这避免了“用户看到 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 权限混在一起: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;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;allowed_origins使用精确 extension ID,不使用通配;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. 审计、限流、撤销和紧急停止
新增:
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.mdpackages/native-messaging-host/THREAT-MODEL.mdpackages/native-messaging-host/README.mdpackages/native-messaging-host/README_zh-CN.mddocs/mcp-bridge-guide.mddocs/mcp-bridge-guide_zh-CN.mddocs/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
:3333contentHash/existingCodeHash在批准时重新验证get_script直接返回源码contentTrust;工具描述为静态常量nativeMessaging这并不表示本 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
本分支新增或扩展了以下自动化覆盖:
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
install.ps1/uninstall.ps1/ rollback 流程;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
connectNative路径;Firefox event-page 生命周期尚未验证,UI 与 controller 会明确隐藏/跳过;建议审查重点 / Suggested review focus
contentHash/existingCodeHash/ TTL / single-shot 不变量;参考 / References
packages/native-messaging-host/PROTOCOL.mdpackages/native-messaging-host/THREAT-MODEL.mddocs/mcp-bridge-guide.md/docs/mcp-bridge-guide_zh-CN.mddocs/store-review/mcp.md再次感谢 @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.