SPB Git

Repository Public

paths.ts 6.3 KB 141 lines typescript

1/**2 * Path safety for every file tool (CONTRACT §4).3 *4 * A model-supplied path is accepted only if, after resolving symlinks of the deepest existing ancestor, it stays5 * inside the (real) workspace. For writes, protected paths are refused regardless of policy: these are the files6 * a trusted, unsandboxed component (git, the engine, a login shell) would later execute or obey.7 */8import { lstat, realpath } from 'node:fs/promises';9import path from 'node:path';1011export class PathError extends Error {12  constructor(13    message: string,14    readonly code: 'invalid' | 'outside' | 'symlink_escape' | 'protected',15  ) {16    super(message);17    this.name = 'PathError';18  }19}2021export interface ResolvedPath {22  /** Real absolute path (symlinks of existing ancestors resolved). Use this for I/O. */23  abs: string;24  /** Workspace-relative POSIX path of `abs` ('' for the workspace root). Key of the file state map. */25  rel: string;26  /** Real absolute workspace root. */27  workspaceReal: string;28  /** Whether the final path exists (lstat). */29  exists: boolean;30}3132/** Shell startup files: never writable, at any depth (a login shell or a developer would execute them later). */33export const SHELL_RC_FILES: readonly string[] = [34  '.zshrc', '.zshenv', '.zprofile', '.zlogin', '.zlogout',35  '.bashrc', '.bash_profile', '.bash_login', '.bash_logout', '.profile', '.envrc',36];37/** Root-level instruction files the engine injects into the system prompt: read-only for agents. */38export const ROOT_INSTRUCTION_FILES: readonly string[] = ['agents.md', 'claude.md'];3940// Code points HFS+/APFS ignore in names (same family git guards against for `.git`): zero-width and bidi marks.41const IGNORABLE = /[‌‍‎‏‪-‮-]/g;42/** Fold a path segment the way a case-insensitive, normalisation-insensitive macOS volume would compare it. */43function fold(seg: string): string {44  return seg.normalize('NFC').replace(IGNORABLE, '').toLowerCase();45}4647/** Reason a workspace-relative path is protected, or null. `rel` uses '/' separators. */48export function protectedReason(rel: string): string | null {49  const segs = rel.split('/').filter((s) => s.length > 0).map(fold);50  if (segs.length === 0) return 'the workspace root itself is not a file';51  const first = segs[0] as string;52  const last = segs[segs.length - 1] as string;53  if (segs.includes('.git')) return '.git internals are only modified through the git tools';54  if (first === '.aos') return '.aos/ is reserved for Agent OS';55  if (segs.length === 1 && ROOT_INSTRUCTION_FILES.includes(first)) return 'project instruction files at the workspace root are read-only for agents';56  if (SHELL_RC_FILES.includes(last)) return 'shell startup files are never writable';57  return null;58}5960function isInside(root: string, p: string): boolean {61  return p === root || p.startsWith(root + path.sep);62}6364async function lexists(p: string): Promise<'symlink' | 'other' | null> {65  try {66    const st = await lstat(p);67    return st.isSymbolicLink() ? 'symlink' : 'other';68  } catch {69    return null;70  }71}7273/**74 * Resolve `input` (workspace-relative, or absolute inside the workspace) to a real path inside the workspace.75 * mode 'write' additionally refuses protected paths (checked on both the path as written and the resolved one).76 * Throws PathError with a message suitable for the model.77 */78export async function resolveInWorkspace(workspaceAbs: string, input: string, mode: 'read' | 'write'): Promise<ResolvedPath> {79  if (typeof input !== 'string' || input.length === 0) throw new PathError('Path is empty.', 'invalid');80  if (input.includes('\0')) throw new PathError('Path contains a NUL byte.', 'invalid');81  if (input.length > 4096) throw new PathError('Path is too long.', 'invalid');82  if (!path.isAbsolute(workspaceAbs)) throw new PathError('Workspace path is not absolute.', 'invalid');8384  let workspaceReal: string;85  try {86    workspaceReal = await realpath(workspaceAbs);87  } catch {88    throw new PathError('Workspace does not exist.', 'invalid');89  }9091  if (input.split(/[\\/]/).includes('..')) throw new PathError(`Path "${input}" contains "..": use a path relative to the workspace root without parent segments.`, 'invalid');9293  let lexical: string;94  if (path.isAbsolute(input)) {95    const norm = path.normalize(input);96    if (isInside(workspaceReal, norm)) lexical = norm;97    else if (isInside(path.normalize(workspaceAbs), norm)) lexical = path.join(workspaceReal, path.relative(path.normalize(workspaceAbs), norm));98    else throw new PathError(`Path "${input}" is outside the workspace. Use paths relative to the workspace root.`, 'outside');99  } else {100    lexical = path.join(workspaceReal, input);101  }102  if (!isInside(workspaceReal, lexical)) throw new PathError(`Path "${input}" is outside the workspace.`, 'outside');103104  // Realpath of the deepest existing ancestor; the remaining (non-existent) tail is appended verbatim.105  let cur = lexical;106  const tail: string[] = [];107  let real: string | null = null;108  let exists = false;109  for (let depth = 0; depth < 4096; depth++) {110    const kind = await lexists(cur);111    if (kind !== null) {112      try {113        real = await realpath(cur);114      } catch {115        // exists by lstat but cannot be resolved: dangling symlink or a loop. Writing through it would create116        // the target wherever it points; reading it is pointless.117        throw new PathError(`Path "${input}" goes through a symbolic link that cannot be resolved (dangling or looping).`, 'symlink_escape');118      }119      if (tail.length === 0) exists = true;120      break;121    }122    const parent = path.dirname(cur);123    if (parent === cur) break;124    tail.unshift(path.basename(cur));125    cur = parent;126  }127  if (real === null) throw new PathError(`Path "${input}" cannot be resolved.`, 'invalid');128  const abs = tail.length > 0 ? path.join(real, ...tail) : real;129  if (!isInside(workspaceReal, abs)) {130    throw new PathError(`Path "${input}" resolves outside the workspace through a symbolic link; refused.`, 'symlink_escape');131  }132133  const rel = path.relative(workspaceReal, abs).split(path.sep).join('/');134  if (mode === 'write') {135    const lexRel = path.relative(workspaceReal, lexical).split(path.sep).join('/');136    const reason = protectedReason(rel) ?? protectedReason(lexRel);137    if (reason) throw new PathError(`Path "${input}" is protected and cannot be written: ${reason}.`, 'protected');138  }139  return { abs, rel, workspaceReal, exists };140}141
Files main
Menu

Theme