Repository Public
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