Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/439-claude-normal-home-guard.md
Original file line number Diff line number Diff line change
@@ -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)
6 changes: 5 additions & 1 deletion packages/agent-bundle/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
52 changes: 46 additions & 6 deletions packages/agent-bundle/src/host-contracts/native-claude-contract.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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}`;
Expand Down Expand Up @@ -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
Comment on lines +350 to +351

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Document the narrowed normal-home guarantee

After this narrowing, modifications to any top-level .claude.json field other than mcpServers intentionally pass, while website/docs/en/reference/security.mdx:21-23, website/docs/zh/reference/security.mdx:17-20, and packages/agent-bundle/README.md:1058-1059 still tell users that contributor smoke commands fail whenever the selected host's normal home state changes. Update both locale pages and the README to describe the exact config/settings/plugins and user-scope MCP surface; otherwise the documented security guarantee is stronger than the implementation.

AGENTS.md reference: AGENTS.md:L71-L77

Useful? React with 👍 / 👎.

* "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<string> => {
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<ClaudeNormalHomeSnapshot> => 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),
Expand All @@ -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');
Expand Down
129 changes: 100 additions & 29 deletions packages/agent-bundle/tests/native-claude-contract.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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<void>,
) => {
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,
Expand All @@ -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<void>,
): Promise<void> => {
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 () => {
Expand Down
9 changes: 7 additions & 2 deletions website/docs/en/reference/security.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
6 changes: 5 additions & 1 deletion website/docs/zh/reference/security.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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**。
Expand Down
Loading