/ memdir / teamMemPaths.ts
teamMemPaths.ts
  1  import { lstat, realpath } from 'fs/promises'
  2  import { dirname, join, resolve, sep } from 'path'
  3  import { getFeatureValue_CACHED_MAY_BE_STALE } from '../services/analytics/growthbook.js'
  4  import { getErrnoCode } from '../utils/errors.js'
  5  import { getAutoMemPath, isAutoMemoryEnabled } from './paths.js'
  6  
  7  /**
  8   * Error thrown when a path validation detects a traversal or injection attempt.
  9   */
 10  export 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   */
 22  function 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   */
 73  export 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   */
 84  export 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   */
 92  export 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   */
109  async 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   */
183  async 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   */
214  export 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   */
228  export 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   */
265  export 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   */
290  export function isTeamMemFile(filePath: string): boolean {
291    return isTeamMemoryEnabled() && isTeamMemPath(filePath)
292  }