✨ 安全重构 ScriptCat MCP 桥接:sctl 本地守护进程、分级授权与人工确认 - #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>
# Conflicts: # src/pages/options/routes/Tools/index.test.tsx
|
@cyfung1031 很高兴前期的一点探索能对项目有帮助。只有参与了才知道一个项目是有多么的繁琐,需要付出多少精力,感谢作者大大的辛勤付出,也再次致敬所有无私付出的各位开源作者。 |
「MCP 桥接」重构为「外部接入 / External Access」,收拢为工具页单卡片。 - 扁平信任:去 per-client scope/配对/撤销(删 client_identity),clientId 降为审计标签;两条全局策略(写操作 mcp_write_policy / 源码读取 mcp_source_read_policy),源码读取不再豁免 CLI - 接入(enrollment):带外配对码,controller.pair→enroll,新增 pending_enrollment + stopExternalAccess kill switch;mcp_url 默认 ws://localhost:8643 - 三档决策(拒绝/允许/本会话允许):新增 session_allow(chrome.storage.session,per-script);安装/更新复用 install.html + 即装即用,启用/停用/删除/源码走 mcp_confirm - 审计走现有 logger(component=local-access),删 McpAudit/McpClient 环形缓冲与 DAO - UI:McpSection→ExternalAccessSection 单卡片;Tools 分区 mcp-bridge→external-access;i18n 10 语言重写 - 文档:mcp-bridge-guide→external-access-guide(中英),protocol.json $comment 与 sctl 仓对齐
protocol.json 的 envelopeTypes 由扁平数组改为 {session, bridge} 两层分组(session=握手/存活/生命周期,bridge=能力 RPC),types.ts 对应拆成 SESSION_MESSAGE_TYPES / BRIDGE_MESSAGE_TYPES。扁平信任后 pair.*/client.* envelope 与 4 个 payload 接口已死,一并从 protocol.json / types.ts / conformance / offscreen relay 删除。protocolVersion 不变(线格式未动)。
|
Claude 最新MCP Server Spec |
scripts.source.get 增加可选 startLine/endLine 行窗(1-based 闭区间,越界截断,sha256 仍是全文哈希); 新增 scripts.source.grep 动作,复用同一披露闸门(同 scope scripts:source:read、同策略、同 sessionAllowKey),支持字面量/正则两档匹配,正则按行执行 + 单行长度上限 + 墙钟预算防灾难性回溯。 不复用 stringMatching,与脚本列表页搜索零共享代码。挂起操作新增 disclosure 字段记录披露形态, 使同一脚本上的整份读取与 grep 请求不再被现有去重误合并。
三处修补,均属任务 2 的边界与错误路径: - 行窗与 grep 参数此前只在 sliceLines / grepLines 内校验,而「需人工审批」策略下这两个 函数要等用户批准后才执行:一条 mode=regex 的非法正则、空 query、contextLines=11 之类 注定失败的请求会先建出待批操作并弹出确认页,客户端要等到用户点完才拿到 INVALID_REQUEST。 拆出 assertLineWindow / assertGrepParams 供 bridge 的 VALIDATORS 在受理阶段直接拒绝。 - NaN 与小数能穿过所有区间比较:contextLines=NaN 让 slice 回传命中行之前的全部行(绕过 上下文 ≤ 10 的披露约定),maxMatches=NaN 让全部命中都进不了 matches 却仍报 truncated, 行窗 NaN 则把 NaN 写进返回值。改为先要求整数。 - 开窗读取的用例声称「sha256 仍是全文哈希」却没断言 sha256,把哈希换成切片哈希也照样绿; 补上正反两条断言。
text 档此前也被套上了 4096 字符长行跳过与墙钟预算,导致压缩成单行的用户脚本在 字面量搜索下永远零命中;按设计仅对 regex 档限制回溯爆炸半径。
客户端提交 {oldText, newText, replaceAll?} 片段而非整份代码:扩展侧按顺序应用、
逐步重新校验唯一命中,拼出新全文后走 kind=update 操作交由安装页确认(内联 diff +
权限卡)。客户端不提供哈希,请求→批准之间由扩展自算的 existingCodeHash 把守。
- source.ts 新增 applyTextEdits:命中 0 次与多于 1 次给出可区分的错误消息;
替换自行实现而非 String.replaceAll,避免 newText 里的 $& / $1 被当作替换模式展开
- 受理阶段(未开确认页前)完成锚点解析与 2 MiB 上限校验,注定失败的请求不弹页
- update 操作带目标 origin:留空会让 parseScriptFromCode 抹掉脚本的更新地址
- executeInstall 仅在 options.enable 有定义时才覆盖启用状态:编辑在「直接允许 /
本会话允许」下没有开关值,无条件覆盖会把刚编辑过的脚本静默关掉
- 通知映射接线 kind=update:编辑请求不再落到 allow_notify_generic - 安装页顶栏 chip 按 isUpdate 分支,编辑/覆盖安装显示「更新请求」 - 10 个语言包按各自表达删去 source_privacy_hint / policy_source_hint 的「可能含密钥」补语
countOccurrences 按 needle.length 前进,是非重叠计数:`aa` 在 `aaa` 里只算一次, 「恰好命中一次」的保证于是静默失效,直接改掉第一处——正是内容锚定要杜绝的悄悄改 错地方。空行块 `\n\n`、连续缩进、成串括号都是日常会踩到的自重叠锚点。改为按候选 位置计数,歧义时如实报 not unique。 applyTextEdits 此前没有任何条数上限或执行预算,而每条 edit 都要全量扫描并重建整 份(至多 2 MiB)脚本。实测 800 条 replaceAll 的 39 KiB 请求让这段同步循环跑满 46 秒;按 protocol.json 的 4 MiB maxFrameBytes 可放大到约 76 分钟。它跑在 service worker——全扩展的消息总线——的受理阶段,两档写策略都会经过,且在任何人看到确认页 之前。同文件的 grep 路径为同样的理由早就配了行长上限与墙钟预算,写路径一条都没有。 补上对称的 MAX_TEXT_EDITS 与 EDIT_BUDGET_MS,时钟同样可注入。
CLI 清单里的 `sctl scripts list / info / source`、`sctl rm` 与 `--json` 在 sctl 命令面资源化后全部不存在了,照着做会直接撞 unknown command;换成 get / grep / edit / delete 与 -o/--output,并补上可省略的资源词与 grep 的字面量默认语义。 MCP 工具表补 scripts_source_grep 与 scripts_edit_request,并说明 scripts_source_get 的行窗字段、编辑的内容锚定与 100 条批量上限。 案例 2 本就是本轮能力的主场景,改写为 grep 定位 → 开窗读 → 内容锚定编辑的完整 链路:披露闸门与写入闸门各走一次,锚点对不上时在弹页之前就失败。顺带去掉「源码 可能含机密」的旧措辞,改引确认页当前实际文案。 zh-CN 同步镜像,引用的确认页文案逐字取自 zh-CN 语言包。
There was a problem hiding this comment.
综合技术审查:ScriptCat #1573 与 sctl #3
结论
建议修改,两个 PR 暂时继续处于收敛阶段。 本次审查对应仍在演进的草案:ScriptCat 9412954ada9f1a34533ef6dd592d80e29c40e43f 与 sctl b871d32a2ce7000119ff7bd472d4e3cc8c28e032。这不是发布认证。本次只有一组配对候选,不进行评分。
JSON-RPC 重构确实改善了整体设计:协议源与生成器已统一由 sctl 维护;ScriptCat 使用生成的、兼容 MV3 CSP 的 TypeScript 校验器;ScriptCat 原有的协议副本已经移除;两端都采用严格的 JSON-RPC 2.0 消息格式;两个精确 HEAD 各自的 CI 均为绿色。这些改进应当保留。
目前仍有两个会阻止合并的契约问题:
- ScriptCat 已成功执行写操作,但 CLI/MCP 仍可能收到
INTERNAL_ERROR。 已观察到两条路径。全局设为“直接允许”时,dispatchWrite()在写入完成后返回OperationStatusResult,而 sctl 期望的是对应方法的结果类型;正常批准删除时,ScriptCat 返回{uuid, name},协议声明却要求{uuid, deleted: true}。sctl 会校验成功结果,因此会在浏览器状态已经改变之后拒绝这两种结果。调用方若根据报错重试,可能重复或叠加写操作。 - 停止或断开连接并不会撤销已经持久化的待批准权限。
awaiting_user操作既没有绑定连接世代,也不会在停止外部访问或 WebSocket 断开时失效;旧确认页仍可调用decide()。取消与批准之间也没有原子的状态转换。具体竞态交错仍需 T2 复现,但缺少失效处理和会话绑定是直接可见的事实。
下一道门槛不应只是更多仓库内的绿色 CI,而应当冻结精确 SHA,通过真实 WebSocket 与浏览器边界,验证结果是否真实反映写后状态、每个请求是否收到终态、取消/断开是否彻底失效、批准范围是否准确,以及协议版本是否兼容。
范围与审查依据
- 系统: ScriptCat MV3 扩展,以及 sctl 守护进程、CLI 和 MCP 服务。
- 目标: 提供有价值的本地自动化,同时避免让回环传输、记忆式授权或生成协议变成未经审查的代码执行与源码披露通道。
- 验收条件: 本地传输经过认证;授权精确绑定操作;每个请求都有一个终态;停止/撤销会使旧权限失效;资源消耗有硬边界;CLI/MCP 报告与写后状态一致;源码与凭据得到保护;生成契约保持一致;跨平台信任锚可靠;审计完整且不记录秘密。
- 需要保留的设计意图: 外部访问默认关闭、默认需要批准、扩展掌握最终写入权、守护进程只绑定回环地址、执行前复核暂存内容和目标哈希、显式运行
sctl serve、v1 明确采用下游扁平信任,以及 v1 不包含远程/WSS 方案。 - 证据含义:
observed(已观察)表示已沿精确源码追踪;measured(已测量)表示执行过明确探针;inferred(推断)表示控制流或并发后果仍待 T2 复现;external-cited(外部引用)表示来自 GitHub 讨论或 Actions,只作为外部证据,不等于本地独立执行。
本次纳入了当前全部讨论观点,但不把讨论内容当作权威结论。icacaca 的早期探索打开了这项设计的讨论空间。CodFrm 倾向 Go 单二进制、扩展主动发起 WebSocket、不使用 nativeMessaging、v1 仅回环、写操作阻塞等待,并说明实现还会有较大调整。cyfung1031 正确地区分了 WebSocket 传输与认证/授权安全,并认为远程访问需要单独的威胁模型。daiaji 提供了 Firefox 与工作流方面的动机,同时承认扩大 Agent 能力的风险。sctl 先前的审查仅作为待验证假设,并已针对这两个精确 HEAD 重新检查。维护者报告的浏览器测试早于当前结果校验版本,因此只属于 external-cited,不能证明当前配对行为。
发现
| 严重程度 | 问题 | 证据与定位 | 影响 | 必要修正与验证 | 置信度 / 剩余风险 |
|---|---|---|---|---|---|
| 阻断 | 成功写入可能因 ScriptCat 返回错误的成功结果类型而被报告为 INTERNAL_ERROR。 |
observed + measured:直接允许路径在 ScriptCat src/app/service/service_worker/external_access/bridge.ts:385-399 返回 decide() 的结果;该结果在 approval.ts:528-546,576-605 中是操作状态,而 sctl 在 internal/daemon/bridge/call.go:88-103 按方法契约校验。精确生成校验器探针得到 direct=false, wire=true。此外,批准删除在 ScriptCat approval.ts:670-673 返回 {uuid,name},而 sctl internal/pkg/protocol/protocol.json:547-560 要求 {uuid,deleted:true}。 |
install/edit/toggle/delete 已经改变浏览器状态时,CLI/MCP 仍会报告失败。自动重试不安全,调用方看到的终态也不真实。 | 两条路径都必须返回规范的方法结果;OperationStatusResult 仅用于 UI/状态查询;删除改为 {uuid,deleted:true}。T1: 每个方法的生产端/校验端共用夹具。T2: 对所有直接允许与人工批准写操作执行真实配对测试,断言结果被接受、CLI/MCP 成功、状态只变化一次。 |
高。写入后仍可能发生传输故障,需要另行定义幂等与对账语义。 |
| 阻断 | 停止、断开与取消没有持久撤销待批准权限,也没有串行化终态转换。 | observed + inferred:ScriptCat service.ts:78-85 清除密钥和会话授权,但不清理待处理操作;controller.ts:144-184 只修改连接状态;src/app/repo/external_access.ts:12-37 的操作记录没有连接世代;approval.ts:491-545 会接受未过期的 awaiting_user 记录,取消与批准分别读取后再更新。sctl 在 docs/protocol.md:117-121,191-207 中声明断开应取消请求。 |
调用方已经失败或用户已经停止外部访问后,旧页面仍可能执行写入;取消与批准可能竞态;旧响应还可能跨越重连。 | 为每个阻塞操作记录连接/会话世代;以原子方式执行 awaiting_user -> executing/cancelled/rejected;在断开、删密钥或替换连接前先取消该世代;无世代的旧记录一律作废。T2: 使用屏障测试 stop/disconnect/cancel 与 decide 的交错,再做真实配对旧页面测试;断言只出现一个终态,撤销获胜时绝不写入。 |
缺少权限边界的证据为高;具体竞态结果仍待 T2。写入与终态持久化之间还需要崩溃恢复。 |
| 高 | 会话授权范围大于用户实际审阅的代码或披露范围。 | observed:ScriptCat session_allow.ts:16-20、approval.ts:176-202,259-292,440-449,528-535 的 install/edit 授权键没有包含内容与目标状态哈希。源码操作虽在 approval.ts:380-410 保存了精确参数,但可复用授权只按脚本区分,且紧凑确认页 src/pages/external_access_confirm/App.tsx:114-161 只显示通用提示。 |
用户批准安全的代码 A 后,同一会话中的不同代码 B 可能直接获准;批准窄范围 grep/行区间后,也可能被扩大为完整源码读取。 | 移除代码变更和原始源码披露的可复用授权。若必须保留,应把短期能力精确绑定到动作、内容/披露参数、目标身份、旧哈希、启用状态与过期时间,并显示完整规范化范围。T1/T2: 授权键夹具,以及 A→B、窄范围→完整源码的真实流程。 | 高。全局直接允许只能作为明确、持续可见的广泛绕过选项。 |
| 高 | 去重把不同 JSON-RPC 请求 ID 合并为一个操作,却只保存并回复第一个 ID。 | observed + measured/inferred:ScriptCat 在 approval.ts:176-181,259-266,382-392 去重,在 src/app/repo/external_access.ts:31-34 只保存一个 requestId,并在 approval.ts:555-570 只回复它;sctl internal/daemon/bridge/call.go:37-57 为每个 UUID 建立独立等待项。确定性探针只发出了 r1,r2 仍在等待。 |
并发或重试的相同调用会让其中一个等待到超时;取消归属与“只执行一次”也会变得含糊。 | v1 最简单的做法是:不同非空 ID 不去重。若必须合并提示,应持久化有上限的等待者集合、向所有仍有效的 ID 扇出结果、按等待者取消,并用幂等键保证只执行一次。T2: 两调用的批准/拒绝/过期/断开/只取消其一矩阵。 | 中高;完整异步行为仍待配对 T2。扇出方案会引入有界状态与崩溃恢复责任。 |
| 高 | 正则 grep 的两秒预算无法中断一次灾难性 JavaScript 匹配。 | observed + measured:ScriptCat source.ts:100-109,179-220 只在调用同步 RegExp.test() 前周期性检查时钟。隔离 Worker 中的对抗性表达式约 2.5 秒仍未结束,只能强制终止。 |
一个很小的已认证 grep 请求就能长时间阻塞特权 Service Worker,连批准、取消与保活也会受影响。 | 使用线性时间正则引擎,或把匹配放到可在硬截止时间终止的 Worker/进程;保留字面量模式和输入输出上限。T2: 通过最终隔离边界运行对抗性语料,断言硬超时以及 ping/桥接仍可响应。 | 高。需要测试语法差异和 Worker 清理。 |
| 高 | 无需批准的元数据会原样暴露可能含秘密或内部拓扑的 URL 字段。 | observed:ScriptCat bridge.ts:273-292 返回 matches、includes、excludes、connects、requires、resources;生成的 ScriptMetadata 在 generated/protocol.generated.ts:61-82 保留这些数组。 |
签名查询、userinfo、私有主机、Bearer 信息和内部拓扑可绕过单独的源码披露门槛。 | 定义脱敏元数据契约:移除 userinfo/query/fragment,或默认只返回主机/数量摘要;原始 URL 放到敏感元数据或源码策略之后。T1: 含秘密 URL 的夹具,证明默认输出不包含任何秘密子串。 | 高。名称和描述也可能敏感或具有诱导性,应继续按不可信内容处理。 |
| 高 | 破坏性操作与权限提升控件不够审慎,保存失败时也不安全。 | observed + external-cited:删除按钮在 src/pages/external_access_confirm/App.tsx:164-192 中只是普通点击,并对破坏性按钮设置 autoFocus,与 docs/external-access-guide_zh-CN.md:229-235 及 PR 讨论中的 1.5 秒长按说明相矛盾。全局直接允许在 ExternalAccessSection.tsx:78-119,157-169 中先立即生效,之后才显示警告,持久化也未等待。 |
按 Enter 或单击即可删除;误触一次策略就可能在确认前取消所有人工审查;存储失败还会造成 UI 与实际策略不一致。 | 删除破坏性自动聚焦,实现可访问的审慎确认,并提供键盘与减少动态效果的等价方式。策略提升必须先确认,再等待保存;失败时恢复旧值。T1: 焦点/计时/键盘/存储失败测试。T2: 浏览器、屏幕阅读器与策略竞态检查。 | 高。仅靠长按会排除部分行动障碍用户,必须提供同等审慎的替代方式。 |
| 高 | 确认失败会关闭唯一恢复界面,而文档所述的待批准恢复入口并不存在。 | observed:紧凑确认页与安装页拒绝处理在 external_access_confirm/App.tsx:81-90、install/useInstallData.ts:380-388 的 finally 中关闭。服务层虽在 service.ts:67-75、client.ts:538-549 暴露列表/重开能力,但没有前端调用方;指南却承诺“等待确认”入口。 |
过期、冲突或临时消息失败可能看起来已经成功,页面消失后请求又被搁置。 | 只在明确终态成功后关闭;保留类型化的行内错误与安全重试。增加待处理数量/列表、等待时长、过期时间、重开与取消,并为加载/提交/过期状态提供可访问通知。T1/T2: Promise 拒绝、慢 Worker、冲突、关闭后重开、键盘与屏幕阅读器流程。 | 源码缺口证据为高;运行时可访问性需要 T2。多请求还需确定顺序。 |
| 高 | 规范协议没有覆盖完整实际契约,也未强制版本兼容与跨仓库生成/互操作门槛。 | observed:protocol.json:98-164 允许一些最终会被 ScriptCat source.ts:24-39、bridge.ts:117-128 拒绝的跨字段组合;MCP schema 仍手写在 sctl internal/client/mcpserver/tools.go:3-44;两端只校验业务 input,没有覆盖完整包装与会话载荷(protocol-wire.ts:1-20、protocolschema/validate.go:32-71);sctl conn.go:182-202 接收 schemaVersion 却不比较;Makefile:31-43 不验证最终格式化后的 ScriptCat 副本。 |
各自 CI 绿色时,结果生产端、包装、会话、MCP 或生成副本仍可能漂移;当前写入结果错误正是实例。版本不兼容也可能先显示已连接,直到业务请求才失败。 | 扩展协议源/生成器,覆盖完整包装、会话载荷、结果、跨字段约束和 MCP schema;v1 强制精确 schema 版本与生成摘要;配对 CI 校验格式化后的消费端产物;能力确认成功前不显示已连接。T1: 共用正反例语料与漂移注入。T2/T3: 冻结 SHA 的真实进程握手与全方法往返。 | 高。兼容版本范围需要明确演进规则;v1 采用精确相等最安全。 |
| 高 | Windows 及不安全的既有覆盖目录尚未证明“仅当前用户可读”的凭据边界。 | observed:sctl docs/threat-model.md:65-91 声称文件仅用户可读,但 internal/pkg/fsutil/atomic.go:10-35、paths/paths.go:11-29、control/token.go:25-32、daemon/store/keystore.go:42-47 只使用可移植的 mode 位,没有 Windows DACL 创建/验证,也不检查不安全目录。 |
pairing.key 与 control.token 是信任锚;POSIX mode 数值不能建立 Windows 权限边界。 |
增加当前用户 Windows DACL 或系统凭据存储;各平台启动时验证所有权/访问权,对不安全覆盖目录拒绝启动。在验证完成前缩小文档承诺。T2/T3: Windows 与 POSIX 上测试全新目录和故意放宽权限的多用户目录。 | 证据缺口为高。管理员/SYSTEM 与同用户账户完全失陷仍可作为明确非目标。 |
| 中 | 扩展可连接任意 WebSocket URL,确定性的协议错误又会被归为普通连接失败并重试。 | observed + inferred:URL 经 ExternalAccessSection.tsx:172-175,253-260、config.ts:668-673、controller.ts:75-98、external-access-connect.ts:212-229 直接进入 new WebSocket,没有回环/协议/凭据检查。解码错误在 external-access-connect.ts:250-259,363-372 关闭并安排重连,controller.ts:144-147 统一映射为 host_unreachable。 |
粘贴错误或配置损坏会跨入明确推迟的远程威胁模型。用户无法区分“守护进程未启动”和“版本/schema 不兼容”,确定性错误可能陷入不透明重试。 | v1 只接受无凭据、路径和查询的显式回环 ws URL。传递类型化 protocol_error/schema_mismatch,展示本地化版本和处理建议,对确定性错误停止或严格限制重试。T1/T2: URL 表格测试和不兼容真实对端测试。 |
缺少校验的置信度高;重试循环结果仍待 T2。 |
| 中 | 审计不能完整还原生命周期,按可伪造标签限流也无法约束扁平信任下的滥用。 | observed:ScriptCat 在 bridge.ts:225-265 记录请求进入,但 audit.ts:12-36、approval.ts:491-545 没有批准流程终态事件。sctl 在 controlapi.go:91-114 接受自报标签,在 bridge/call.go:25-34 以其限流,并在 ratelimit.go:10-48 无限保留键。 |
日志无法判断请求最终是否执行;轮换标签可绕过名义配额并增加内存。标签只能是归因文本,不是身份。 | 每个操作只发出一条脱敏终态事件;增加与标签无关的会话全局配额;限制/规范标签、限制键数量并清理不活跃项。T1/T2: 生命周期事件夹具和经控制 API 轮换数千标签的压力测试。 | 结构缺口置信度高。扁平信任下,全局配额可能让一个前端影响另一个。 |
| 中 | CLI 行为和语言不符合已记录的接口。 | observed:sctl cmd/sctl/main.go:13-28 将非 ExitError 的 Cobra 参数错误落到退出码 1,与“用户拒绝”冲突;README 将断开保留为退出码 2,但 internal/cli/dispatch.go:44-59 映射为 3;internal/cli/write.go:47-57 的安装摘要会打印空版本。docs/development.md:8-15 要求简体中文,而 README 和至少 33 处 CLI 字符串仍为英文。 |
自动化无法区分拒绝、作废和参数错误;人类输出具有误导性,也违反仓库既定语言约定。 | 退出码 1 仅表示拒绝,2 表示取消/作废/断开,3 表示校验或运行错误;安装输出与 schema 对齐。统一翻译用户可见文本,或通过独立决策修改语言约定;不得翻译 flag、JSON key、错误码与协议 token。T2: 编译后二进制的帮助/错误/状态/版本/输出和退出码黄金矩阵。 | 高。操作系统启动失败可另行记录为传统退出行为。 |
| 中 | 配对过程没有持久化提交确认。 | observed + inferred:sctl 在保存前就于 daemon/bridge/conn.go:109-151 发送加密长期密钥;ScriptCat 在 external-access-connect.ts:327-345、controller.ts:137-142 切换重连密钥并异步转发持久化。 |
故障或进程退出可能使两端保存不同长期密钥,之后持续重连失败。 | 使用有界两阶段提交,或提供明确且经过测试的恢复状态:扩展先持久化并确认,再由守护进程提交/启用。T2: 在发送、两端持久化与确认的每个边界注入失败。 | 中;分裂状态仍是待故障注入验证的推断。两阶段状态需要过期与崩溃恢复。 |
| 中 | 发布证据与文档还没有形成原子的配对交接单元。 | observed + external-cited:两个精确 HEAD 各自的 Actions 为绿色,但没有真实 ScriptCat+sctl 联调任务;sctl 的 macOS/Linux/Windows 发布包都在 Ubuntu 工作流中构建和验证。ScriptCat PR 正文仍保留已废弃的 Native Messaging 叙述。仓库中没有记录本次配对、混合版本和回滚行为的清单。 |
审查者可能批准错误架构;部分发布或回滚会组合未经测试的两端;原生权限、路径、生命周期与压缩包仍未验证。 | 更新 PR 正文,说明当前拓扑、威胁模型、精确配对、证据、已知缺口、发布顺序与回滚;发布兼容性清单/摘要和混合版本矩阵;增加 macOS/Windows/Linux 原生制品冒烟测试。T3: 精确配对浏览器/守护进程任务,以及 N/N、混合版本与回滚测试。 | 缺少证据的置信度高。交叉编译不等于目标平台执行。 |
修正组合
合并前必须修正
- 修正所有成功写入结果的生产端,优先处理直接允许与删除,并证明调用方结果等于浏览器写后状态。
- 把停止/断开/取消改成绑定会话且原子的撤销边界;旧待处理记录迁移为已取消。
- 移除可复用的代码/源码授权,或把它精确绑定到内容与披露范围,并完整展示范围。
- 恢复“每个请求 ID 一个终态响应”;为正则执行提供可强制终止的硬边界。
- 修复破坏性操作、策略提升和失败恢复界面。
- 建立不泄露秘密的元数据契约与跨平台凭据边界。
- 强制完整生成契约和精确配对兼容性,能力确认前不得报告已连接。
应当保留
- 保留 Go 守护进程、扩展主动连接回环 WebSocket、显式
sctl serve,以及 v1 排除远程/WSS 的范围。 - 保留新鲜 nonce、方向分离的 HMAC 上下文、HKDF 密钥分离、AES-GCM 配对密钥传递、常量时间比较、严格 JSON-RPC 消息、扩展最终写入权、哈希复核和默认批准。
- 保留 sctl 作为协议所有者、确定性生成、原生 CSP 安全校验器,以及 MV3 路径中不使用运行时 Ajv、
eval或new Function。 - 保留生成结果校验、UUID 关联、忽略真正迟到的未知响应、发布校验和,以及当前仓库内 lint/unit/E2E/race 门槛。
加强与新增
- 生成完整双向契约和共用一致性语料,覆盖方法结果、会话消息、跨字段规则、MCP schema、无效消息、取消序列与兼容组合。
- 增加结构化且不含秘密的可观测字段:对端版本、schema 版本/摘要、连接世代、请求/操作 ID、方法、状态转换、取消原因、校验类别、重连/撤销与旧页面尝试。
- 增加机器可读的配对发布清单和目标平台原生冒烟测试。
- 增加持续可见的信任保留摘要、待处理操作恢复、类型化协议诊断,以及从规范操作数据生成的脱敏授权回执;回执不得成为第二套授权依据。
验证路径与结论上限
- T1 确定性/静态: 修正所有方法结果夹具;重新生成全部产物;比较格式化后的 ScriptCat 消费端文件;运行共用正反例契约、URL/权限/语言审计,以及聚焦的授权与生命周期测试。
- T2 行为: 执行带屏障的取消/批准与重复请求测试;对抗性正则和限流探针;编译后二进制的 CLI 退出码/输出测试;真实浏览器的批准、恢复、可访问性和旧页面流程;Windows/POSIX 凭据检查。
- T3 配对运行与发布: 冻结两个 SHA,启动真实守护进程和扩展,覆盖配对、hello/capabilities、列表/源码、所有写入策略、批准/拒绝/取消/断开/重连/撤销、畸形消息、写后状态、混合版本、目标平台制品与回滚。
当前精确 HEAD 的 Actions 是有价值但有上限的证据:ScriptCat run 30899687695 的 lint、单元测试和 Playwright 分片成功;sctl run 30899674257 的 lint、build/vet/race 测试和 protocol-schema 成功。由于缺少依赖和 Go,本地没有执行 ScriptCat/Go 套件。因此,独立 CI 只能证明各仓库内部一致性,不能证明精确配对互操作、完整边界校验、目标平台可运行或回滚安全。
回滚、可观测性与风险接受
- 把两个 SHA 与 schema/生成摘要视为一个回滚单元。除非混合配对经过测试并列为支持,否则不要只回滚一端。
- 在升级、降级、连接替换、断开或密钥语义迁移前,取消所有进行中和无世代的旧批准。明确回滚时保留还是撤销配对密钥与会话授权。
- 不得记录源码、代码 diff、配对码、长期/控制 token、HMAC 材料、原始敏感 URL 或无上限的客户端标签。
- v1 可在文档中明确接受:固定回环端口可见、双向认证后的明文回环传输、单个已配对扩展、下游扁平信任、标签仅作提示、全局撤销,以及不防御同一用户账户完全失陷。
- 不应接受:停止/断开后的旧请求仍可执行、旧授权批准新代码、披露范围大于用户所见、不可抢占工作却宣称有时间上限、写入成功但报告失败、请求 ID 无终态,或未经平台证据支持的凭据安全声明。
审计交接
- 审查配对: ScriptCat
9412954ada9f1a34533ef6dd592d80e29c40e43f;sctlb871d32a2ce7000119ff7bd472d4e3cc8c28e032。 - 讨论状态: 审查时 ScriptCat 有 14 条顶层评论,没有已提交 review 或行内线程;sctl 有一条先前的总体 review,没有行内线程。讨论内容只作为上下文或
external-cited证据。 - 相对早期候选已解决: 自定义消息与 JSON-RPC 不匹配、损坏的协议漂移任务、ScriptCat 手工维护的协议 JSON 副本、运行时 schema 编译导致的 MV3 CSP 启动风险,以及守护进程自动拉起的跨平台路径。
- 重新审查触发条件: 协议 JSON/生成器/生成文件、方法结果生产端、JSON-RPC/会话接线、批准/会话授权状态、断开/取消、版本协商、CI/发布工作流或任一候选 SHA 发生变化。
- 最终建议: 草案继续保持收敛状态。先修复“写入成功却返回错误”和“断开后旧权限仍有效”两个阻断项,再处理关联、授权、资源、隐私问题,并通过真实、脱敏、跨仓库门槛验证冻结后的配对版本。
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 on127.0.0.1:8643); thestore-build isolation, Windows installer, and Native Host CI items no longer apply.
Please review against this comment instead.
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.