2a3b5b4da5
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 25e184e5 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 25e184e5 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.
102 lines
3.7 KiB
JavaScript
102 lines
3.7 KiB
JavaScript
// tools/path-normalization.mjs
|
|
/**
|
|
* Path normalization — router-gate v4 spec §3.1.1.
|
|
* + glob-matcher util (used by skill-scope-verifier, tdd-real-test-verifier).
|
|
* Pure-core; I/O (realpath) injected via opts for testability.
|
|
*/
|
|
import path from 'node:path';
|
|
import os from 'node:os';
|
|
import fs from 'node:fs';
|
|
|
|
const ENV_WHITELIST = ['HOME', 'USERPROFILE', 'APPDATA', 'XDG_CONFIG_HOME', 'XDG_DATA_HOME', 'XDG_CACHE_HOME'];
|
|
|
|
export function expandHome(target, homedir) {
|
|
if (target === '~') return homedir;
|
|
if (target.startsWith('~/') || target.startsWith('~\\')) {
|
|
return homedir + target.slice(1);
|
|
}
|
|
return target;
|
|
}
|
|
|
|
export function expandEnvVars(target, env) {
|
|
let out = target;
|
|
for (const name of ENV_WHITELIST) {
|
|
const val = env[name];
|
|
if (val === undefined) continue;
|
|
out = out.split(`%${name}%`).join(val);
|
|
out = out.split(`\${${name}}`).join(val);
|
|
// bare $VAR — only when followed by non-word boundary.
|
|
// Use a function replacer so `val` is inserted literally (avoids $& / $' / $` replacement-pattern misinterpretation).
|
|
out = out.replace(new RegExp(`\\$${name}(?![A-Za-z0-9_])`, 'g'), () => val);
|
|
}
|
|
return out;
|
|
}
|
|
|
|
export function caseFold(p, platform) {
|
|
return platform === 'win32' ? p.toLowerCase() : p;
|
|
}
|
|
|
|
// NOTE: `pattern` must use forward slashes. For cross-platform path matching use `globMatch` instead.
|
|
export function globToRegExp(pattern) {
|
|
let re = '';
|
|
for (let i = 0; i < pattern.length; i++) {
|
|
const c = pattern[i];
|
|
if (c === '*') {
|
|
if (pattern[i + 1] === '*') {
|
|
re += '.*'; // ** — across separators
|
|
i++;
|
|
if (pattern[i + 1] === '/') i++; // consume trailing slash of **/
|
|
} else {
|
|
re += '[^/]*'; // * — within segment
|
|
}
|
|
} else if (c === '?') {
|
|
re += '[^/]';
|
|
} else if ('.+^${}()|[]\\'.includes(c)) {
|
|
re += '\\' + c;
|
|
} else {
|
|
re += c;
|
|
}
|
|
}
|
|
return new RegExp(`^${re}$`);
|
|
}
|
|
|
|
export function globMatch(pathStr, pattern) {
|
|
const norm = (s) => s.split('\\').join('/');
|
|
return globToRegExp(norm(pattern)).test(norm(pathStr));
|
|
}
|
|
|
|
/**
|
|
* Normalize a path: expand ~, expand whitelisted env vars, resolve, realpath, case-fold.
|
|
*
|
|
* @param {string} target - Raw path (may contain ~ or $VAR).
|
|
* @param {object} [opts]
|
|
* @param {string} [opts.homedir] - Override home directory (default: os.homedir()).
|
|
* @param {object} [opts.env] - Override environment map (default: process.env).
|
|
* @param {string} [opts.platform] - Override platform string (default: process.platform).
|
|
* @param {Function} [opts.realpath] - Injectable realpath (default: fs.realpathSync) — used for test determinism.
|
|
* @param {Function} [opts.resolve] - Injectable path.resolve (default: path.resolve) — injectable for cross-platform test determinism.
|
|
*/
|
|
export function pathNormalize(target, opts = {}) {
|
|
const {
|
|
homedir = os.homedir(),
|
|
env = process.env,
|
|
platform = process.platform,
|
|
realpath = fs.realpathSync,
|
|
resolve = path.resolve,
|
|
} = opts;
|
|
let p = expandHome(target, homedir);
|
|
p = expandEnvVars(p, env);
|
|
const resolved = resolve(p);
|
|
let real;
|
|
try {
|
|
real = realpath(resolved);
|
|
} catch (e) {
|
|
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
|
|
}
|
|
// 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('/');
|
|
}
|