diff --git a/.changeset/439-claude-normal-home-guard.md b/.changeset/439-claude-normal-home-guard.md new file mode 100644 index 000000000..f9f8a2959 --- /dev/null +++ b/.changeset/439-claude-normal-home-guard.md @@ -0,0 +1,5 @@ +--- +"agent-bundle": patch +--- + +Stop the native Claude contract smoke (`runNativeClaudeSmoke`, the `native-host-smoke` Claude source leg) from reporting `claude-native.normal-home.changed` → `harness-failure` on every signed-in turn against Claude Code 2.1.257+. The normal-home guard digested the sibling `.claude.json` whole, and the host rewrites that file's bookkeeping (cached feature flags, first-start and machine identity, notification and usage counters, per-project session statistics) on every start, even under `--no-session-persistence`. The guard now digests `config.json`, `settings.json`, `settings.local.json`, and `plugins/` as before, plus only the user-scope `mcpServers` registrations of `.claude.json` — a first start creating the file, or any bookkeeping rewrite, passes with `normalHome: 'unchanged'`, while adding, changing, or removing a registration or corrupting the file still fails. Fixes #439 (#529) diff --git a/packages/agent-bundle/README.md b/packages/agent-bundle/README.md index a1758e24d..86174fbba 100644 --- a/packages/agent-bundle/README.md +++ b/packages/agent-bundle/README.md @@ -1056,7 +1056,11 @@ pnpm test:packed:native:codex ``` Each command builds and installs one production-only tarball, removes provider API-key and -credential-shaped environment values, and fails if the selected host's normal home state changes. +credential-shaped environment values, and fails if the selected host's normal configuration, +settings, or installed-plugin state changes (for Claude: `~/.claude/config.json`, `settings.json`, +`settings.local.json`, and `plugins/`; the source contract smoke additionally guards the +user-scope `mcpServers` registrations in `~/.claude.json`, whose other keys are host bookkeeping +Claude Code rewrites on every start). Do not add provider API keys to the project configuration or use them as a fallback for either harness. diff --git a/packages/agent-bundle/src/host-contracts/native-claude-contract.ts b/packages/agent-bundle/src/host-contracts/native-claude-contract.ts index b385fd80d..6bd5a9805 100644 --- a/packages/agent-bundle/src/host-contracts/native-claude-contract.ts +++ b/packages/agent-bundle/src/host-contracts/native-claude-contract.ts @@ -1,5 +1,6 @@ import { createHash } from 'node:crypto'; +import { digest } from '../core/digest.ts'; import { isErrno } from '../core/errors.ts'; import { isRecord } from '../core/strict-json.ts'; import { spawn } from 'node:child_process'; @@ -244,12 +245,23 @@ export interface NativeClaudeSmokeReport { readonly status: 'harness-failure' | 'passed' | 'skipped'; } +/** + * The normal-home surface the smoke must leave untouched: the settings files, + * the installed plugin tree, and the user-scope MCP registrations inside the + * sibling `.claude.json` state file. The rest of that file is host + * bookkeeping Claude Code rewrites on every signed-in turn (cached feature + * flags, first-start and machine identity, notification and usage counters, + * per-project session statistics — 2.1.257+ even under + * `--no-session-persistence`), so digesting it whole made the guard trip on + * every real run (#439); `mcpServers` is the one durable configuration the + * file carries that a plugin smoke could plausibly alter. + */ interface ClaudeNormalHomeSnapshot { - readonly claudeJson: string; readonly config: string; readonly localSettings: string; readonly plugins: string; readonly settings: string; + readonly stateMcpServers: string; } const candidateSkillEventName = (pluginName: string, skillName: string): string => `${pluginName}:${skillName}`; @@ -334,20 +346,48 @@ const resolveClaudeNormalHome = ( return Object.freeze({ directory: join(homeDirectory, '.claude'), stateFile: join(homeDirectory, '.claude.json') }); }; +/** + * Digests the user-scope `mcpServers` registrations of Claude's `.claude.json` + * and nothing else in it. An absent file and a file without the key both mean + * "no registrations" (a first start in a fresh home creates the file without + * any), a file that is not a JSON object digests to its own constant, so the + * guard still notices the smoke creating registrations or corrupting the + * file, while the bookkeeping keys the host rewrites on every turn never enter + * the digest. + */ +const digestClaudeStateMcpServers = async (path: string): Promise => { + let text: string; + try { + text = await readFile(path, 'utf8'); + } catch (error) { + if (isErrno(error, 'ENOENT')) return 'none'; + throw error; + } + let parsed: unknown; + try { + parsed = JSON.parse(text); + } catch { + return 'unparsable'; + } + if (!isRecord(parsed)) return 'unparsable'; + if (!('mcpServers' in parsed)) return 'none'; + return digest(parsed.mcpServers); +}; + const snapshotClaudeNormalHome = async (paths: ClaudeNormalHomePaths): Promise => Object.freeze({ - claudeJson: await digestClaudeFileTree(paths.stateFile), config: await digestClaudeFileTree(join(paths.directory, 'config.json')), localSettings: await digestClaudeFileTree(join(paths.directory, 'settings.local.json')), plugins: await digestClaudeFileTree(join(paths.directory, 'plugins')), settings: await digestClaudeFileTree(join(paths.directory, 'settings.json')), + stateMcpServers: await digestClaudeStateMcpServers(paths.stateFile), }); const sameClaudeNormalHome = (left: ClaudeNormalHomeSnapshot, right: ClaudeNormalHomeSnapshot): boolean => - left.claudeJson === right.claudeJson - && left.config === right.config + left.config === right.config && left.localSettings === right.localSettings && left.plugins === right.plugins - && left.settings === right.settings; + && left.settings === right.settings + && left.stateMcpServers === right.stateMcpServers; const normalHomeFailure = (code: string, message: string): NativeClaudeSmokeReport => Object.freeze({ diagnostics: diagnostic(code, message), @@ -356,7 +396,7 @@ const normalHomeFailure = (code: string, message: string): NativeClaudeSmokeRepo const normalHomeChangedDiagnostic = Object.freeze({ code: 'claude-native.normal-home.changed', - message: 'Claude normal config/settings/plugins state changed; inspect local state without retaining its output.', + message: 'Claude normal config/settings/plugins state or user-scope MCP registrations changed; inspect local state without retaining its output.', }); const isMissingExecutableError = (error: unknown): boolean => isErrno(error, 'ENOENT'); diff --git a/packages/agent-bundle/tests/native-claude-contract.test.ts b/packages/agent-bundle/tests/native-claude-contract.test.ts index 1db341bdf..9ebd291db 100644 --- a/packages/agent-bundle/tests/native-claude-contract.test.ts +++ b/packages/agent-bundle/tests/native-claude-contract.test.ts @@ -3,7 +3,7 @@ import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { fileURLToPath } from 'node:url'; -import { expect, it } from '@rstest/core'; +import { describe, expect, it } from '@rstest/core'; const loadNativeClaudeContract = async () => import('../src/host-contracts/native-claude-contract.ts').catch(() => undefined); const nativeIt = process.env.AGENT_BUNDLE_NATIVE_CLAUDE_SMOKE === '1' ? it : it.skip; @@ -309,20 +309,29 @@ it('proves the normal Claude config, settings, and plugins stay unchanged withou } }); -it('protects the default sibling Claude state file without retaining its opaque contents', async () => { - const harness = await loadNativeClaudeContract(); - expect(harness).toBeDefined(); +/** + * The sibling `.claude.json` is host bookkeeping Claude Code rewrites on every + * signed-in turn (#439); the guard digests only its user-scope `mcpServers` + * registrations, never its opaque contents. + */ +describe('the default sibling Claude state file', () => { + const successfulStream = [ + '{"type":"system","subtype":"init","apiKeySource":"none","plugins":[{"name":"agent-bundle-native-smoke"}]}', + '{"type":"assistant","message":{"content":[{"type":"tool_use","name":"Skill","input":{"skill":"agent-bundle-native-smoke:agent-bundle-native-smoke"}}]}}', + '{"type":"result","subtype":"success"}', + ].join('\n'); - const root = await mkdtemp(join(tmpdir(), 'agent-bundle-claude-default-home-contract-')); - const defaultHome = join(root, 'home'); - const normalClaudeHome = join(defaultHome, '.claude'); - try { - await mkdir(normalClaudeHome, { recursive: true }); - await writeFile(join(defaultHome, '.claude.json'), '{"marker":"normal-sibling-marker"}\n'); - const result = await harness!.runNativeClaudeSmoke({ + /** Runs the smoke against `defaultHome`, letting the fake Claude rewrite `.claude.json` as `rewrite` describes. */ + const smokeWithStateRewrite = async ( + defaultHome: string, + rewrite: (stateFile: string) => Promise, + ) => { + const harness = await loadNativeClaudeContract(); + expect(harness).toBeDefined(); + return harness!.runNativeClaudeSmoke({ candidatePluginName, candidateSkillName, - cwd: root, + cwd: defaultHome, enabled: true, environment: { AGENT_BUNDLE_NATIVE_CLAUDE_SMOKE: '1', PATH: '/usr/bin' }, homeDirectory: defaultHome, @@ -334,26 +343,88 @@ it('protects the default sibling Claude state file without retaining its opaque return { exitCode: 0, stderr: '', stdout: '{"loggedIn":true,"authMethod":"claude.ai","subscriptionType":"pro"}\n' }; } if (request.args[0] === 'plugin') return { exitCode: 0, stderr: '', stdout: 'Plugin is valid.\n' }; - await writeFile(join(defaultHome, '.claude.json'), '{"marker":"normal-sibling-changed-marker"}\n'); - return { - exitCode: 0, - stderr: '', - stdout: [ - '{"type":"system","subtype":"init","apiKeySource":"none","plugins":[{"name":"agent-bundle-native-smoke"}]}', - '{"type":"assistant","message":{"content":[{"type":"tool_use","name":"Skill","input":{"skill":"agent-bundle-native-smoke:agent-bundle-native-smoke"}}]}}', - '{"type":"result","subtype":"success"}', - ].join('\n'), - }; + await rewrite(join(defaultHome, '.claude.json')); + return { exitCode: 0, stderr: '', stdout: successfulStream }; }, }); + }; - expect(result.status).toBe('harness-failure'); - expect((result as unknown as { readonly normalHome?: unknown }).normalHome).not.toBe('unchanged'); - expect(JSON.stringify(result)).not.toContain(root); - expect(JSON.stringify(result)).not.toMatch(/normal-sibling-(?:changed-)?marker/iu); - } finally { - await rm(root, { force: true, recursive: true }); - } + const withDefaultHome = async ( + initialState: string | undefined, + operation: (defaultHome: string) => Promise, + ): Promise => { + const root = await mkdtemp(join(tmpdir(), 'agent-bundle-claude-default-home-contract-')); + const defaultHome = join(root, 'home'); + try { + await mkdir(join(defaultHome, '.claude'), { recursive: true }); + if (initialState !== undefined) await writeFile(join(defaultHome, '.claude.json'), initialState); + await operation(defaultHome); + } finally { + await rm(root, { force: true, recursive: true }); + } + }; + + it('tolerates the bookkeeping rewrite of a signed-in turn and a first start creating the file', async () => { + // Keys observed moving on Claude Code 2.1.257–2.1.260 between turns, with + // the user-scope registrations left alone. + const before = { + cachedGrowthBookFeatures: { flag: 'normal-sibling-marker' }, + firstStartTime: '2026-09-01T00:00:00.000Z', + machineID: 'normal-sibling-machine-marker', + mcpServers: { registered: { command: 'normal-sibling-mcp-marker' } }, + numStartups: 41, + projects: { '/elsewhere': { allowedTools: [], lastSessionId: 'old' } }, + seenNotifications: ['a'], + }; + await withDefaultHome(`${JSON.stringify(before)}\n`, async (defaultHome) => { + const result = await smokeWithStateRewrite(defaultHome, (stateFile) => writeFile(stateFile, JSON.stringify({ + ...before, + cachedExperimentData: { experiment: 'normal-sibling-changed-marker' }, + cachedGrowthBookFeatures: { flag: 'normal-sibling-changed-marker' }, + numStartups: 42, + pluginUsage: { 'agent-bundle-native-smoke': 1 }, + projects: { ...before.projects, [defaultHome]: { allowedTools: [], lastSessionId: 'new', mcpServers: {} } }, + seenNotifications: ['a', 'b'], + skillUsage: { 'agent-bundle-native-smoke:agent-bundle-native-smoke': 1 }, + }))); + expect(result).toMatchObject({ normalHome: 'unchanged', status: 'passed' }); + expect(JSON.stringify(result)).not.toContain(defaultHome); + expect(JSON.stringify(result)).not.toMatch(/normal-sibling-[a-z-]*marker/iu); + }); + + // A fresh home: the first start writes the file (observed on 2.1.260 even + // signed out) without registering anything. + await withDefaultHome(undefined, async (defaultHome) => { + const result = await smokeWithStateRewrite(defaultHome, (stateFile) => writeFile( + stateFile, + '{"firstStartTime":"2026-09-04T05:36:00.000Z","machineID":"normal-sibling-marker","numStartups":1}\n', + )); + expect(result).toMatchObject({ normalHome: 'unchanged', status: 'passed' }); + }); + }); + + it('still fails when the smoke adds, changes, or removes user-scope MCP registrations', async () => { + const registered = '{"mcpServers":{"registered":{"command":"normal-sibling-mcp-marker"}},"numStartups":1}\n'; + const cases: readonly (readonly [string | undefined, string])[] = [ + [registered, '{"mcpServers":{"registered":{"command":"normal-sibling-changed-marker"}},"numStartups":2}\n'], + [registered, '{"mcpServers":{},"numStartups":2}\n'], + [registered, '{"numStartups":2}\n'], + ['{"numStartups":1}\n', '{"mcpServers":{"added":{"command":"normal-sibling-changed-marker"}},"numStartups":2}\n'], + [undefined, '{"mcpServers":{"added":{"command":"normal-sibling-changed-marker"}}}\n'], + // Corrupting the state file is a change too. + [registered, 'not json\n'], + ]; + for (const [initial, rewritten] of cases) { + await withDefaultHome(initial, async (defaultHome) => { + const result = await smokeWithStateRewrite(defaultHome, (stateFile) => writeFile(stateFile, rewritten)); + expect(result.status).toBe('harness-failure'); + expect(result.diagnostics.map((diagnostic) => diagnostic.code)).toEqual(['claude-native.normal-home.changed']); + expect((result as unknown as { readonly normalHome?: unknown }).normalHome).not.toBe('unchanged'); + expect(JSON.stringify(result)).not.toContain(defaultHome); + expect(JSON.stringify(result)).not.toMatch(/normal-sibling-[a-z-]*marker/iu); + }); + } + }); }); it('reports an incompatible Claude version as a harness failure before candidate validation', async () => { diff --git a/website/docs/en/reference/security.mdx b/website/docs/en/reference/security.mdx index 391226445..b984314d0 100644 --- a/website/docs/en/reference/security.mdx +++ b/website/docs/en/reference/security.mdx @@ -19,8 +19,13 @@ existing session, and there is no fallback path that reads an API key from confi Each Codex trial sets a temporary `CODEX_HOME` and copies only the installed CLI's opaque `auth.json` into it. Your normal Codex home, configuration, and installed-plugin state are not used as trial state and are left unchanged. The contributor smoke commands remove provider -API-key and credential-shaped environment values and fail if the selected host's normal home state -changes. +API-key and credential-shaped environment values and fail if the selected host's normal +configuration, settings, or installed-plugin state changes. For Claude that surface is +`~/.claude/config.json`, `settings.json`, `settings.local.json`, and `plugins/`, plus — in the +native contract smoke — the user-scope `mcpServers` registrations inside `~/.claude.json`. The +rest of `~/.claude.json` is host bookkeeping Claude Code rewrites on every start (cached feature +flags, first-start and machine identity, usage counters, per-project session statistics) and is +deliberately not guarded. Native authenticated smokes are excluded from default and ordinary local test runs, and the trusted self-hosted CI workflow that runs them uses an existing subscription session with **no diff --git a/website/docs/zh/reference/security.mdx b/website/docs/zh/reference/security.mdx index 7038e1602..d60493165 100644 --- a/website/docs/zh/reference/security.mdx +++ b/website/docs/zh/reference/security.mdx @@ -16,7 +16,11 @@ description: 'agent-bundle 的安全边界:不接受提供方凭据、仅 loop 每次 Codex 试验都会设置一个临时的 `CODEX_HOME`,并只把已安装 CLI 那份不透明的 `auth.json` 复制进去。 你日常的 Codex home、配置与已安装插件状态不会被用作试验状态,也不会被改动。贡献者冒烟命令会移除提供方 -API key 与形似凭据的环境取值,并在所选宿主的日常 home 状态发生变化时失败。 +API key 与形似凭据的环境取值,并在所选宿主的日常配置、设置或已安装插件状态发生变化时失败。对 Claude +而言,这个范围是 `~/.claude/config.json`、`settings.json`、`settings.local.json` 与 `plugins/`, +另外原生契约冒烟还会检查 `~/.claude.json` 里用户作用域的 `mcpServers` 注册。`~/.claude.json` 的其余 +内容是 Claude Code 每次启动都会重写的宿主记账数据(缓存的特性开关、首次启动与机器标识、使用计数、 +按项目的会话统计),有意不在守护范围内。 原生的已认证冒烟测试被排除在默认与日常本地测试运行之外,而运行它们的那个可信自托管 CI 工作流使用既有的 订阅会话,且**不使用任何工作流 secret**。