fix(router-gate-v4): Smoke 5 REAL fix — path-normalization separator bug

Smoke 5 restart-test (chistaa session) refuted stale-process hypothesis and
identified the real bug: Stream A's pathNormalize() returned OS-native paths
(backslashes on win32) while DEFAULT_PROTECTED_PATTERNS regexes are forward-slash
only.

Trace confirmation:
  Stream A pathNormalize('~/foo/bar.jsonl') on win32:
    BEFORE: 'c:\\users\\admin\\foo\\bar.jsonl' — backslashes
    AFTER:  'c:/users/admin/foo/bar.jsonl'      — forward slashes
  isProtectedPath now matches → Bash/PowerShell hooks block correctly.

Root cause: path.resolve() + fs.realpathSync() on Windows produce backslashes,
caseFold lowercases them but doesn't change separators. DEFAULT_PROTECTED_PATTERNS
in shell-content-rules.mjs are forward-slash regexes (e.g. /(^|\/)\.claude\/projects/i).
defaultPathNormalize fallback in shell-content-rules.mjs DID normalize separators,
which is why my emergency commit 1072fae4 unit-tests passed but live behavior
failed — live hooks use resolvePathNormalize() which returns Stream A's
buggy implementation.

Fix:
- path-normalization.mjs: append .split('\\').join('/') to pathNormalize output.
- path-normalization.test.mjs: +1 RED→GREEN test for win32 separator normalization.

Why previous commit 1072fae4 was incomplete:
- Added pattern to protected list ✓
- Added enforce-read-path-deny.mjs ✓ (Read tool — works because hook uses
  defaultPathNormalize directly, not resolvePathNormalize)
- Did NOT detect Bash/PowerShell path-normalize integration bug (debug script
  bypassed Stream A by passing defaultPathNormalize directly).

Side observation (recorded as Stream H TODO by chistaa session):
- extractPathArgs/pathDenyOverlay — non-reading path in non-first position is
  not checked fully. Independent latent bug, separate fix.

Regression: 1715/1715 vitest tools GREEN (+1 separator test).

Critical: re-run Smoke 5 in clean session — expected PASS all 6 vectors now.
This commit is contained in:
Дмитрий
2026-05-30 08:39:52 +03:00
parent 1072fae4a6
commit 9799b4d3e8
2 changed files with 18 additions and 1 deletions
+4 -1
View File
@@ -94,5 +94,8 @@ export function pathNormalize(target, opts = {}) {
if (e && e.code && e.code !== 'ENOENT') throw e; // surface real FS errors; fail-close handled by caller
real = resolved; // ENOENT — best-effort resolved path for unknown-state files
}
return caseFold(real, platform);
// Smoke 5 integration fix (2026-05-30): normalize ALL separators to forward slashes
// regardless of platform. DEFAULT_PROTECTED_PATTERNS regexes are forward-slash only.
// Without this, win32 path.resolve + realpath returns backslashes and patterns miss.
return caseFold(real, platform).split('\\').join('/');
}
+14
View File
@@ -78,6 +78,20 @@ describe('pathNormalize', () => {
it('expands ~ and resolves', () => {
expect(pathNormalize('~/x', { homedir: '/h', realpath: (p) => p, resolve: (p) => p, platform: 'linux', env: {} })).toBe('/h/x');
});
// Smoke 5 integration bug 2026-05-30 — Stream A pathNormalize returned backslashes
// on win32, breaking DEFAULT_PROTECTED_PATTERNS regex (forward-slash-only) match.
// Fix: normalize all separators to forward slashes in output, regardless of platform.
it('normalizes backslashes to forward slashes on win32 (integration fix for protected patterns)', () => {
const result = pathNormalize('~/foo/bar.jsonl', {
homedir: 'C:\\Users\\Admin',
realpath: (p) => p,
resolve: path.win32.resolve,
platform: 'win32',
env: {},
});
expect(result).not.toMatch(/\\/);
expect(result.includes('/')).toBe(true);
});
it('collapses .. via path.resolve', () => {
// Use path.posix.resolve to simulate POSIX behaviour cross-platform
const result = pathNormalize('a/../b', { realpath: (p) => p, resolve: path.posix.resolve, platform: 'linux', homedir: '/h', env: {} });