From babf03977ea41d3b14ba3832fe29d1133ef3ee82 Mon Sep 17 00:00:00 2001 From: hsien wei Date: Fri, 23 Oct 2026 22:12:48 +0800 Subject: [PATCH] fix(mcp): require versioned consent for write-capable servers - Reclassify bundled filesystem and git MCP servers as write tier, off by default, consent revision 2 - Add a shared fail-closed resolveMcpEnablement used by both new-session settings and the Settings UI - Treat legacy enabled:true grants for revised entries as off at read time without rewriting config.json - Derive the required revision and the stored grant from one catalog rule shared by the resolver and the writer - Add a main-process single-server consent update that builds the grant from the catalog, never from the renderer - Reject mcpDefaults in generic writeConfig patches so stale renderer snapshots cannot restore revoked grants - Read consent with strict UTF-8 JSON parsing and refuse writes over corrupt or unreadable config files - Keep lenient decoding for ordinary config reads so a stray non-UTF-8 byte cannot reset every setting - Accept a UTF-8 BOM in both read paths - Type hive consent input as stored records so passing the whole config fails at compile time, still validated at run time - Log and notify once when consent state is unreadable at spawn instead of silently dropping default MCP servers - Read MCP settings only from main and drop the unused config prop from the settings panel - Refresh the MCP settings panel quietly on config broadcasts, keeping save errors and avoiding loading flashes - Derive tier type, resolver validation and Settings grouping from a single MCP_TIERS list - Update tier notes and add load, unknown and refresh strings in en, zh-CN and ar - Add regression tests for catalog invariants, resolver edge cases, revision rules, atomic writes, I/O failures, UI state and hire consent --- src/main/config.ts | 92 +++++- src/main/hive.ts | 35 +-- src/main/index.ts | 33 +- src/preload/index.ts | 7 +- .../src/components/McpDefaultsSettings.tsx | 128 ++++++-- src/renderer/src/components/SettingsModal.tsx | 2 +- src/renderer/src/i18n/locales/ar.json | 8 +- src/renderer/src/i18n/locales/en.json | 8 +- src/renderer/src/i18n/locales/zh-CN.json | 8 +- src/renderer/src/store/config.ts | 3 +- src/shared/mcpCatalog.ts | 141 ++++++++- test/hire-validator-parity.test.cjs | 7 + test/mcp-consent-config.test.cjs | 220 +++++++++++++ test/mcp-consent-ui.test.cjs | 289 ++++++++++++++++++ test/mcp-consent.test.cjs | 153 ++++++++++ 15 files changed, 1055 insertions(+), 79 deletions(-) create mode 100644 test/mcp-consent-config.test.cjs create mode 100644 test/mcp-consent-ui.test.cjs create mode 100644 test/mcp-consent.test.cjs diff --git a/src/main/config.ts b/src/main/config.ts index f66c854c15..e55e5aaa11 100644 --- a/src/main/config.ts +++ b/src/main/config.ts @@ -9,7 +9,10 @@ import { providerPreset, type AgentProvider } from '../shared/agentProvider'; -import { defaultMcpDefaults } from '../shared/mcpCatalog'; +import { + defaultMcpDefaults, isPlainObject, mcpCatalogEntry, mcpConsentGrant, + type McpDefaultsMap, type McpConsentStateResult +} from '../shared/mcpCatalog'; import { MAX_AGENT_TOKEN_CAP } from '../shared/tokenCaps'; import { expandTilde, normalizeHiveHome } from './fs'; import type { IntegrationRecord } from '../shared/integrations'; @@ -214,7 +217,7 @@ export interface HarnessConfig { /** Per-server consent state for the default MCP bundle, keyed by catalog id. * Seeded from MCP_CATALOG (safe-readonly ON, write/secret OFF); the user flips * these in Settings. A server is wired into an agent only when enabled here. */ - mcpDefaults?: { [id: string]: { enabled: boolean } }; + mcpDefaults?: McpDefaultsMap; /** Enable semantic memory (MemPalace CLI). No-op if mempalace isn't installed. */ semanticMemory: boolean; /** Embedding model for the palace: lightweight 'minilm' or multilingual 'embeddinggemma'. */ @@ -509,6 +512,8 @@ function configPath(): string { function withTriggerDefaults(cfg: HarnessConfig): HarnessConfig { return { ...cfg, + // Never hand out the process-wide consent seed by mutable reference. + mcpDefaults: cfg.mcpDefaults === DEFAULTS.mcpDefaults ? defaultMcpDefaults() : cfg.mcpDefaults, contextTrigger: { compact: { ...DEFAULT_CONTEXT_TRIGGER.compact, ...cfg.contextTrigger?.compact }, clear: { ...DEFAULT_CONTEXT_TRIGGER.clear, ...cfg.contextTrigger?.clear } @@ -585,15 +590,43 @@ function migrateTriggersV1(cfg: HarnessConfig): HarnessConfig { } } +/** How config.json bytes become text. Both strip a UTF-8 BOM (Windows editors). + * - 'strict' : any invalid byte is a load failure. Consent reads use this, so a + * damaged file is reported to the user instead of being granted from. + * - 'lenient' : invalid bytes become U+FFFD, the long-standing readConfig behaviour. + * A stray ANSI/GBK byte in an unrelated string (a hand-edited name) + * must not reset every setting on the next unrelated write. U+FFFD can + * only land inside a JSON string, so it can never form an + * `enabled:true` boolean or an integer revision — it cannot widen consent. */ +type ConfigDecoding = 'strict' | 'lenient'; + +/** undefined = no file yet (first run). Every other failure throws with context: + * I/O errors keep their Node error (errno code intact), parse/shape get a message. */ +function readStoredConfig(decoding: ConfigDecoding): Record | undefined { + let raw: Buffer; + try { + raw = readFileSync(configPath()); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') return undefined; + throw error; + } + let parsed: unknown; + try { + parsed = JSON.parse(new TextDecoder('utf-8', { fatal: decoding === 'strict' }).decode(raw)); + } catch { + throw new Error(decoding === 'strict' + ? 'Cannot load config.json: expected complete, valid UTF-8 JSON' + : 'Cannot load config.json: expected complete JSON'); + } + if (!isPlainObject(parsed)) throw new Error('Cannot load config.json: expected an object'); + return parsed; +} + export function readConfig(): HarnessConfig { - const p = configPath(); - // No file yet = a first run with nothing to migrate; the defaults ARE the - // post-migration shape. Deliberately does not persist — a bare read must not - // conjure a config.json before onboarding has written one. - if (!existsSync(p)) return withTriggerDefaults({ ...DEFAULTS }); try { - const raw = readFileSync(p, 'utf8'); - const parsed = JSON.parse(raw); + const parsed = readStoredConfig('lenient'); + // First run: no file and no migration write before onboarding. + if (parsed === undefined) return withTriggerDefaults({ ...DEFAULTS }); return normalizeStoredHomes(migrateTriggersV1(withTriggerDefaults({ ...DEFAULTS, ...parsed }))); } catch { return withTriggerDefaults({ ...DEFAULTS }); @@ -667,6 +700,10 @@ function persistConfig(next: HarnessConfig): HarnessConfig { } export function writeConfig(patch: Partial): HarnessConfig { + if (!isPlainObject(patch)) throw new Error('invalid config patch'); + if (Object.hasOwn(patch, 'mcpDefaults')) { + throw new Error('MCP consent must be updated with setMcpServerEnabled'); + } const current = readConfig(); const next: HarnessConfig = { ...current, ...patch }; // Project INGESTION — a registered repo is typed by hand ("~/dev/foo") as often @@ -697,6 +734,43 @@ export function writeConfig(patch: Partial): HarnessConfig { return persistConfig(next); } +/** Consent reads cannot use readConfig's forgiving fallback or run migrations. + * Keep corrupt files intact and distinguish first install from other I/O errors. */ +function readMcpConsentConfig(): HarnessConfig { + const parsed = readStoredConfig('strict'); + if (parsed === undefined) return withTriggerDefaults({ ...DEFAULTS }); + if (Object.hasOwn(parsed, 'mcpDefaults') && !isPlainObject(parsed.mcpDefaults)) { + throw new Error('Cannot load MCP consent: mcpDefaults must be an object'); + } + return withTriggerDefaults({ ...DEFAULTS, ...parsed }); +} + +/** Only the MCP records cross this read boundary, never unrelated credentials. + * Never throws: a failure comes back as `{ ok:false, error }` for the UI to show. */ +export function getMcpConsentState(): McpConsentStateResult { + try { + return { ok: true, records: readMcpConsentConfig().mcpDefaults ?? defaultMcpDefaults() }; + } catch (error) { + return { ok: false, error: error instanceof Error ? error.message : String(error) }; + } +} + +/** Synchronous main-process RMW serializes updates from windows in this process. + * Revision is chosen here, never supplied by a renderer snapshot. */ +export function setMcpServerEnabled(catalogId: unknown, enabled: unknown): HarnessConfig { + if (typeof catalogId !== 'string' || typeof enabled !== 'boolean') throw new Error('invalid MCP consent update'); + const entry = mcpCatalogEntry(catalogId); + if (!entry) throw new Error('invalid MCP server id'); + const record = enabled ? mcpConsentGrant(entry) : { enabled: false }; + if (!record) throw new Error('invalid MCP catalog consent policy'); + // Throws on an unreadable/corrupt file, so the IPC promise rejects and nothing is written. + const current = readMcpConsentConfig(); + return persistConfig({ + ...current, + mcpDefaults: { ...current.mcpDefaults, [catalogId]: record } + }); +} + /** Set or clear one agent's token ceiling against the latest config on disk. * * Renderer config objects are snapshots. Replacing `agentTokenCaps` from one of diff --git a/src/main/hive.ts b/src/main/hive.ts index 5f9c38bffc..bb474aa6ed 100644 --- a/src/main/hive.ts +++ b/src/main/hive.ts @@ -37,18 +37,13 @@ import { bridgeOf, type AgentProvider } from '../shared/agentProvider'; -import { MCP_CATALOG } from '../shared/mcpCatalog'; +import { MCP_CATALOG, mcpConsentRecord, resolveMcpEnablement, type McpConsentInput } from '../shared/mcpCatalog'; import { selectBroadcastTargets } from '../shared/broadcast'; import { preferredAgentRole } from '../shared/agentRole'; import { mergeTaskLedger } from '../shared/taskLedger'; import { expandTilde } from './fs'; import { resolveGodName } from '../shared/godIdentity'; -/** The subset of HarnessConfig the hive consumes for the default-MCP merge. - * Kept as a local shape so hive.ts never imports the foundation-owned config - * module just for a type. */ -type McpDefaultsMap = { [id: string]: { enabled: boolean } } | undefined; - // ─── Types ────────────────────────────────────────────────────────────────── export type MessageAct = 'request' | 'inform' | 'propose' | 'query' | 'agree' | 'refuse' | 'done'; @@ -722,9 +717,12 @@ export class HiveManager { * old env-var spelling. */ kgCliPath?: string; theme?: 'light' | 'dark'; - /** Consent state for the default-MCP bundle (W3). Threaded from the live - * HarnessConfig by the caller; undefined → catalog defaults apply. */ - mcpDefaults?: { [id: string]: { enabled: boolean } }; + /** Stored consent records for the default-MCP bundle (W3), keyed by catalog id — + * the `records` of `getMcpConsentState()`, never the whole HarnessConfig. + * undefined → no stored records: catalog defaults apply (safe ON, write/secret OFF). + * null → consent state could not be read: fail closed, no default server. + * Values stay untrusted JSON; buildDefaultMcpServers validates each one. */ + mcpDefaults?: McpConsentInput; /** App-resources `skills/` source dir (W3). The bundled read-only skills are * copied into the agent's `.claude/skills/` per spawn; undefined or missing * is a no-op (tolerated until Kevin populates the resource dir). */ @@ -1242,7 +1240,7 @@ export class HiveManager { return Array.from(new Set(out)); } - private hookSettings(shim: string, cwd: string, cfg: McpDefaultsMap, theme?: 'light' | 'dark', writableDirs: string[] = []): unknown { + private hookSettings(shim: string, cwd: string, cfg: McpConsentInput, theme?: 'light' | 'dark', writableDirs: string[] = []): unknown { // Bundled node, NOT bare `node` — see nodeLauncherPath(). Claude runs each of // these through `sh -c` with a stripped PATH, where `node` is often absent. const cmd = this.nodeRun(shim); @@ -1312,29 +1310,26 @@ export class HiveManager { * server only when it's enabled (catalog ∩ consent), scopes filesystem/git to the * agent cwd (never whole-disk), and namespaces every id `munder-` so a server * of the same name in the user's own ~/.claude is never clobbered. A write/secret - * server is included ONLY on an explicit `enabled:true` consent — never via a + * server is included ONLY on valid versioned consent — never via a * default — so a malformed/partial config can't silently arm a keyed server. + * The static type keeps callers honest; the record values are still untrusted + * JSON, so mcpConsentRecord/resolveMcpEnablement validate each one at run time + * and fail closed even if a caller's static type is wrong. */ private buildDefaultMcpServers( cwd: string, - cfg: McpDefaultsMap + cfg: McpConsentInput ): Record }> { const out: Record }> = {}; for (const e of MCP_CATALOG) { - const consented = cfg?.[e.id]?.enabled; - const enabled = consented ?? e.defaultEnabled; - if (!enabled) continue; - // Defense-in-depth: a write/secret server requires an EXPLICIT opt-in; it can - // never ride in on a default (the catalog already ships these OFF, but this - // guards a hand-edited/partial mcpDefaults map too). - if (e.tier !== 'safe-readonly' && consented !== true) continue; + if (!resolveMcpEnablement(e, mcpConsentRecord(cfg, e.id))) continue; // Replace the `` placeholder (filesystem/git) with the agent cwd at merge // time so these stay strictly workspace-scoped. const args = e.spec.args.map((a) => (a === '' ? cwd : a)); out[`munder-${e.id}`] = { command: e.spec.command, args, - ...(e.spec.env ? { env: e.spec.env } : {}) + ...(e.spec.env ? { env: { ...e.spec.env } } : {}) }; } return out; diff --git a/src/main/index.ts b/src/main/index.ts index 0cc017c8d6..99227d8ca9 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -14,7 +14,7 @@ import { resolveCommand as resolveCliCommand, isSafeCommandName } from './shellE import { initAutoUpdater, abortPendingRestart } from './updater'; import { RealtimeFloorWatcher } from './realtimeFloorWatcher'; import { - readConfig, writeConfig, setAgentTokenCap, resetConfig, onConfigWritten, ensureHarnessHome, ensureClaudePermissionsAccepted, + readConfig, writeConfig, setAgentTokenCap, setMcpServerEnabled, getMcpConsentState, resetConfig, onConfigWritten, ensureHarnessHome, ensureClaudePermissionsAccepted, modelForRole, OPS_STANDUP_MISSION, HEARTBEAT_MISSION, COMPACT_MAINTENANCE_MISSION, type HarnessConfig, type ScheduledMission } from './config'; import { listDir, readFileText, readFileBinary, writeFileText, statAbs, expandTilde } from './fs'; @@ -1191,6 +1191,31 @@ function breakerToast(title: string, body: string): void { catch { /* unsupported platform */ } } +/** The consent-read failure last told to the user, so spawning a whole team against + * one broken config.json raises one toast rather than one per agent. Cleared by the + * next good read, so a later, separate breakage is announced again. */ +let surfacedMcpConsentFailure: string | undefined; + +/** Default-MCP consent for a new session. An unreadable config.json fails closed — + * no default server is wired, safe ones included — but never silently: the agent + * is missing tools the user expects, so log it and tell them how to recover. */ +function mcpDefaultsForSpawn(): Record | null { + const consent = getMcpConsentState(); + if (consent.ok) { + surfacedMcpConsentFailure = undefined; + return consent.records; + } + console.warn('[mcp] consent state unreadable; spawning without default MCP servers:', consent.error); + if (surfacedMcpConsentFailure !== consent.error) { + surfacedMcpConsentFailure = consent.error; + breakerToast( + 'Default MCP servers disabled', + `${consent.error}. New agents start without default MCP servers until config.json is repaired; then refresh Settings → Default MCP servers.` + ); + } + return null; +} + /** One circuit-breaker beat: pull a fresh usage sample per active agent, append * it to the durable cost ledger (the SOLE durable cost store), tick the breaker, * emit each BreakerState on control:breakerState (Seam 2), and enforce any @@ -2789,7 +2814,7 @@ async function spawnAgentCore(opts: AgentSpawnOptions, owner: Electron.WebConten kgCliPath: knowledge.env().KG_CLI, theme: readConfig().terminalTheme ?? 'light', // W3 — default-MCP consent state + the bundled skills source dir. - mcpDefaults: readConfig().mcpDefaults, + mcpDefaults: mcpDefaultsForSpawn(), skillsDir: skillsResourceDir(), // The shared palace is mutated by the agent's own `mempalace` calls, so // the OS sandbox must let it through (empty when memory is off). @@ -3198,6 +3223,10 @@ ipcMain.handle('integrations:test', async (_evt, payload: unknown) => { // ─── IPC: config ──────────────────────────────────────────────────────────── ipcMain.handle('config:get', (): HarnessConfig => readConfig()); +ipcMain.handle('config:getMcpConsentState', () => getMcpConsentState()); +ipcMain.handle('config:setMcpServerEnabled', (_evt, catalogId: unknown, enabled: unknown) => + setMcpServerEnabled(catalogId, enabled) +); ipcMain.handle('config:update', (_evt, patch: Partial) => { // FIRST RUN: every hive-bound service is started by bootstrapHiveServices(), // which runs once at app-ready and early-returns on `!hive.enabled()` — i.e. diff --git a/src/preload/index.ts b/src/preload/index.ts index ffdb5382a7..12a69894ef 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -1,5 +1,6 @@ import { contextBridge, ipcRenderer, webUtils, type IpcRendererEvent } from 'electron'; import type { AgentProvider } from '../shared/agentProvider'; +import type { McpDefaultsMap, McpConsentStateResult } from '../shared/mcpCatalog'; import type { HireManifest } from '../shared/hire'; export type { HireManifest } from '../shared/hire'; import type { IntegrationRecord, IntegrationTemplate } from '../shared/integrations'; @@ -275,7 +276,7 @@ export interface HarnessConfig { godModel?: string; /** Per-server consent for the default MCP bundle, keyed by catalog id. Mirrors * src/main/config.ts. */ - mcpDefaults?: { [id: string]: { enabled: boolean } }; + mcpDefaults?: McpDefaultsMap; semanticMemory: boolean; embeddingModel: 'minilm' | 'embeddinggemma'; missions?: ScheduledMission[]; @@ -659,6 +660,10 @@ const api = { ipcRenderer.invoke('config:get'), updateConfig: (patch: Partial): Promise => ipcRenderer.invoke('config:update', patch), + getMcpConsentState: (): Promise => + ipcRenderer.invoke('config:getMcpConsentState'), + setMcpServerEnabled: (catalogId: string, enabled: boolean): Promise => + ipcRenderer.invoke('config:setMcpServerEnabled', catalogId, enabled), /** Set or clear one per-agent token ceiling against main's latest config. */ setAgentTokenCap: (agentId: string, tokenCap?: number): Promise => ipcRenderer.invoke('config:setAgentTokenCap', agentId, tokenCap), diff --git a/src/renderer/src/components/McpDefaultsSettings.tsx b/src/renderer/src/components/McpDefaultsSettings.tsx index 37224a0d2b..7ce20fd908 100644 --- a/src/renderer/src/components/McpDefaultsSettings.tsx +++ b/src/renderer/src/components/McpDefaultsSettings.tsx @@ -1,13 +1,16 @@ -import { useState } from 'react'; +import { useCallback, useEffect, useRef, useState } from 'react'; import { useTranslation } from 'react-i18next'; -import type { HarnessConfig } from '@/store/config'; -import { MCP_CATALOG, type McpTier } from '@shared/mcpCatalog'; +import { + MCP_CATALOG, MCP_TIERS, isPlainObject, mcpCatalogEntry, mcpConsentRecord, resolveMcpEnablement, type McpTier +} from '@shared/mcpCatalog'; -export interface McpDefaultsSettingsProps { - config: HarnessConfig; -} +/** A load failure means the state is unknown and clears once a read succeeds; a save + * failure stays until the user acts, so a background refresh cannot hide it. */ +type McpPanelError = { kind: 'load' | 'save'; message: string }; -const TIER_ORDER: McpTier[] = ['safe-readonly', 'write', 'secret']; +/** 'user': initial load, Refresh, post-save readback — shows loading and resets + * feedback. 'broadcast': another config write happened — revalidate quietly. */ +type RefreshOrigin = 'user' | 'broadcast'; const TIER_LABEL_KEY: Record = { 'safe-readonly': 'mcpDefaults.tiers.safeReadonly', 'write': 'mcpDefaults.tiers.write', @@ -27,24 +30,96 @@ const labelStyle: React.CSSProperties = { textTransform: 'uppercase' }; -export function McpDefaultsSettings({ config }: McpDefaultsSettingsProps) { +export function McpDefaultsSettings() { const { t } = useTranslation(); const [note, setNote] = useState(''); + const [error, setError] = useState(null); + const [records, setRecords] = useState | null>(null); + const [loading, setLoading] = useState(true); + const [pending, setPending] = useState(null); + const saving = useRef(false); + const mounted = useRef(false); + const readVersion = useRef(0); - const enabledFor = (id: string): boolean => - config.mcpDefaults?.[id]?.enabled ?? MCP_CATALOG.find((e) => e.id === id)?.defaultEnabled ?? false; + const refresh = useCallback(async (origin: RefreshOrigin = 'user') => { + const version = ++readVersion.current; + // Every config write broadcasts (missions, token caps, theme…). Keep the last + // confirmed records and feedback on screen while revalidating in the background. + if (origin === 'user') { + setLoading(true); + setRecords(null); + setNote(''); + setError(null); + } + try { + const state = await window.cth.getMcpConsentState(); + if (!mounted.current || version !== readVersion.current) return null; + if (!state || state.ok !== true || !isPlainObject(state.records)) throw new Error('MCP consent unavailable'); + setRecords(state.records); + if (origin === 'broadcast') setError((current) => (current?.kind === 'load' ? null : current)); + return state.records; + } catch { + if (mounted.current && version === readVersion.current) { + setRecords(null); + setError({ kind: 'load', message: t('mcpDefaults.couldNotLoad') }); + } + return null; + } finally { + // Also settles a user read that a newer broadcast read superseded. + if (mounted.current && version === readVersion.current) setLoading(false); + } + }, [t]); + + useEffect(() => { + mounted.current = true; + let off: (() => void) | undefined; + try { + off = window.cth.onConfigChanged(() => { void refresh('broadcast'); }); + void refresh(); + } catch { + setRecords(null); + setLoading(false); + setError({ kind: 'load', message: t('mcpDefaults.couldNotLoad') }); + } + return () => { + mounted.current = false; + ++readVersion.current; + off?.(); + }; + }, [refresh, t]); const toggle = async (id: string) => { - const next = !enabledFor(id); + if (saving.current || loading || records === null) return; + const entry = mcpCatalogEntry(id); + if (!entry) return; + const next = !resolveMcpEnablement(entry, mcpConsentRecord(records, id)); + saving.current = true; + setPending(id); + setNote(''); + setError(null); + ++readVersion.current; try { - await window.cth.updateConfig({ - mcpDefaults: { ...(config.mcpDefaults ?? {}), [id]: { enabled: next } } - }); - setNote(t('mcpDefaults.toggleNote', { id, state: next ? t('common.on') : t('common.off') })); - setTimeout(() => setNote(''), 1800); + const updated = await window.cth.setMcpServerEnabled(id, next); + const savedRecord = mcpConsentRecord(updated?.mcpDefaults, id); + if (!isPlainObject(savedRecord) || !Object.hasOwn(savedRecord, 'enabled') || savedRecord.enabled !== next + || resolveMcpEnablement(entry, savedRecord) !== next) { + throw new Error('MCP consent update was not confirmed'); + } + if (!mounted.current) return; + // Read after the save, rather than showing a response snapshot that another + // window may already have superseded. A failed read leaves state unknown. + const latest = await refresh(); + if (mounted.current && latest) { + const on = resolveMcpEnablement(entry, mcpConsentRecord(latest, id)); + setNote(t('mcpDefaults.toggleNote', { id, state: on ? t('common.on') : t('common.off') })); + } } catch { - setNote(t('mcpDefaults.couldNotSave')); - setTimeout(() => setNote(''), 2000); + if (!mounted.current) return; + await refresh(); + if (mounted.current) setError({ kind: 'save', message: t('mcpDefaults.couldNotSave') }); + } finally { + saving.current = false; + if (mounted.current) setPending(null); } }; @@ -59,7 +134,7 @@ export function McpDefaultsSettings({ config }: McpDefaultsSettingsProps) { - {TIER_ORDER.map((tier) => { + {MCP_TIERS.map((tier) => { const entries = byTier(tier); if (entries.length === 0) return null; const isConsent = tier !== 'safe-readonly'; @@ -80,7 +155,7 @@ export function McpDefaultsSettings({ config }: McpDefaultsSettingsProps) {
{entries.map((entry) => { - const on = enabledFor(entry.id); + const on = resolveMcpEnablement(entry, mcpConsentRecord(records, entry.id)); return (
); @@ -135,9 +213,11 @@ export function McpDefaultsSettings({ config }: McpDefaultsSettingsProps) { ); })} - {note && ( - {note} - )} + + {error && {error.message}} + {note && {note}}
); } diff --git a/src/renderer/src/components/SettingsModal.tsx b/src/renderer/src/components/SettingsModal.tsx index 349d3731f7..abf2309224 100644 --- a/src/renderer/src/components/SettingsModal.tsx +++ b/src/renderer/src/components/SettingsModal.tsx @@ -1427,7 +1427,7 @@ export function SettingsModal({ config, onClose, initialSection }: SettingsModal {/* CONNECTIONS — everything external (MCP + Slack + webhook + REST) */} {activeSection === 'Connections' && ( <> - +
)} diff --git a/src/renderer/src/i18n/locales/ar.json b/src/renderer/src/i18n/locales/ar.json index fe74ab7ebc..b26f8d9686 100644 --- a/src/renderer/src/i18n/locales/ar.json +++ b/src/renderer/src/i18n/locales/ar.json @@ -839,13 +839,17 @@ "title": "خوادم MCP الافتراضية", "desc": "تُدمج هذه الخوادم في إعدادات جلسة كل وكيل جديد. الخوادم الآمنة مفعّلة افتراضيًا؛ أمّا خوادم الكتابة والأسرار فمعطّلة حتى توافق عليها. تسري التغييرات عند إنشاء الوكيل التالي — الوكلاء العاملون لا يتأثّرون.", "toggleNote": "{{id}}: {{state}}", - "couldNotSave": "تعذّر الحفظ", + "couldNotSave": "تعذّر تأكيد الحفظ. حدّث إعدادات MCP قبل المحاولة مجددًا.", + "couldNotLoad": "تعذّر تأكيد إعدادات MCP. تحقّق من config.json أو أصلحه ثم حدّث الإعدادات. لا يمكن تغيير الموافقة حتى تنجح قراءة الإعدادات.", + "loading": "جارٍ التحميل…", + "unavailable": "غير معروف", + "refresh": "تحديث إعدادات MCP", "tiers": { "safeReadonly": "آمنة وللقراءة فقط (مفعّلة افتراضيًا)", "write": "صلاحية كتابة (تتطلّب موافقتك)", "secret": "تتطلّب سرًّا / مفتاح API (تتطلّب موافقتك)", "safeReadonlyNote": "تقرأ هذه الخوادم البيانات فقط، ولا تحتاج أي أسرار، ونطاقها محصور بمساحة عمل الوكيل. وهي مفعّلة لكل وكيل جديد.", - "writeNote": "تستطيع هذه الخوادم تغيير الحالة خارج مساحة العمل. معطّلة افتراضيًا — لا تفعّلها إلا بعد المراجعة.", + "writeNote": "تستطيع هذه الخوادم كتابة البيانات أو تعديلها، بما في ذلك ملفات مساحة العمل وسجل Git. معطّلة افتراضيًا وتتطلّب موافقة صريحة. قد تتطلّب الموافقات القديمة تجديدًا.", "secretNote": "تتطلّب هذه الخوادم مفتاح API أو بيانات اعتماد. معطّلة افتراضيًا — أضف بياناتك ثم فعّلها بعد الموافقة." } }, diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index 68aac27438..95db615fa3 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -839,13 +839,17 @@ "title": "Default MCP servers", "desc": "These servers are merged into each new agent's session settings. Safe servers are on by default; write/secret servers are off until you consent. Changes take effect on the next agent spawn — running agents are not affected.", "toggleNote": "{{id}}: {{state}}", - "couldNotSave": "could not save", + "couldNotSave": "Could not confirm the save. Refresh MCP settings before trying again.", + "couldNotLoad": "Cannot confirm MCP settings. Check or repair config.json, then refresh. Consent changes are disabled until settings can be read.", + "loading": "loading…", + "unavailable": "unknown", + "refresh": "Refresh MCP settings", "tiers": { "safeReadonly": "Safe & Read-Only (on by default)", "write": "Write Access (consent required)", "secret": "Requires Secret / API Key (consent required)", "safeReadonlyNote": "These servers read data only, need no secrets, and are scoped to the agent workspace. They are enabled for every new agent.", - "writeNote": "These servers can mutate state beyond the workspace. Off by default — enable only after reviewing.", + "writeNote": "These servers can write or modify state, including workspace files and Git history. Off by default; explicit consent is required. Older grants may require renewed consent.", "secretNote": "These servers require an API key or credentials. Off by default — add your credentials and enable after consent." } }, diff --git a/src/renderer/src/i18n/locales/zh-CN.json b/src/renderer/src/i18n/locales/zh-CN.json index 0db2c7f25d..ef62c3c5d5 100644 --- a/src/renderer/src/i18n/locales/zh-CN.json +++ b/src/renderer/src/i18n/locales/zh-CN.json @@ -839,13 +839,17 @@ "title": "默认 MCP 服务器", "desc": "这些服务器会并入每个新智能体的会话设置。安全服务器默认开启;写入/密钥类服务器在你同意前保持关闭。更改在下次生成智能体时生效——正在运行的智能体不受影响。", "toggleNote": "{{id}}:{{state}}", - "couldNotSave": "无法保存", + "couldNotSave": "无法确认保存结果。请刷新 MCP 设置后再试。", + "couldNotLoad": "无法确认 MCP 设置。请检查或修复 config.json 后刷新。在成功读取设置前,无法更改授权。", + "loading": "正在加载…", + "unavailable": "未知", + "refresh": "刷新 MCP 设置", "tiers": { "safeReadonly": "安全且只读(默认开启)", "write": "写入访问(需要同意)", "secret": "需要密钥 / API Key(需要同意)", "safeReadonlyNote": "这些服务器只读取数据,不需要密钥,且限定在智能体工作区内。它们对每个新智能体启用。", - "writeNote": "这些服务器可以修改工作区之外的状态。默认关闭——审核后再启用。", + "writeNote": "这些服务器可以写入或修改状态,包括工作区文件和 Git 历史。默认关闭,需要明确同意。旧授权可能需要重新确认。", "secretNote": "这些服务器需要 API 密钥或凭据。默认关闭——添加凭据并在同意后启用。" } }, diff --git a/src/renderer/src/store/config.ts b/src/renderer/src/store/config.ts index 4f11435af8..ea485e1bea 100644 --- a/src/renderer/src/store/config.ts +++ b/src/renderer/src/store/config.ts @@ -16,6 +16,7 @@ import type { import { isNewer } from '@shared/updateState'; import modelCatalog from '@shared/modelCatalog.json'; import type { CatalogModel, ModelCatalog } from '@shared/modelCatalogPayload'; +import type { McpDefaultsMap } from '@shared/mcpCatalog'; export { AGENT_PROVIDER_PRESETS, @@ -78,7 +79,7 @@ export interface HarnessConfig { godModel?: string; /** Per-server consent for the default MCP bundle, keyed by catalog id (mirrors * src/main/config.ts; seeded from MCP_CATALOG). */ - mcpDefaults?: { [id: string]: { enabled: boolean } }; + mcpDefaults?: McpDefaultsMap; semanticMemory: boolean; embeddingModel: 'minilm' | 'embeddinggemma'; missions?: ScheduledMission[]; diff --git a/src/shared/mcpCatalog.ts b/src/shared/mcpCatalog.ts index 607fcf43c6..865b5f3ff4 100644 --- a/src/shared/mcpCatalog.ts +++ b/src/shared/mcpCatalog.ts @@ -4,10 +4,8 @@ * agent's per-session `settings.json`. Keep it free of electron/UI/node imports. * * Tiers gate consent: - * - 'safe-readonly' → no secret, no destructive write OUTSIDE the agent cwd; shipped - * ON by default (`defaultEnabled:true`). `filesystem`/`git` are - * scoped to the agent cwd at merge time (never whole-disk). - * - 'write' → can mutate state beyond the workspace; OFF by default, + * - 'safe-readonly' → no secret or state-changing tools; shipped ON by default. + * - 'write' → can mutate state, including within the workspace; OFF by default, * consent-gated. * - 'secret' → needs an API key / token / connection string; OFF by default, * consent-gated. @@ -22,7 +20,39 @@ * makes a server that fails to resolve non-fatal to the agent. */ -export type McpTier = 'safe-readonly' | 'write' | 'secret'; +/** Every consent tier, in Settings display order. Add a tier here: the type, the + * resolver's validation and the Settings grouping all derive from this list. Any + * tier other than 'safe-readonly' is consent-gated (see the Tiers note above). */ +export const MCP_TIERS = ['safe-readonly', 'write', 'secret'] as const; + +export type McpTier = (typeof MCP_TIERS)[number]; + +export function isMcpTier(value: unknown): value is McpTier { + return typeof value === 'string' && (MCP_TIERS as readonly string[]).includes(value); +} + +export const LEGACY_MCP_CONSENT_REVISION = 1; + +/** A consent revision is a safe positive integer; anything else is malformed. */ +function isConsentRevision(value: unknown): value is number { + return typeof value === 'number' && Number.isSafeInteger(value) && value >= 1; +} + +export interface McpConsentRecord { + enabled: boolean; + consentRevision?: number; +} + +export type McpDefaultsMap = Record; + +/** Stored consent records keyed by catalog id, as read from config.json — never the + * whole HarnessConfig. undefined → no stored records, catalog defaults apply; + * null → the state could not be read, fail closed. Values stay untrusted JSON. */ +export type McpConsentInput = Readonly> | null | undefined; + +export type McpConsentStateResult = + | { ok: true; records: Record } + | { ok: false; error: string }; export interface McpCatalogEntry { /** Stable catalog id (also the consent key in `config.mcpDefaults`). The merge @@ -45,10 +75,12 @@ export interface McpCatalogEntry { tier: McpTier; /** Seed for `config.mcpDefaults[id].enabled`. Always === (tier === 'safe-readonly'). */ defaultEnabled: boolean; + /** Permission contract revision, not a package version or identity proof. */ + consentRevision?: number; } /** The default MCP bundle. Safe/read-only servers are ON; anything that writes - * beyond the workspace or needs a secret is OFF until the user consents. */ + * or needs a secret is OFF until the user consents. */ export const MCP_CATALOG: McpCatalogEntry[] = [ // ─── Safe, read-only, no-secret — shipped ON ────────────────────────────── { @@ -88,22 +120,24 @@ export const MCP_CATALOG: McpCatalogEntry[] = [ { id: 'filesystem', label: 'Filesystem (cwd)', - description: 'Read/edit files within the agent workspace only (scoped to cwd at spawn).', + description: 'Read, write, edit and move files under the configured workspace root. Requires explicit consent.', // The trailing arg is the allowed root — Workstream 3 replaces this placeholder // with the agent cwd at merge time so it is NEVER whole-disk. spec: { command: 'npx', args: ['-y', '@modelcontextprotocol/server-filesystem', ''] }, - tier: 'safe-readonly', - defaultEnabled: true + tier: 'write', + defaultEnabled: false, + consentRevision: 2 }, { id: 'git', label: 'Git (cwd)', - description: 'Inspect git status/log/diff for the workspace repo (scoped to cwd at spawn).', + description: 'Inspect and modify the workspace repository, including add, commit, reset and branch operations. Requires explicit consent.', // Reference git server ships as Python; `--repository ` is set at merge time. // TODO-verify transport (uvx vs an npm port). spec: { command: 'uvx', args: ['mcp-server-git', '--repository', ''] }, - tier: 'safe-readonly', - defaultEnabled: true + tier: 'write', + defaultEnabled: false, + consentRevision: 2 }, // ─── Write / secret — shipped OFF, consent-gated ────────────────────────── @@ -154,7 +188,11 @@ export const MCP_CATALOG: McpCatalogEntry[] = [ /** Look up a catalog entry by id. */ export function mcpCatalogEntry(id: string): McpCatalogEntry | undefined { - return MCP_CATALOG.find((e) => e.id === id); + const entry = MCP_CATALOG.find((e) => e.id === id); + return entry ? { + ...entry, + spec: { ...entry.spec, args: [...entry.spec.args], ...(entry.spec.env ? { env: { ...entry.spec.env } } : {}) } + } : undefined; } /** Whether an id is a known safe-readonly server (the only tier a hire manifest may @@ -165,8 +203,81 @@ export function isSafeReadonlyMcp(id: string): boolean { /** Seed for `DEFAULTS.mcpDefaults` — derived from the catalog so the two never * drift (safe-readonly ON, write/secret OFF). */ -export function defaultMcpDefaults(): Record { - const out: Record = {}; +export function defaultMcpDefaults(): McpDefaultsMap { + const out: McpDefaultsMap = {}; for (const e of MCP_CATALOG) out[e.id] = { enabled: e.defaultEnabled }; return out; } + +/** JSON object boundary shared by config, runtime and renderer. Stricter than the + * module-private `isPlainObject` in triggers.ts: only `Object.prototype` or + * null-prototype objects pass (no arrays, class instances, Map/Date), and a + * throwing Proxy reads as "not an object" instead of breaking the caller. */ +export function isPlainObject(value: unknown): value is Record { + try { + if (value === null || typeof value !== 'object') return false; + const prototype = Object.getPrototypeOf(value); + return prototype === Object.prototype || prototype === null; + } catch { + return false; + } +} + +/** Ignore inherited properties and accessors. null means malformed, not absent. */ +function ownDataValue(record: object, key: string): unknown { + const descriptor = Object.getOwnPropertyDescriptor(record, key); + return descriptor === undefined ? undefined : 'value' in descriptor ? descriptor.value : null; +} + +/** Missing maps use catalog defaults; malformed maps fail closed for every entry. + * The static type steers callers; the runtime checks still guard a mistyped value. */ +export function mcpConsentRecord(records: McpConsentInput, catalogId: string): unknown { + try { + if (records === undefined) return undefined; + return isPlainObject(records) ? ownDataValue(records, catalogId) : null; + } catch { + return null; + } +} + +/** The revision a grant must carry for this catalog entry; omitted (or undefined) + * means the legacy revision. undefined result = the catalog entry is malformed. */ +export function requiredConsentRevision(entry: McpCatalogEntry): number | undefined { + // Not `??`: a null revision is a malformed entry, not a request for the legacy one. + const revision = entry.consentRevision === undefined ? LEGACY_MCP_CONSENT_REVISION : entry.consentRevision; + return isConsentRevision(revision) ? revision : undefined; +} + +/** The record an explicit grant stores — the single source for both the main-process + * writer and the resolver's expectations. Consent-gated tiers and revised entries + * carry the catalog revision; an unrevised safe entry keeps the legacy + * `{ enabled:true }` shape. undefined = the entry's own policy is invalid. */ +export function mcpConsentGrant(entry: McpCatalogEntry): McpConsentRecord | undefined { + const revision = requiredConsentRevision(entry); + if (revision === undefined) return undefined; + const record: McpConsentRecord = entry.tier !== 'safe-readonly' || revision !== LEGACY_MCP_CONSENT_REVISION + ? { enabled: true, consentRevision: revision } + : { enabled: true }; + return resolveMcpEnablement(entry, record) ? record : undefined; +} + +/** Read-time consent only: never migrates or mutates a stored grant. */ +export function resolveMcpEnablement(entry: McpCatalogEntry, candidateRecord: unknown): boolean { + try { + const requiredRevision = requiredConsentRevision(entry); + if (requiredRevision === undefined) return false; + if (!isMcpTier(entry.tier)) return false; + if (typeof entry.defaultEnabled !== 'boolean') return false; + if (entry.tier !== 'safe-readonly' && entry.defaultEnabled) return false; + if (candidateRecord === undefined) return entry.tier === 'safe-readonly' && entry.defaultEnabled; + if (!isPlainObject(candidateRecord) || ownDataValue(candidateRecord, 'enabled') !== true) return false; + // Stored records are untrusted JSON: an own key holding undefined is malformed. + const recordedRevision = Object.hasOwn(candidateRecord, 'consentRevision') + ? ownDataValue(candidateRecord, 'consentRevision') : LEGACY_MCP_CONSENT_REVISION; + if (!isConsentRevision(recordedRevision)) return false; + return recordedRevision === requiredRevision; + } catch { + // Untrusted accessors/proxies must not break a consumer or grant access. + return false; + } +} diff --git a/test/hire-validator-parity.test.cjs b/test/hire-validator-parity.test.cjs index f06f602c74..538c86debd 100644 --- a/test/hire-validator-parity.test.cjs +++ b/test/hire-validator-parity.test.cjs @@ -22,6 +22,13 @@ function loadGalleryValidator() { const validateInGallery = loadGalleryValidator(); +test('write-capable filesystem and git requests require consent without granting it', () => { + const result = validateHireManifest(manifest({ mcpServers: ['filesystem', 'git'] })); + assert.equal(result.ok, true); + assert.deepEqual(result.consentRequired, ['filesystem', 'git']); + assert.equal(Object.hasOwn(result.manifest, 'mcpDefaults'), false); +}); + function manifest(overrides = {}) { return { spec: 'munder-difflin/hire@1', diff --git a/test/mcp-consent-config.test.cjs b/test/mcp-consent-config.test.cjs new file mode 100644 index 0000000000..522fb67e11 --- /dev/null +++ b/test/mcp-consent-config.test.cjs @@ -0,0 +1,220 @@ +'use strict'; + +const test = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const os = require('node:os'); +const path = require('node:path'); +const loadTs = require('./load-ts.cjs'); +const userData = fs.mkdtempSync(path.join(os.tmpdir(), 'md-consent-config-')); +const configPath = path.join(userData, 'config.json'); +const electron = require.resolve('electron'); +require.cache[electron] = { id: electron, filename: electron, loaded: true, + exports: { app: { getPath: () => userData }, Notification: class { show() {} static isSupported() { return false; } } } }; +const config = loadTs('src/main/config.ts'); +const { HiveManager } = loadTs('src/main/hive.ts'); +const { resolveMcpEnablement, MCP_CATALOG } = loadTs('src/shared/mcpCatalog.ts'); +const seed = (overrides = {}) => fs.writeFileSync(configPath, JSON.stringify({ triggersMigratedV1: true, ...overrides })); +const disk = () => JSON.parse(fs.readFileSync(configPath, 'utf8')); +test.after(() => fs.rmSync(userData, { recursive: true, force: true })); + +test('generic patches cannot bypass consent, including undefined and null', () => { + for (const mcpDefaults of [undefined, null, {}, { git: { enabled: true, consentRevision: 2 } }]) { + seed(); + const before = fs.readFileSync(configPath); + assert.throws(() => config.writeConfig({ mcpDefaults }), /setMcpServerEnabled/); + assert.deepEqual(fs.readFileSync(configPath), before); + } + config.writeConfig({ notifications: true }); + assert.equal(disk().notifications, true); +}); + +test('single-server RMW validates inputs and preserves other windows and unrelated settings', () => { + seed({ harnessHome: userData, slackBotToken: 'fixture-secret', webhookSecret: 'fixture-webhook', + extensionField: { preserve: true }, mcpDefaults: { 'github-token': { enabled: true }, unknown: { keep: 'yes' }, git: { enabled: true } } }); + for (const [id, enabled] of [['', true], ['unknown', true], ['__proto__', true], [null, true], ['git', 'true'], ['git', null]]) { + assert.throws(() => config.setMcpServerEnabled(id, enabled), /invalid/i); + } + config.setMcpServerEnabled('git', true); + const staleWindow = disk(); + const next = config.setMcpServerEnabled('filesystem', true); + assert.deepEqual(next.mcpDefaults.git, { enabled: true, consentRevision: 2 }); + config.setMcpServerEnabled('git', false); + config.setMcpServerEnabled('filesystem', true); + assert.deepEqual(disk().mcpDefaults.git, { enabled: false }); + assert.throws(() => config.writeConfig({ mcpDefaults: staleWindow.mcpDefaults }), /setMcpServerEnabled/); + assert.equal(disk().slackBotToken, 'fixture-secret'); + assert.equal(disk().webhookSecret, 'fixture-webhook'); + assert.equal(disk().harnessHome, userData); + assert.deepEqual(disk().extensionField, { preserve: true }); + assert.deepEqual(disk().mcpDefaults.unknown, { keep: 'yes' }); + assert.deepEqual(disk().mcpDefaults['github-token'], { enabled: true }); + assert.deepEqual(config.getMcpConsentState(), { ok: true, records: disk().mcpDefaults }); + assert.equal(JSON.stringify(config.getMcpConsentState()).includes('fixture-secret'), false); +}); + +test('legacy read is byte-preserving; malformed records can be replaced individually', () => { + seed({ mcpDefaults: { filesystem: { enabled: true }, git: { enabled: true }, db: null } }); + const before = fs.readFileSync(configPath); + const records = config.getMcpConsentState().records; + for (const id of ['filesystem', 'git']) { + assert.equal(resolveMcpEnablement(MCP_CATALOG.find((entry) => entry.id === id), records[id]), false); + } + config.readConfig(); + assert.deepEqual(fs.readFileSync(configPath), before); + config.setMcpServerEnabled('db', true); + assert.deepEqual(disk().mcpDefaults.db, { enabled: true, consentRevision: 1 }); + assert.deepEqual(disk().mcpDefaults.git, { enabled: true }); +}); + +test('invalid JSON, UTF-8 and container shapes reject without writes or announcements', () => { + const seen = []; + const off = config.onConfigWritten((next) => seen.push(next)); + try { + for (const raw of ['{"mcpDefaults":', 'null', '[]', 'true', '"text"', + ...[null, [], true, 'true', 1].map((mcpDefaults) => JSON.stringify({ mcpDefaults })), + Buffer.from([0x7b, 0x22, 0x78, 0x22, 0x3a, 0x22, 0xff, 0x22, 0x7d])]) { + fs.writeFileSync(configPath, raw); + const before = fs.readFileSync(configPath); + const state = config.getMcpConsentState(); + assert.equal(state.ok, false); + assert.match(state.error, /^Cannot load (config\.json|MCP consent):/, String(raw)); + assert.throws(() => config.setMcpServerEnabled('git', true), (error) => error.message === state.error); + assert.deepEqual(fs.readFileSync(configPath), before); + } + assert.equal(seen.length, 0); + } finally { off(); } +}); + +test('I/O and atomic-write failures propagate, keep disk bytes and emit no success', () => { + seed({ mcpDefaults: { git: { enabled: false } } }); + const before = fs.readFileSync(configPath); + const seen = []; + const off = config.onConfigWritten((next) => seen.push(next)); + try { + for (const method of ['readFileSync', 'writeFileSync', 'renameSync']) { + const original = fs[method]; + fs[method] = (...args) => { + if (String(args[0]).startsWith(configPath)) throw Object.assign(new Error(`injected ${method}`), { code: 'EACCES' }); + return original(...args); + }; + try { + assert.throws(() => config.setMcpServerEnabled('git', true), /injected/); + if (method === 'readFileSync') { + assert.deepEqual(config.getMcpConsentState(), { ok: false, error: 'injected readFileSync' }); + } + } finally { fs[method] = original; } + assert.deepEqual(fs.readFileSync(configPath), before); + assert.equal(fs.readdirSync(userData).some((name) => name.includes('.tmp-')), false); + } + assert.equal(seen.length, 0); + } finally { off(); } +}); + +test('first install, missing MCP map, reset and fresh default objects', () => { + fs.unlinkSync(configPath); + assert.equal(config.getMcpConsentState().ok, true); + const next = config.setMcpServerEnabled('git', true); + assert.deepEqual(next.mcpDefaults.git, { enabled: true, consentRevision: 2 }); + seed(); + config.setMcpServerEnabled('time', true); + assert.deepEqual(disk().mcpDefaults.time, { enabled: true }); + config.resetConfig(); + assert.deepEqual(disk().mcpDefaults.git, { enabled: false }); + const first = config.readConfig(); + first.mcpDefaults.git.enabled = true; + seed(); + assert.equal(config.readConfig().mcpDefaults.git.enabled, false); +}); + +test('non-UTF-8 bytes in an unrelated string: consent read is strict, ordinary writes keep every setting', () => { + config.resetConfig(); + const records = { git: { enabled: true, consentRevision: 2 }, filesystem: { enabled: true }, 'github-token': { enabled: true } }; + // GBK "中文" as a Windows ANSI editor saves it into a hand-edited value. + const raw = Buffer.concat([ + Buffer.from(JSON.stringify({ triggersMigratedV1: true, harnessHome: userData, slackBotToken: 'fixture-secret', mcpDefaults: records }) + .replace(/}$/, ',"godModel":"')), + Buffer.from([0xd6, 0xd0, 0xce, 0xc4]), Buffer.from('"}') + ]); + fs.writeFileSync(configPath, raw); + const consent = config.getMcpConsentState(); + assert.equal(consent.ok, false); + assert.match(consent.error, /UTF-8/); + assert.throws(() => config.setMcpServerEnabled('filesystem', true)); + assert.deepEqual(fs.readFileSync(configPath), raw); + + assert.equal(config.readConfig().harnessHome, userData); + config.writeConfig({ notifications: true }); + const saved = disk(); + assert.equal(saved.notifications, true); + assert.equal(saved.harnessHome, userData); + assert.equal(saved.slackBotToken, 'fixture-secret'); + // Replacement characters stay inside the string: consent is carried over, never widened. + assert.deepEqual(saved.mcpDefaults, records); + const after = config.getMcpConsentState(); + assert.equal(after.ok, true); + const on = (id) => resolveMcpEnablement(MCP_CATALOG.find((entry) => entry.id === id), after.records[id]); + assert.deepEqual(['git', 'filesystem', 'github-token'].map(on), [true, false, true]); +}); + +test('a UTF-8 BOM (Windows editors) is accepted by both ordinary and consent reads', () => { + config.resetConfig(); + fs.writeFileSync(configPath, '\uFEFF' + JSON.stringify({ triggersMigratedV1: true, harnessHome: userData, + mcpDefaults: { git: { enabled: true, consentRevision: 2 } } }), 'utf8'); + assert.equal(config.readConfig().harnessHome, userData); + assert.deepEqual(config.getMcpConsentState().records.git, { enabled: true, consentRevision: 2 }); +}); + +test('strict consent reads skip migrations; existing trigger migration still runs exactly once', () => { + config.resetConfig(); + const records = { git: { enabled: true }, 'github-token': { enabled: true } }; + seed({ triggersMigratedV1: false, webhookEnabled: true, webhookSecret: 'legacy-fixture', mcpDefaults: records }); + const before = fs.readFileSync(configPath); + const seen = []; + const off = config.onConfigWritten((next) => seen.push(next)); + try { + assert.equal(config.getMcpConsentState().ok, true); + assert.deepEqual(fs.readFileSync(configPath), before); + assert.equal(seen.length, 0); + const migrated = config.readConfig(); + assert.equal(migrated.triggersMigratedV1, true); + assert.equal(migrated.webhookTriggers[0].secret, 'legacy-fixture'); + assert.deepEqual(migrated.mcpDefaults, records); + assert.equal(seen.length, 1); + config.readConfig(); + assert.equal(seen.length, 1); + } finally { off(); } +}); + +test('persisted consent closes the loop through strict reads into new Session settings', async () => { + const home = path.join(userData, 'office 空白'); + fs.mkdirSync(home); + const hive = new HiveManager(() => home); + seed({ mcpDefaults: { git: { enabled: true }, filesystem: { enabled: true } } }); + const settings = async (id) => { + const state = config.getMcpConsentState(); + const injection = await hive.ensureAgent({ id, name: 'Consent', cwd: home, provider: 'claude' }, + { mcpDefaults: state.ok ? state.records : null }); + return JSON.parse(fs.readFileSync(injection.args[injection.args.indexOf('--settings') + 1], 'utf8')); + }; + const before = await settings('legacy'); + assert.equal(before.mcpServers['munder-git'], undefined); + const events = []; + const off = config.onConfigWritten((next) => { + assert.deepEqual(next.mcpDefaults, disk().mcpDefaults, 'announcement follows atomic persistence'); + events.push(next); + }); + try { + config.setMcpServerEnabled('git', true); + const granted = await settings('granted'); + assert.ok(granted.mcpServers['munder-git']); + assert.equal(granted.mcpServers['munder-filesystem'], undefined); + assert.deepEqual(granted.hooks, before.hooks); + config.setMcpServerEnabled('git', false); + assert.equal((await settings('revoked')).mcpServers['munder-git'], undefined); + assert.equal(events.length, 2); + fs.writeFileSync(configPath, '{"mcpDefaults":'); + assert.equal((await settings('corrupt')).mcpServers, undefined); + assert.equal(events.length, 2); + } finally { off(); } +}); diff --git a/test/mcp-consent-ui.test.cjs b/test/mcp-consent-ui.test.cjs new file mode 100644 index 0000000000..dbae9f2143 --- /dev/null +++ b/test/mcp-consent-ui.test.cjs @@ -0,0 +1,289 @@ +'use strict'; + +const test = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const ts = require('typescript'); +const loadTs = require('./load-ts.cjs'); +const catalog = loadTs('src/shared/mcpCatalog.ts'); + +// Execute the real component with a small hook host, no DOM/Electron GUI. +// State transitions and IPC ordering are exercised; this is not visual QA. +function mount(api) { + const slots = []; + let cursor = 0; + let effects = []; + const cleanups = []; + const t = (key, values) => values ? `${key}:${values.id}:${values.state}` : key; + const equal = (left, right) => left && right && left.length === right.length && left.every((value, index) => Object.is(value, right[index])); + const react = { + useState(initial) { + const index = cursor++; + if (!(index in slots)) slots[index] = initial; + return [slots[index], (value) => { slots[index] = typeof value === 'function' ? value(slots[index]) : value; }]; + }, + useRef(initial) { + const index = cursor++; + return slots[index] ?? (slots[index] = { current: initial }); + }, + useCallback(callback, deps) { + const index = cursor++; + if (!slots[index] || !equal(slots[index].deps, deps)) slots[index] = { callback, deps }; + return slots[index].callback; + }, + useEffect(effect, deps) { + const index = cursor++; + if (!slots[index] || !equal(slots[index], deps)) { + slots[index] = deps; + effects.push(() => { cleanups[index]?.(); cleanups[index] = effect(); }); + } + } + }; + const source = fs.readFileSync('src/renderer/src/components/McpDefaultsSettings.tsx', 'utf8'); + const js = ts.transpileModule(source, { compilerOptions: { module: ts.ModuleKind.CommonJS, + target: ts.ScriptTarget.ES2022, jsx: ts.JsxEmit.ReactJSX } }).outputText; + const mod = { exports: {} }; + new Function('module', 'exports', 'require', js)(mod, mod.exports, (id) => { + if (id === 'react') return react; + if (id === 'react-i18next') return { useTranslation: () => ({ t }) }; + if (id === '@shared/mcpCatalog') return catalog; + return require(id); + }); + global.window = { cth: api }; + const render = () => { + cursor = 0; + // No props: consent state comes only from main's strict read, never a config snapshot. + const tree = mod.exports.McpDefaultsSettings({}); + const pendingEffects = effects; + effects = []; + pendingEffects.forEach((effect) => effect()); + return tree; + }; + return { render, unmount: () => { cleanups.forEach((cleanup) => cleanup?.()); delete global.window; } }; +} + +function all(tree, predicate) { + if (Array.isArray(tree)) return tree.flatMap((node) => all(node, predicate)); + if (!tree || typeof tree !== 'object') return []; + return [...(predicate(tree) ? [tree] : []), ...all(tree.props?.children, predicate)]; +} +function buttons(tree) { return all(tree, (node) => node.type === 'button'); } +function serverButton(tree, id) { + const row = all(tree, (node) => node.key === id && node.type === 'div')[0]; + assert.ok(row, id); + return buttons(row)[0]; +} +function alert(tree) { return all(tree, (node) => node.props?.role === 'alert')[0]?.props.children; } +function status(tree) { return all(tree, (node) => node.props?.role === 'status')[0]?.props.children; } +const flush = () => new Promise((resolve) => setImmediate(resolve)); +const deferred = () => { + let resolve; + let reject; + const promise = new Promise((yes, no) => { resolve = yes; reject = no; }); + return { promise, resolve, reject }; +}; +function fixture(overrides = {}) { + let records = { git: { enabled: true }, filesystem: { enabled: true }, 'github-token': { enabled: true } }; + let changed; + const calls = []; + const api = { + getMcpConsentState: async () => ({ ok: true, records }), + setMcpServerEnabled: async (id, enabled) => { + calls.push([id, enabled]); + const entry = catalog.MCP_CATALOG.find((entry) => entry.id === id); + records = { ...records, [id]: enabled ? { enabled, consentRevision: entry.consentRevision ?? 1 } : { enabled } }; + changed?.({ mcpDefaults: records }); + return { mcpDefaults: records }; + }, + onConfigChanged: (listener) => { changed = listener; return () => { changed = undefined; }; }, + updateConfig: () => { throw new Error('must not use snapshot patch'); }, + ...overrides + }; + return { api, calls, broadcast(next) { records = next; changed?.({ mcpDefaults: next }); }, subscribed: () => !!changed }; +} + +test('UI resolves legacy grants OFF, confirms per-server save and follows cross-window revocation', async () => { + const host = fixture(); + const ui = mount(host.api); + try { + assert.ok(serverButton(ui.render(), 'git').props.disabled); + await flush(); + let tree = ui.render(); + assert.equal(serverButton(tree, 'git').props.children, 'common.off'); + assert.equal(serverButton(tree, 'github-token').props.children, 'common.on'); + serverButton(tree, 'git').props.onClick(); + tree = ui.render(); + assert.equal(serverButton(tree, 'git').props.disabled, true); + await flush(); + tree = ui.render(); + assert.deepEqual(host.calls, [['git', true]]); + assert.equal(serverButton(tree, 'git').props.children, 'common.on'); + assert.equal(status(tree), 'mcpDefaults.toggleNote:git:common.on'); + host.broadcast({ git: { enabled: false } }); + await flush(); + assert.equal(serverButton(ui.render(), 'git').props.children, 'common.off'); + } finally { ui.unmount(); } + assert.equal(host.subscribed(), false); +}); + +test('read rejection, invalid result and unavailable IPC leave unknown state and visible error', async () => { + for (const read of [undefined, () => { throw new Error('invoke failed'); }, + async () => ({ ok: false, error: 'bad JSON' }), async () => ({ ok: true, records: null }), + async () => ({ ok: true, records: [] })]) { + const host = fixture({ getMcpConsentState: read }); + const ui = mount(host.api); + try { + ui.render(); + await flush(); + const tree = ui.render(); + assert.equal(alert(tree), 'mcpDefaults.couldNotLoad'); + assert.equal(serverButton(tree, 'git').props.children, 'mcpDefaults.unavailable'); + assert.equal(serverButton(tree, 'git').props.disabled, true); + assert.equal(status(tree), undefined); + } finally { ui.unmount(); } + } +}); + +test('a failed change subscription is visible and cannot authorize', async () => { + const host = fixture({ onConfigChanged: () => { throw new Error('subscription failed'); } }); + const ui = mount(host.api); + try { + ui.render(); + const tree = ui.render(); + assert.equal(alert(tree), 'mcpDefaults.couldNotLoad'); + assert.equal(serverButton(tree, 'git').props.disabled, true); + assert.equal(status(tree), undefined); + } finally { ui.unmount(); } +}); + +test('failed saves and failed readback never report success; refresh can recover', async () => { + for (const save of [undefined, () => { throw new Error('sync invocation failed'); }, + async () => { throw new Error('disk failure'); }, async () => undefined]) { + const host = fixture({ setMcpServerEnabled: save }); + const ui = mount(host.api); + try { + ui.render(); await flush(); + serverButton(ui.render(), 'git').props.onClick(); + await flush(); + const tree = ui.render(); + assert.equal(alert(tree), 'mcpDefaults.couldNotSave'); + assert.equal(status(tree), undefined); + assert.equal(serverButton(tree, 'git').props.children, 'common.off'); + } finally { ui.unmount(); } + } + const host = fixture(); + const ui = mount(host.api); + try { + ui.render(); await flush(); + host.api.setMcpServerEnabled = async () => { + host.api.getMcpConsentState = async () => { throw new Error('readback failed'); }; + return { mcpDefaults: { git: { enabled: true, consentRevision: 2 } } }; + }; + serverButton(ui.render(), 'git').props.onClick(); + await flush(); + let tree = ui.render(); + assert.equal(alert(tree), 'mcpDefaults.couldNotLoad'); + assert.equal(status(tree), undefined); + assert.equal(serverButton(tree, 'git').props.disabled, true); + host.api.getMcpConsentState = async () => ({ ok: true, records: {} }); + buttons(tree).at(-1).props.onClick(); + await flush(); + tree = ui.render(); + assert.equal(alert(tree), undefined); + assert.equal(serverButton(tree, 'git').props.disabled, false); + } finally { ui.unmount(); } +}); + +test('newer cross-window read wins over an older pending read and duplicate clicks are blocked', async () => { + const oldRead = deferred(); + const save = deferred(); + const host = fixture({ getMcpConsentState: () => oldRead.promise, + setMcpServerEnabled: (...args) => { host.calls.push(args); return save.promise; } }); + const ui = mount(host.api); + try { + ui.render(); + host.api.getMcpConsentState = async () => ({ ok: true, records: { git: { enabled: false } } }); + host.broadcast({ git: { enabled: false } }); + await flush(); + oldRead.resolve({ ok: true, records: { git: { enabled: true, consentRevision: 2 } } }); + await flush(); + const button = serverButton(ui.render(), 'git'); + assert.equal(button.props.children, 'common.off'); + button.props.onClick(); button.props.onClick(); + assert.equal(host.calls.length, 1); + save.reject(new Error('rejected')); + await flush(); + assert.equal(alert(ui.render()), 'mcpDefaults.couldNotSave'); + } finally { ui.unmount(); } +}); + +test('preload forwards only server id and enabled and propagates invoke rejection', async () => { + let api; + const calls = []; + const electron = require.resolve('electron'); + require.cache[electron] = { id: electron, filename: electron, loaded: true, exports: { + contextBridge: { exposeInMainWorld: (_name, exposed) => { api = exposed; } }, + ipcRenderer: { invoke: async (...args) => { calls.push(args); throw new Error('invoke rejected'); } } + } }; + global.__APP_VERSION__ = 'test'; + try { loadTs('src/preload/index.ts'); } + finally { delete global.__APP_VERSION__; } + await assert.rejects(api.setMcpServerEnabled('git', true), /invoke rejected/); + await assert.rejects(api.getMcpConsentState(), /invoke rejected/); + assert.deepEqual(calls, [['config:setMcpServerEnabled', 'git', true], ['config:getMcpConsentState']]); + const main = fs.readFileSync('src/main/index.ts', 'utf8'); + assert.match(main, /ipcMain\.handle\('config:setMcpServerEnabled',[\s\S]*?setMcpServerEnabled\(catalogId, enabled\)/); + assert.match(main, /ipcMain\.handle\('config:getMcpConsentState', \(\) => getMcpConsentState\(\)\)/); + assert.match(main, /mcpDefaults: mcpDefaultsForSpawn\(\),/); + // The panel reads consent only from main; no stale config prop is threaded in. + const settings = fs.readFileSync('src/renderer/src/components/SettingsModal.tsx', 'utf8'); + assert.match(settings, //); + const spawnConsent = main.match(/function mcpDefaultsForSpawn\(\)[\s\S]*?\r?\n}\r?\n/)?.[0] ?? ''; + assert.match(spawnConsent, /const consent = getMcpConsentState\(\)/); + assert.match(spawnConsent, /return consent\.records/); + assert.match(spawnConsent, /console\.warn\([^)]*consent\.error\)/); + assert.match(spawnConsent, /breakerToast\(/); + assert.match(spawnConsent, /return null;/); +}); + +test('background config broadcasts keep records and save errors; a good read clears load errors', async () => { + const host = fixture(); + const ui = mount(host.api); + try { + ui.render(); await flush(); + const failedSave = host.api.setMcpServerEnabled; + host.api.setMcpServerEnabled = async () => { throw new Error('disk failure'); }; + serverButton(ui.render(), 'git').props.onClick(); + await flush(); + assert.equal(alert(ui.render()), 'mcpDefaults.couldNotSave'); + + // An unrelated write elsewhere (mission tick, theme) broadcasts the whole config. + const slowRead = deferred(); + host.api.getMcpConsentState = () => slowRead.promise; + host.broadcast({ git: { enabled: false } }); + let tree = ui.render(); + assert.equal(serverButton(tree, 'git').props.disabled, false, 'no loading flash'); + assert.equal(serverButton(tree, 'git').props.children, 'common.off'); + assert.equal(alert(tree), 'mcpDefaults.couldNotSave'); + slowRead.resolve({ ok: true, records: { git: { enabled: false } } }); + await flush(); + assert.equal(alert(ui.render()), 'mcpDefaults.couldNotSave', 'save failure stays until the user acts'); + + host.api.getMcpConsentState = async () => ({ ok: false, error: 'bad JSON' }); + host.broadcast({}); + await flush(); + tree = ui.render(); + assert.equal(alert(tree), 'mcpDefaults.couldNotLoad'); + assert.equal(serverButton(tree, 'git').props.children, 'mcpDefaults.unavailable'); + assert.equal(serverButton(tree, 'git').props.disabled, true); + + host.api.getMcpConsentState = async () => ({ ok: true, records: { git: { enabled: true, consentRevision: 2 } } }); + host.api.setMcpServerEnabled = failedSave; + host.broadcast({}); + await flush(); + tree = ui.render(); + assert.equal(alert(tree), undefined); + assert.equal(serverButton(tree, 'git').props.children, 'common.on'); + assert.equal(serverButton(tree, 'git').props.disabled, false); + } finally { ui.unmount(); } +}); diff --git a/test/mcp-consent.test.cjs b/test/mcp-consent.test.cjs new file mode 100644 index 0000000000..4d5e40778b --- /dev/null +++ b/test/mcp-consent.test.cjs @@ -0,0 +1,153 @@ +'use strict'; + +const test = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const os = require('node:os'); +const path = require('node:path'); +const loadTs = require('./load-ts.cjs'); +const electron = require.resolve('electron'); +require.cache[electron] = { id: electron, filename: electron, loaded: true, + exports: { Notification: class { show() {} static isSupported() { return false; } } } }; +const { + MCP_CATALOG, MCP_TIERS, isMcpTier, mcpCatalogEntry, mcpConsentRecord, + mcpConsentGrant, requiredConsentRevision, defaultMcpDefaults, resolveMcpEnablement +} = loadTs('src/shared/mcpCatalog.ts'); +const { HiveManager } = loadTs('src/main/hive.ts'); +const targets = ['filesystem', 'git']; + +test('catalog classifies write-capable servers and seeds closed consent', () => { + assert.equal(new Set(MCP_CATALOG.map((entry) => entry.id)).size, MCP_CATALOG.length); + for (const entry of MCP_CATALOG) { + assert.ok(Number.isSafeInteger(entry.consentRevision ?? 1)); + assert.ok((entry.consentRevision ?? 1) >= 1); + if (entry.tier !== 'safe-readonly') assert.equal(entry.defaultEnabled, false); + assert.equal(defaultMcpDefaults()[entry.id].enabled, entry.defaultEnabled); + if (targets.includes(entry.id)) { + assert.equal(entry.tier, 'write'); + assert.equal(entry.defaultEnabled, false); + assert.equal(entry.consentRevision, 2); + } + } +}); + +test('tiers come from one list that the catalog and resolver both honour', () => { + assert.deepEqual([...MCP_TIERS], ['safe-readonly', 'write', 'secret']); + for (const entry of MCP_CATALOG) assert.equal(isMcpTier(entry.tier), true, entry.id); + for (const value of ['', 'readonly', 'WRITE', null, undefined, 1, ['write']]) assert.equal(isMcpTier(value), false); + const safe = MCP_CATALOG.find((entry) => entry.tier === 'safe-readonly' && entry.defaultEnabled); + assert.equal(resolveMcpEnablement({ ...safe, tier: 'admin' }, undefined), false); + assert.equal(resolveMcpEnablement({ ...safe, tier: 'admin' }, { enabled: true }), false); +}); + +test('one revision rule drives both the stored grant and the resolver', () => { + const byId = (id) => MCP_CATALOG.find((entry) => entry.id === id); + const db = byId('db'); + assert.equal(requiredConsentRevision(db), 1); + assert.equal(requiredConsentRevision({ ...db, consentRevision: undefined }), 1, 'explicit undefined is the legacy revision'); + assert.equal(requiredConsentRevision(byId('git')), 2); + for (const consentRevision of [0, -1, 1.5, NaN, Infinity, '2', null]) { + assert.equal(requiredConsentRevision({ ...db, consentRevision }), undefined, String(consentRevision)); + assert.equal(mcpConsentGrant({ ...db, consentRevision }), undefined); + } + assert.deepEqual(mcpConsentGrant(byId('git')), { enabled: true, consentRevision: 2 }); + assert.deepEqual(mcpConsentGrant(db), { enabled: true, consentRevision: 1 }); + assert.deepEqual(mcpConsentGrant(byId('time')), { enabled: true }); + assert.deepEqual(mcpConsentGrant({ ...byId('time'), consentRevision: 3 }), { enabled: true, consentRevision: 3 }); + assert.deepEqual(mcpConsentGrant({ ...db, consentRevision: undefined }), { enabled: true, consentRevision: 1 }); + assert.equal(mcpConsentGrant({ ...db, defaultEnabled: true }), undefined, 'invalid catalog policy grants nothing'); + for (const entry of MCP_CATALOG) assert.equal(resolveMcpEnablement(entry, mcpConsentGrant(entry)), true, entry.id); +}); + +test('resolver rejects legacy, malformed, stale and future grants', () => { + for (const id of targets) { + const entry = MCP_CATALOG.find((entry) => entry.id === id); + for (const record of [undefined, null, [], true, 'true', 1, {}, { enabled: 'true' }, + { enabled: 1 }, { enabled: null }, { enabled: true }, Object.create({ enabled: true, consentRevision: 2 }), + ...[undefined, null, 0, -1, 1, 3, 1.5, '2', NaN, Infinity, -Infinity, Number.MAX_SAFE_INTEGER + 1] + .map((consentRevision) => ({ enabled: true, consentRevision })), + { enabled: false, consentRevision: 2 }]) { + assert.equal(resolveMcpEnablement(entry, record), false, `${id}: ${String(record)}`); + } + assert.equal(resolveMcpEnablement(entry, { enabled: true, consentRevision: 2 }), true); + const throwing = new Proxy({}, { getPrototypeOf() { throw new Error('untrusted'); } }); + assert.equal(resolveMcpEnablement(entry, throwing), false); + assert.equal(resolveMcpEnablement(entry, { get enabled() { throw new Error('untrusted'); } }), false); + for (const consentRevision of [null, NaN, Infinity, 0, '2']) { + assert.equal(resolveMcpEnablement({ ...entry, consentRevision }, { enabled: true, consentRevision: 2 }), false); + } + assert.equal(resolveMcpEnablement({ ...entry, defaultEnabled: true }, { enabled: true, consentRevision: 2 }), false); + } +}); + +test('other MCP legacy consent and readonly defaults remain compatible', () => { + for (const entry of MCP_CATALOG.filter((entry) => !targets.includes(entry.id))) { + assert.equal(resolveMcpEnablement(entry, undefined), entry.defaultEnabled); + assert.equal(resolveMcpEnablement(entry, { enabled: true }), true); + assert.equal(resolveMcpEnablement(entry, { enabled: true, consentRevision: 1 }), true); + assert.equal(resolveMcpEnablement(entry, { enabled: true, consentRevision: 2 }), false); + assert.equal(resolveMcpEnablement(entry, { enabled: false }), false); + assert.equal(resolveMcpEnablement(entry, { enabled: true, consentRevision: NaN }), false); + } +}); + +test('catalog lookups and seeds do not expose mutable shared grants or specs', () => { + const entry = mcpCatalogEntry('github-token'); + entry.consentRevision = 99; + entry.spec.args.push('injected'); + entry.spec.env.GITHUB_PERSONAL_ACCESS_TOKEN = 'injected'; + assert.equal(mcpCatalogEntry('github-token').consentRevision, undefined); + assert.equal(mcpCatalogEntry('github-token').spec.args.includes('injected'), false); + assert.equal(mcpCatalogEntry('github-token').spec.env.GITHUB_PERSONAL_ACCESS_TOKEN, ''); + const seed = defaultMcpDefaults(); + seed.git.enabled = true; + assert.equal(defaultMcpDefaults().git.enabled, false); + assert.equal(mcpConsentRecord(new Proxy({}, { getOwnPropertyDescriptor() { throw new Error('trap'); } }), 'git'), null); + assert.equal(mcpConsentRecord(Object.create({ git: { enabled: true, consentRevision: 2 } }), 'git'), null); +}); + +test('real generated Claude settings use the same effective consent as the UI resolver', async () => { + const home = fs.mkdtempSync(path.join(os.tmpdir(), 'md-consent-')); + try { + const cwd = path.join(home, 'workspace 空白'); + fs.mkdirSync(cwd); + const hive = new HiveManager(() => home); + // The provider bridge exports sessions to the user's global Codex home. + // This test only verifies settings injection; keep that unrelated side effect isolated. + hive.exposeCodexDataDirs = () => {}; + hive.installCodexHooks = (dir) => path.join(dir, '.codex'); + for (const [index, records] of [undefined, { filesystem: { enabled: true }, git: { enabled: true } }, + { filesystem: { enabled: true, consentRevision: 2 }, git: { enabled: true, consentRevision: 2 }, + 'github-token': { enabled: true }, fetch: { enabled: false } }, + { filesystem: { enabled: true, consentRevision: 3 }, git: { enabled: 'true', consentRevision: 2 } }, + null, [], true, 'true'].entries()) { + const injection = await hive.ensureAgent({ id: `consent-${index}`, name: 'Consent', provider: 'claude', cwd }, { mcpDefaults: records }); + const settingsIndex = injection.args.indexOf('--settings'); + assert.ok(settingsIndex >= 0); + const settings = JSON.parse(fs.readFileSync(injection.args[settingsIndex + 1], 'utf8')); + const servers = settings.mcpServers ?? {}; + // Keep an assertion independent of the new helper to prove the old runtime bug in Red. + if (index < 2 || index >= 3) { + for (const id of targets) assert.equal(servers[`munder-${id}`], undefined); + } + for (const entry of MCP_CATALOG) { + const record = records && !Array.isArray(records) && typeof records === 'object' ? records[entry.id] : undefined; + const expected = records !== undefined && (records === null || typeof records !== 'object' || Array.isArray(records)) + ? false : resolveMcpEnablement(entry, record); + assert.equal(Object.hasOwn(servers, `munder-${entry.id}`), expected, entry.id); + if (expected) assert.deepEqual(servers[`munder-${entry.id}`], { + command: entry.spec.command, args: entry.spec.args.map((arg) => arg === '' ? cwd : arg), + ...(entry.spec.env ? { env: entry.spec.env } : {}) + }); + } + assert.ok(settings.hooks.SessionStart); + assert.equal(settings.sandbox.enabled, true); + } + for (const provider of ['codex', 'opencode', 'copilot', 'custom']) { + const injection = await hive.ensureAgent({ id: `provider-${provider}`, name: 'Provider', provider, cwd }, + { mcpDefaults: { filesystem: { enabled: true, consentRevision: 2 } } }); + assert.equal(injection.args.includes('--settings'), false, provider); + assert.equal(fs.existsSync(path.join(home, 'hive', 'agents', `provider-${provider}`, 'settings.json')), false); + } + } finally { fs.rmSync(home, { recursive: true, force: true }); } +});