fix(config-protection): check MultiEdit edits[].file_path targets

Fixes #3761. config-protection only read tool_input.file_path, so a MultiEdit
with the config path inside edits[] edited an existing ruff.toml/.eslintrc
without the block. Check every effective target and block if any is an
existing protected config.
This commit is contained in:
Brian La Plume committed 2026-10-27 11:03:02 -07:00
1 parent 25278e7d7b
commit ed2f02dd06
2 files changed
+78 -4

No files matched your search

+27 -4
View File
@@ -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 };
+51
View File
@@ -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