source dump of claude code
at main 292 lines 12 kB view raw
1import { lstat, realpath } from 'fs/promises' 2import { dirname, join, resolve, sep } from 'path' 3import { getFeatureValue_CACHED_MAY_BE_STALE } from '../services/analytics/growthbook.js' 4import { getErrnoCode } from '../utils/errors.js' 5import { getAutoMemPath, isAutoMemoryEnabled } from './paths.js' 6 7/** 8 * Error thrown when a path validation detects a traversal or injection attempt. 9 */ 10export class PathTraversalError extends Error { 11 constructor(message: string) { 12 super(message) 13 this.name = 'PathTraversalError' 14 } 15} 16 17/** 18 * Sanitize a file path key by rejecting dangerous patterns. 19 * Checks for null bytes, URL-encoded traversals, and other injection vectors. 20 * Returns the sanitized string or throws PathTraversalError. 21 */ 22function sanitizePathKey(key: string): string { 23 // Null bytes can truncate paths in C-based syscalls 24 if (key.includes('\0')) { 25 throw new PathTraversalError(`Null byte in path key: "${key}"`) 26 } 27 // URL-encoded traversals (e.g. %2e%2e%2f = ../) 28 let decoded: string 29 try { 30 decoded = decodeURIComponent(key) 31 } catch { 32 // Malformed percent-encoding (e.g. %ZZ, lone %) — not valid URL-encoding, 33 // so no URL-encoded traversal is possible 34 decoded = key 35 } 36 if (decoded !== key && (decoded.includes('..') || decoded.includes('/'))) { 37 throw new PathTraversalError(`URL-encoded traversal in path key: "${key}"`) 38 } 39 // Unicode normalization attacks: fullwidth ../ (U+FF0E U+FF0F) normalize 40 // to ASCII ../ under NFKC. While path.resolve/fs.writeFile treat these as 41 // literal bytes (not separators), downstream layers or filesystems may 42 // normalize — reject for defense-in-depth (PSR M22187 vector 4). 43 const normalized = key.normalize('NFKC') 44 if ( 45 normalized !== key && 46 (normalized.includes('..') || 47 normalized.includes('/') || 48 normalized.includes('\\') || 49 normalized.includes('\0')) 50 ) { 51 throw new PathTraversalError( 52 `Unicode-normalized traversal in path key: "${key}"`, 53 ) 54 } 55 // Reject backslashes (Windows path separator used as traversal vector) 56 if (key.includes('\\')) { 57 throw new PathTraversalError(`Backslash in path key: "${key}"`) 58 } 59 // Reject absolute paths 60 if (key.startsWith('/')) { 61 throw new PathTraversalError(`Absolute path key: "${key}"`) 62 } 63 return key 64} 65 66/** 67 * Whether team memory features are enabled. 68 * Team memory is a subdirectory of auto memory, so it requires auto memory 69 * to be enabled. This keeps all team-memory consumers (prompt, content 70 * injection, sync watcher, file detection) consistent when auto memory is 71 * disabled via env var or settings. 72 */ 73export function isTeamMemoryEnabled(): boolean { 74 if (!isAutoMemoryEnabled()) { 75 return false 76 } 77 return getFeatureValue_CACHED_MAY_BE_STALE('tengu_herring_clock', false) 78} 79 80/** 81 * Returns the team memory path: <memoryBase>/projects/<sanitized-project-root>/memory/team/ 82 * Lives as a subdirectory of the auto-memory directory, scoped per-project. 83 */ 84export function getTeamMemPath(): string { 85 return (join(getAutoMemPath(), 'team') + sep).normalize('NFC') 86} 87 88/** 89 * Returns the team memory entrypoint: <memoryBase>/projects/<sanitized-project-root>/memory/team/MEMORY.md 90 * Lives as a subdirectory of the auto-memory directory, scoped per-project. 91 */ 92export function getTeamMemEntrypoint(): string { 93 return join(getAutoMemPath(), 'team', 'MEMORY.md') 94} 95 96/** 97 * Resolve symlinks for the deepest existing ancestor of a path. 98 * The target file may not exist yet (we may be about to create it), so we 99 * walk up the directory tree until realpath() succeeds, then rejoin the 100 * non-existing tail onto the resolved ancestor. 101 * 102 * SECURITY (PSR M22186): path.resolve() does NOT resolve symlinks. An attacker 103 * who can place a symlink inside teamDir pointing outside (e.g. to 104 * ~/.ssh/authorized_keys) would pass a resolve()-based containment check. 105 * Using realpath() on the deepest existing ancestor ensures we compare the 106 * actual filesystem location, not the symbolic path. 107 * 108 */ 109async function realpathDeepestExisting(absolutePath: string): Promise<string> { 110 const tail: string[] = [] 111 let current = absolutePath 112 // Walk up until realpath succeeds. ENOENT means this segment doesn't exist 113 // yet; pop it onto the tail and try the parent. ENOTDIR means a non-directory 114 // component sits in the middle of the path; pop and retry so we can realpath 115 // the ancestor to detect symlink escapes. 116 // Loop terminates when we reach the filesystem root (dirname('/') === '/'). 117 for ( 118 let parent = dirname(current); 119 current !== parent; 120 parent = dirname(current) 121 ) { 122 try { 123 const realCurrent = await realpath(current) 124 // Rejoin the non-existing tail in reverse order (deepest popped first) 125 return tail.length === 0 126 ? realCurrent 127 : join(realCurrent, ...tail.reverse()) 128 } catch (e: unknown) { 129 const code = getErrnoCode(e) 130 if (code === 'ENOENT') { 131 // Could be truly non-existent (safe to walk up) OR a dangling symlink 132 // whose target doesn't exist. Dangling symlinks are an attack vector: 133 // writeFile would follow the link and create the target outside teamDir. 134 // lstat distinguishes: it succeeds for dangling symlinks (the link entry 135 // itself exists), fails with ENOENT for truly non-existent paths. 136 try { 137 const st = await lstat(current) 138 if (st.isSymbolicLink()) { 139 throw new PathTraversalError( 140 `Dangling symlink detected (target does not exist): "${current}"`, 141 ) 142 } 143 // lstat succeeded but isn't a symlink — ENOENT from realpath was 144 // caused by a dangling symlink in an ancestor. Walk up to find it. 145 } catch (lstatErr: unknown) { 146 if (lstatErr instanceof PathTraversalError) { 147 throw lstatErr 148 } 149 // lstat also failed (truly non-existent or inaccessible) — safe to walk up. 150 } 151 } else if (code === 'ELOOP') { 152 // Symlink loop — corrupted or malicious filesystem state. 153 throw new PathTraversalError( 154 `Symlink loop detected in path: "${current}"`, 155 ) 156 } else if (code !== 'ENOTDIR' && code !== 'ENAMETOOLONG') { 157 // EACCES, EIO, etc. — cannot verify containment. Fail closed by wrapping 158 // as PathTraversalError so the caller can skip this entry gracefully 159 // instead of aborting the entire batch. 160 throw new PathTraversalError( 161 `Cannot verify path containment (${code}): "${current}"`, 162 ) 163 } 164 tail.push(current.slice(parent.length + sep.length)) 165 current = parent 166 } 167 } 168 // Reached filesystem root without finding an existing ancestor (rare — 169 // root normally exists). Fall back to the input; containment check will reject. 170 return absolutePath 171} 172 173/** 174 * Check whether a real (symlink-resolved) path is within the real team 175 * memory directory. Both sides are realpath'd so the comparison is between 176 * canonical filesystem locations. 177 * 178 * If teamDir does not exist, returns true (skips the check). This is safe: 179 * a symlink escape requires a pre-existing symlink inside teamDir, which 180 * requires teamDir to exist. If there's no directory, there's no symlink, 181 * and the first-pass string-level containment check is sufficient. 182 */ 183async function isRealPathWithinTeamDir( 184 realCandidate: string, 185): Promise<boolean> { 186 let realTeamDir: string 187 try { 188 // getTeamMemPath() includes a trailing separator; strip it because 189 // realpath() rejects trailing separators on some platforms. 190 realTeamDir = await realpath(getTeamMemPath().replace(/[/\\]+$/, '')) 191 } catch (e: unknown) { 192 const code = getErrnoCode(e) 193 if (code === 'ENOENT' || code === 'ENOTDIR') { 194 // Team dir doesn't exist — symlink escape impossible, skip check. 195 return true 196 } 197 // Unexpected error (EACCES, EIO) — fail closed. 198 return false 199 } 200 if (realCandidate === realTeamDir) { 201 return true 202 } 203 // Prefix-attack protection: require separator after the prefix so that 204 // "/foo/team-evil" doesn't match "/foo/team". 205 return realCandidate.startsWith(realTeamDir + sep) 206} 207 208/** 209 * Check if a resolved absolute path is within the team memory directory. 210 * Uses path.resolve() to convert relative paths and eliminate traversal segments. 211 * Does NOT resolve symlinks — for write validation use validateTeamMemWritePath() 212 * or validateTeamMemKey() which include symlink resolution. 213 */ 214export function isTeamMemPath(filePath: string): boolean { 215 // SECURITY: resolve() converts to absolute and eliminates .. segments, 216 // preventing path traversal attacks (e.g. "team/../../etc/passwd") 217 const resolvedPath = resolve(filePath) 218 const teamDir = getTeamMemPath() 219 return resolvedPath.startsWith(teamDir) 220} 221 222/** 223 * Validate that an absolute file path is safe for writing to the team memory directory. 224 * Returns the resolved absolute path if valid. 225 * Throws PathTraversalError if the path contains injection vectors, escapes the 226 * directory via .. segments, or escapes via a symlink (PSR M22186). 227 */ 228export async function validateTeamMemWritePath( 229 filePath: string, 230): Promise<string> { 231 if (filePath.includes('\0')) { 232 throw new PathTraversalError(`Null byte in path: "${filePath}"`) 233 } 234 // First pass: normalize .. segments and check string-level containment. 235 // This is a fast rejection for obvious traversal attempts before we touch 236 // the filesystem. 237 const resolvedPath = resolve(filePath) 238 const teamDir = getTeamMemPath() 239 // Prefix attack protection: teamDir already ends with sep (from getTeamMemPath), 240 // so "team-evil/" won't match "team/" 241 if (!resolvedPath.startsWith(teamDir)) { 242 throw new PathTraversalError( 243 `Path escapes team memory directory: "${filePath}"`, 244 ) 245 } 246 // Second pass: resolve symlinks on the deepest existing ancestor and verify 247 // the real path is still within the real team dir. This catches symlink-based 248 // escapes that path.resolve() alone cannot detect. 249 const realPath = await realpathDeepestExisting(resolvedPath) 250 if (!(await isRealPathWithinTeamDir(realPath))) { 251 throw new PathTraversalError( 252 `Path escapes team memory directory via symlink: "${filePath}"`, 253 ) 254 } 255 return resolvedPath 256} 257 258/** 259 * Validate a relative path key from the server against the team memory directory. 260 * Sanitizes the key, joins with the team dir, resolves symlinks on the deepest 261 * existing ancestor, and verifies containment against the real team dir. 262 * Returns the resolved absolute path. 263 * Throws PathTraversalError if the key is malicious (PSR M22186). 264 */ 265export async function validateTeamMemKey(relativeKey: string): Promise<string> { 266 sanitizePathKey(relativeKey) 267 const teamDir = getTeamMemPath() 268 const fullPath = join(teamDir, relativeKey) 269 // First pass: normalize .. segments and check string-level containment. 270 const resolvedPath = resolve(fullPath) 271 if (!resolvedPath.startsWith(teamDir)) { 272 throw new PathTraversalError( 273 `Key escapes team memory directory: "${relativeKey}"`, 274 ) 275 } 276 // Second pass: resolve symlinks and verify real containment. 277 const realPath = await realpathDeepestExisting(resolvedPath) 278 if (!(await isRealPathWithinTeamDir(realPath))) { 279 throw new PathTraversalError( 280 `Key escapes team memory directory via symlink: "${relativeKey}"`, 281 ) 282 } 283 return resolvedPath 284} 285 286/** 287 * Check if a file path is within the team memory directory 288 * and team memory is enabled. 289 */ 290export function isTeamMemFile(filePath: string): boolean { 291 return isTeamMemoryEnabled() && isTeamMemPath(filePath) 292}