fix(gateguard): sanitize dangerous invisible unicode in denial paths (#3103)

* fix(gateguard): sanitize dangerous invisible unicode in denial paths

sanitizePath only stripped control characters and bidi overrides, so
the 394 codepoints that the repo-wide unicode safety policy classifies
as dangerous-invisible (zero-width U+200B-200D, U+2060, U+2061-2064,
variation selectors U+FE00-FE0F, tag block U+E0000-E007F, Hangul
fillers, U+FEFF, U+180E, U+3164) plus the U+2028/U+2029 line and
paragraph separators passed verbatim into Edit/Write denial messages.
Invisible characters in a quoted file path let a malicious path look
clean to a human reviewer.

Align sanitizePath with the CI policy list and cover it with a
hook-output regression test that asserts no dangerous invisible
character survives into the denial reason while visible path text
stays intact.

* refactor(gateguard): name the sanitizePath unicode policy constants

Address review feedback: move the code points and ranges out of
sanitizePath into descriptive constants mirroring
scripts/ci/check-unicode-safety.js, so the policy is auditable in one
place. Also seed the regression test input with U+2029 so the
paragraph-separator assertion is exercised rather than vacuous.

* fix(gateguard): sanitize C1 control characters in denial paths

Follow-up on the sanitizePath policy alignment: the Unicode C1 control
block (U+0080..U+009F) is invisible in every renderer like the ASCII
controls below 0x20, but the strip only covered the ASCII range. Add
C1_CONTROLS to the policy constants and seed U+0091 into the
regression test input.
This commit is contained in:
xsf
2026-09-21 14:40:16 -04:00
committed by GitHub
parent 8b951d3bf6
commit bf70150eb2
2 changed files with 79 additions and 5 deletions
+28
View File
@@ -3369,6 +3369,34 @@ function runTests() {
console.error(` [cleanup] failed to remove ${stateDir}: ${err.message}`);
}
// --- sanitizePath dangerous invisible unicode regression ---
clearState();
if (
test('sanitizePath strips CI-defined dangerous invisible unicode from denial paths', () => {
const file_path =
'/src/eu2028\u2028eu2029\u2029app.js\u200bhidden\u2060name\ufefftail\u3164x\u0091c1.js';
const input = {
tool_name: 'Edit',
tool_input: { file_path, old_string: 'foo', new_string: 'bar' }
};
const result = runHook(input);
const output = parseOutput(result.stdout);
const reason = String(
output && output.hookSpecificOutput
? output.hookSpecificOutput.permissionDecisionReason
: ''
);
for (const bad of ['\u2028', '\u2029', '\u200b', '\u2060', '\ufeff', '\u3164', '\u0091']) {
assert.ok(!reason.includes(bad), `denial reason must not carry U+${bad.codePointAt(0).toString(16)} (${bad})`);
}
assert.ok(reason.includes('app.js'), 'visible path text must remain');
})
) {
passed++;
} else {
failed++;
}
console.log(`\n ${passed} passed, ${failed} failed\n`);
process.exit(failed > 0 ? 1 : 0);
}