diff --git a/README.md b/README.md index 3e1c7ae..c9de1bd 100644 --- a/README.md +++ b/README.md @@ -61,9 +61,9 @@ AGENTS.md already exists. Overwrite? [y]es / [n]o / [a]ll / [s]kip-all / [m]erge | File type | Merge behaviour | |---|---| -| Markdown (`AGENTS.md`, `CLAUDE.md`, agent prompts, command files) | Structured merge by top-level heading. User-edited sections are preserved. Sections marked `` are updated by the generator. | -| JSON (`.claude/settings.json`, Codex config) | Deep-merge with array union (de-duplicated). User wins on scalar conflicts for non-managed keys. | -| Any other format | Falls back to yes / no / all / skip; no structured merge option is offered. | +| Managed Markdown (`AGENTS.md`, `CLAUDE.md`, nested `AGENTS.md`) | Replaces the generated managed block and preserves user-authored content after ``. | +| JSON (`.claude/settings.json`) | Deep-merge with array union (de-duplicated). User wins on scalar conflicts except generator-controlled safety keys. | +| Any other format | Falls back to yes / no / all / skip; no structured merge option is offered. In `--merge-strategy=merge` mode, files without merge support are skipped and listed in the update summary; use `--merge-strategy=overwrite` or `--yes` to refresh them. | ### CI usage diff --git a/src/cli/update-command.ts b/src/cli/update-command.ts index 4f8b844..91cb30e 100644 --- a/src/cli/update-command.ts +++ b/src/cli/update-command.ts @@ -139,7 +139,9 @@ export async function updateCommand( logger.success(`Updated ${writeResult.writtenPaths.length} file(s).`); if (writeResult.skippedPaths.length > 0) { - logger.warn(`${writeResult.skippedPaths.length} existing Markdown file(s) were left unchanged.`); + logger.warn( + `${writeResult.skippedPaths.length} existing file(s) were left unchanged: ${writeResult.skippedPaths.join(', ')}`, + ); } }); } diff --git a/src/generator/generate-root-config.ts b/src/generator/generate-root-config.ts index 7495cb4..8afdbe7 100644 --- a/src/generator/generate-root-config.ts +++ b/src/generator/generate-root-config.ts @@ -3,6 +3,8 @@ import { renderTemplate } from '../utils/template-renderer.js'; import type { StackConfig } from '../schema/stack-config.js'; import type { WorkspaceStack } from '../schema/workspace-stack.js'; import type { GeneratorContext, GeneratedFile } from './types.js'; +import { mergeManagedTail } from './managed-sentinel.js'; +import { mergeJson } from './merge-json.js'; /** * Generate root-level configuration and documentation files based on the provided stack configuration. @@ -22,10 +24,10 @@ export async function generateRootConfig( if (config.targets.claudeCode) { const claudeMd = await renderTemplate('config/CLAUDE.md.ejs', context); - files.push({ path: 'CLAUDE.md', content: claudeMd }); + files.push({ path: 'CLAUDE.md', content: claudeMd, merge: mergeManagedTail }); const settings = await renderTemplate('config/settings.json.ejs', context); - files.push({ path: '.claude/settings.json', content: settings }); + files.push({ path: '.claude/settings.json', content: settings, merge: mergeJson }); } if (config.targets.codexCli) { @@ -37,7 +39,7 @@ export async function generateRootConfig( } const agentsMd = await renderTemplate('config/AGENTS.md.ejs', context); - files.push({ path: 'AGENTS.md', content: agentsMd }); + files.push({ path: 'AGENTS.md', content: agentsMd, merge: mergeManagedTail }); const agentsDeploymentMd = await renderTemplate('config/AGENTS-DEPLOYMENT.md.ejs', context); files.push({ path: 'AGENTS-DEPLOYMENT.md', content: agentsDeploymentMd }); @@ -123,6 +125,6 @@ async function emitNestedAgentsFiles(options: { const content = await renderTemplate('config/workspace-AGENTS.md.ejs', { workspace }); const filePath = posixPath.join(workspace.path, 'AGENTS.md'); - files.push({ path: filePath, content }); + files.push({ path: filePath, content, merge: mergeManagedTail }); } } diff --git a/src/generator/merge-json.ts b/src/generator/merge-json.ts index cea1c2a..3222a1c 100644 --- a/src/generator/merge-json.ts +++ b/src/generator/merge-json.ts @@ -6,11 +6,18 @@ export interface JsonObject { export type JsonArray = JsonValue[]; /** - * Keys controlled by the generator; incoming value wins for these keys. - * Extend this array as new managed keys are identified. + * Legacy key-level managed list. Prefer MANAGED_JSON_PATHS for new entries so + * common leaf names such as "mode" do not become generator-controlled globally. */ export const MANAGED_JSON_KEYS: readonly string[] = []; +const MANAGED_JSON_PATHS: readonly string[] = [ + 'permissions.disableBypassPermissionsMode', + 'sandbox.autoAllowBashIfSandboxed', + 'sandbox.enabled', + 'sandbox.mode', +]; + function isJsonObject(value: JsonValue): value is JsonObject { return value !== null && typeof value === 'object' && !Array.isArray(value); } @@ -32,7 +39,9 @@ function sortKeys(value: JsonValue): JsonValue { } return value; } - const sorted: JsonObject = {}; + // Null-prototype objects prevent special keys like "__proto__" from + // mutating prototypes while we recursively copy parsed JSON data. + const sorted = Object.create(null) as JsonObject; for (const key of Object.keys(value).sort()) { sorted[key] = sortKeys(value[key]); } @@ -88,28 +97,35 @@ function mergeArrays(existing: JsonArray, incoming: JsonArray): JsonArray { return unionMixedArrays(existing, incoming); } -function deepMerge(existing: JsonValue, incoming: JsonValue, key: string): JsonValue { +function isManagedJsonPath(path: string): boolean { + return MANAGED_JSON_PATHS.includes(path); +} + +function deepMerge(existing: JsonValue, incoming: JsonValue, key: string, path: string): JsonValue { if (isJsonObject(existing) && isJsonObject(incoming)) { - return mergeObjects(existing, incoming); + return mergeObjects(existing, incoming, path); } if (Array.isArray(existing) && Array.isArray(incoming)) { return mergeArrays(existing as JsonArray, incoming as JsonArray); } // Scalar conflict: user (existing) wins unless key is managed - if (MANAGED_JSON_KEYS.includes(key)) { + if (MANAGED_JSON_KEYS.includes(key) || isManagedJsonPath(path)) { return incoming; } return existing; } -function mergeObjects(existing: JsonObject, incoming: JsonObject): JsonObject { - const result: JsonObject = {}; +function mergeObjects(existing: JsonObject, incoming: JsonObject, basePath = ''): JsonObject { + // See sortKeys: keep intermediate merge objects null-prototype so attacker- + // controlled JSON keys are treated as data, not prototype setters. + const result = Object.create(null) as JsonObject; const allKeys = new Set([...Object.keys(existing), ...Object.keys(incoming)]); for (const key of allKeys) { + const childPath = basePath.length > 0 ? `${basePath}.${key}` : key; const inExisting = Object.prototype.hasOwnProperty.call(existing, key); const inIncoming = Object.prototype.hasOwnProperty.call(incoming, key); if (inExisting && inIncoming) { - result[key] = deepMerge(existing[key], incoming[key], key); + result[key] = deepMerge(existing[key], incoming[key], key, childPath); } else if (inExisting) { result[key] = existing[key]; } else { diff --git a/src/generator/write-file.ts b/src/generator/write-file.ts index 8b8ee96..b89114c 100644 --- a/src/generator/write-file.ts +++ b/src/generator/write-file.ts @@ -132,9 +132,8 @@ export async function writeFileSafe(input: WriteFileInput): Promise { } }); + it('attaches merge callbacks to generated root files with supported merge semantics', async () => { + const files = await generateAll(makeConfig()); + const filesByPath = new Map(files.map((file) => [file.path, file])); + + expect(filesByPath.get('CLAUDE.md')?.merge).toBe(mergeManagedTail); + expect(filesByPath.get('AGENTS.md')?.merge).toBe(mergeManagedTail); + expect(filesByPath.get('.claude/settings.json')?.merge).toBe(mergeJson); + }); + + it('attaches a managed-tail merge callback to nested AGENTS.md files', async () => { + const config = makeConfig(); + config.monorepo = { + isRoot: true, + tool: 'pnpm', + workspaces: [ + { + path: 'api', + language: 'python', + runtime: 'python', + framework: null, + packageManager: 'uv', + commands: { typeCheck: null, test: 'pytest', lint: null, build: null }, + }, + ], + }; + + const files = await generateAll(config); + const nestedAgentsMd = files.find((file) => file.path === 'api/AGENTS.md'); + + expect(nestedAgentsMd?.merge).toBe(mergeManagedTail); + }); + it('renders configured project structure paths in CLAUDE.md and AGENTS.md', async () => { const config = makeConfig({ paths: { @@ -182,6 +216,7 @@ describe('generateAll', () => { expect(nestedAgents?.content).toContain('mypy .'); expect(nestedAgents?.content).toContain('agents-workflows:managed-start'); expect(nestedAgents?.content).toContain('agents-workflows:managed-end'); + expect(nestedAgents?.merge).toBe(mergeManagedTail); }); it('renders the configured primary branch in workflow and git guidance', async () => { diff --git a/tests/generator/merge-json.test.ts b/tests/generator/merge-json.test.ts index 8bd1629..262bfab 100644 --- a/tests/generator/merge-json.test.ts +++ b/tests/generator/merge-json.test.ts @@ -49,6 +49,58 @@ describe('mergeJson', () => { expect(result.outer.shared).toBe('user'); }); + it('preserves __proto__ as data without changing the merged object prototype', () => { + const existing = '{"__proto__":{"polluted":true},"safe":"user"}'; + const incoming = json({ generated: true }); + + const result = parse(mergeJson({ existing, incoming })) as Record; + const protoDescriptor = Object.getOwnPropertyDescriptor(result, '__proto__'); + + expect(Object.getPrototypeOf(result)).toBe(Object.prototype); + expect(Object.prototype).not.toHaveProperty('polluted'); + expect(protoDescriptor?.value).toEqual({ polluted: true }); + expect(result.generated).toBe(true); + }); + + it('lets generator-controlled hard safety scalars win during conflicts', () => { + const existing = json({ + permissions: { + defaultMode: 'default', + disableBypassPermissionsMode: 'enable', + }, + sandbox: { + enabled: false, + mode: 'danger-full-access', + }, + }); + const incoming = json({ + permissions: { + defaultMode: 'acceptEdits', + disableBypassPermissionsMode: 'disable', + }, + sandbox: { + enabled: true, + mode: 'workspace-write', + }, + }); + + const result = parse(mergeJson({ existing, incoming })) as { + permissions: { + defaultMode: string; + disableBypassPermissionsMode: string; + }; + sandbox: { + enabled: boolean; + mode: string; + }; + }; + + expect(result.permissions.defaultMode).toBe('default'); + expect(result.permissions.disableBypassPermissionsMode).toBe('disable'); + expect(result.sandbox.enabled).toBe(true); + expect(result.sandbox.mode).toBe('workspace-write'); + }); + it('scalar conflict — user value wins', () => { const existing = json({ x: 1 }); const incoming = json({ x: 2 }); diff --git a/tests/generator/write-file-session.test.ts b/tests/generator/write-file-session.test.ts index 2d56b9d..9c9dbf4 100644 --- a/tests/generator/write-file-session.test.ts +++ b/tests/generator/write-file-session.test.ts @@ -66,7 +66,7 @@ describe('writeFileSafe — session overrides and special cases', () => { await expect(readFile(path, 'utf-8')).resolves.toBe('base|patch'); }); - it('falls back to overwrite with a warn when override is merge but no merge fn provided', async () => { + it('skips with a warn when override is merge but no merge fn provided', async () => { const prompt = makePrompt('n'); configureWriteSession({ override: 'merge' }); const path = join(tmpDir, 'file.md'); @@ -74,13 +74,13 @@ describe('writeFileSafe — session overrides and special cases', () => { const result = await writeFileSafe({ path, content: 'new' }); - expect(result).toEqual({ status: 'written', path }); + expect(result).toEqual({ status: 'skipped', path }); expect(prompt).not.toHaveBeenCalled(); expect(warnSpy).toHaveBeenCalled(); - await expect(readFile(path, 'utf-8')).resolves.toBe('new'); + await expect(readFile(path, 'utf-8')).resolves.toBe('old'); }); - it('S4: fallback-overwrite warn message contains merge and overwriting', async () => { + it('S4: fallback-skip warn message contains merge and skipping', async () => { makePrompt('n'); configureWriteSession({ override: 'merge' }); const path = join(tmpDir, 'file.md'); @@ -89,7 +89,7 @@ describe('writeFileSafe — session overrides and special cases', () => { await writeFileSafe({ path, content: 'new' }); expect(warnSpy).toHaveBeenCalledWith( - expect.stringMatching(/merge.*overwriting/i), + expect.stringMatching(/merge.*skipping/i), ); });