diff --git a/scripts/hooks/config-protection.js b/scripts/hooks/config-protection.js index 8dce8b2ea..a6aa9180e 100644 --- a/scripts/hooks/config-protection.js +++ b/scripts/hooks/config-protection.js @@ -157,9 +157,32 @@ function run(inputOrRaw, options = {}) { } const input = parseInput(inputOrRaw); - const filePath = input?.tool_input?.file_path || input?.tool_input?.file || ''; - if (!filePath) return { exitCode: 0 }; + for (const filePath of targetPaths(input?.tool_input)) { + const blocked = checkTarget(filePath); + if (blocked) return blocked; + } + return { exitCode: 0 }; +} +/** + * Every path the tool call would write. A MultiEdit entry may carry its own + * `file_path`; one without falls back to the top-level path, the same rule + * GateGuard applies (#3761). Checking only the top-level path let a batch + * edit an existing config through `edits[].file_path`. + */ +function targetPaths(toolInput) { + const shared = toolInput?.file_path || toolInput?.file || ''; + const paths = new Set(); + if (typeof shared === 'string' && shared) paths.add(shared); + const edits = Array.isArray(toolInput?.edits) ? toolInput.edits : []; + for (const edit of edits) { + const entryPath = edit && typeof edit === 'object' ? edit.file_path : undefined; + if (typeof entryPath === 'string' && entryPath) paths.add(entryPath); + } + return [...paths]; +} + +function checkTarget(filePath) { const basename = path.basename(filePath); // Match case-insensitively. Every PROTECTED_FILES entry is lowercase, and on // case-insensitive filesystems (macOS APFS/HFS+, Windows NTFS) a write to @@ -194,7 +217,7 @@ function run(inputOrRaw, options = {}) { } if (!exists) { - return { exitCode: 0 }; + return null; } return { @@ -207,7 +230,7 @@ function run(inputOrRaw, options = {}) { }; } - return { exitCode: 0 }; + return null; } module.exports = { run }; diff --git a/tests/hooks/config-protection.test.js b/tests/hooks/config-protection.test.js index 9ab7d844f..8f766ff3c 100644 --- a/tests/hooks/config-protection.test.js +++ b/tests/hooks/config-protection.test.js @@ -466,6 +466,57 @@ function runTests() { }) ); + results.push( + test('blocks a MultiEdit whose existing config target is only in edits[].file_path (#3761)', () => { + return withOwnedDirectory(fs.mkdtempSync(path.join(os.tmpdir(), 'ecc-config-protect-multi-')), tmpDir => { + const ruff = path.join(tmpDir, 'ruff.toml'); + const eslintrc = path.join(tmpDir, '.eslintrc.json'); + fs.writeFileSync(ruff, '{}'); + fs.writeFileSync(eslintrc, '{}'); + const ordinary = path.join(tmpDir, 'app.py'); + + const perEntryOnly = runHook({ + tool_name: 'MultiEdit', + tool_input: { edits: [{ file_path: ruff, old_string: '{}', new_string: '{ }' }] } + }); + assert.strictEqual(perEntryOnly.code, 2, 'per-entry-only batch should be blocked: ' + perEntryOnly.stderr); + assert.ok(perEntryOnly.stderr.includes('BLOCKED: Modifying ruff.toml is not allowed.'), perEntryOnly.stderr); + + const mixed = runHook({ + tool_name: 'MultiEdit', + tool_input: { + file_path: ordinary, + edits: [ + { old_string: 'a', new_string: 'b' }, + { file_path: eslintrc, old_string: '{}', new_string: '{ }' } + ] + } + }); + assert.strictEqual(mixed.code, 2, 'mixed ordinary/config batch should be blocked: ' + mixed.stderr); + assert.ok(mixed.stderr.includes('BLOCKED: Modifying .eslintrc.json is not allowed.'), mixed.stderr); + }); + }) + ); + + results.push( + test('allows a MultiEdit that only creates or touches non-protected files', () => { + return withOwnedDirectory(fs.mkdtempSync(path.join(os.tmpdir(), 'ecc-config-protect-multi-ok-')), tmpDir => { + const result = runHook({ + tool_name: 'MultiEdit', + tool_input: { + file_path: path.join(tmpDir, 'app.py'), + edits: [ + { old_string: 'a', new_string: 'b' }, + { file_path: path.join(tmpDir, 'lib.py'), old_string: 'a', new_string: 'b' }, + { file_path: path.join(tmpDir, 'ruff.toml'), old_string: '', new_string: '[lint]' } + ] + } + }); + assert.strictEqual(result.code, 0, 'batch with no existing protected target should pass: ' + result.stderr); + }); + }) + ); + results.push( test('does not block build or test tooling configs', () => { // Pins the boundary: this hook guards LINTER configs. A future widening