UNPKG

@sema-agent/core

Version:

Stateless, task-oriented AI agent core

871 lines (870 loc) • 42.5 kB
import { createHash } from "node:crypto"; import { readDenySentence } from "./read-deny.js"; import { homeViewsOf, homeViewUnreadSentence } from "./read-deny-home.js"; import { skipsRootsContainment } from "./containment-face.js"; import { sliceHeadSafe } from "../../core/surrogate-safe-slice.js"; import { fileArgPath, pathTargetValue, protectivePathTargetOf } from "../../core/tool-registry.js"; import { readPath } from "../../core/effective-path-target.js"; import { isUncSpelling } from "../../core/unc-spelling.js"; import { withGateTimeout, DEFAULT_GATE_AWAIT_TIMEOUT_MS } from "../../core/gate-bounded-await.js"; import { thrownText } from "../../core/thrown-value.js"; export function writeTargetPath(call, pt = protectivePathTargetOf(call)) { if (pt !== undefined) return pathTargetValue(call.args, pt); const np = call.args?.notebook_path; const notebook = typeof np === "string" && np.length > 0 ? np : undefined; return fileArgPath(call.args) ?? notebook; } export function sha256(content) { return createHash("sha256").update(content, "utf8").digest("hex"); } const SEP = "/"; export function relativeInEveryFamily(p) { return !(p.startsWith("/") || p.startsWith("\\") || /^[A-Za-z]:/.test(p)); } function nativeSepOf(p) { return isWinFormPath(p) && p.includes("\\") ? "\\" : "/"; } function lastSepIndex(p) { return isWinFormPath(p) ? Math.max(p.lastIndexOf("/"), p.lastIndexOf("\\")) : p.lastIndexOf("/"); } const BLOCKED_DEVICE_PATHS = new Set([ "/dev/zero", "/dev/random", "/dev/urandom", "/dev/full", "/dev/stdin", "/dev/stdout", "/dev/stderr", "/dev/tty", "/dev/console", "/dev/fd/0", "/dev/fd/1", "/dev/fd/2", ]); function isProcStdioFd(key) { return key.startsWith("/proc/") && (key.endsWith("/fd/0") || key.endsWith("/fd/1") || key.endsWith("/fd/2")); } export function namesStandardInput(path) { const k = path.startsWith("/") ? normalizeAbsPathLexically(path) : ""; return k === "/dev/stdin" || k === "/dev/fd/0" || (k.startsWith("/proc/") && k.endsWith("/fd/0")); } const PROC_SENSITIVE_SUFFIXES = ["/environ", "/cmdline", "/auxv", "/maps", "/mem", "/stat"]; function isProcSensitiveFile(key) { return key.startsWith("/proc/") && PROC_SENSITIVE_SUFFIXES.some((suf) => key.endsWith(suf)); } const WIN_RESERVED_RE = /^(CON|PRN|AUX|NUL|COM[0-9¹²³]|LPT[0-9¹²³]|CONIN\$|CONOUT\$)(\.[^\\/]*)?$/i; export function isWinFormPath(p) { return /^[A-Za-z]:[\\/]/.test(p) || p.startsWith("\\\\") || (!p.startsWith("/") && p.includes("\\")); } export function expandHomeTilde(p, home) { if (p === "~") return home; if (!p.startsWith("~")) return p; const next = p[1]; if (next !== "/" && next !== "\\") return p; return home + "/" + p.slice(2); } function isWinReservedDeviceKey(key) { if (!isWinFormPath(key)) return false; const t = key.replace(/[\\/]+$/, ""); const base = t.slice(Math.max(t.lastIndexOf("/"), t.lastIndexOf("\\")) + 1); return WIN_RESERVED_RE.test(base); } export function isBlockedDevicePath(key) { return BLOCKED_DEVICE_PATHS.has(key) || isProcStdioFd(key) || isProcSensitiveFile(key) || isWinReservedDeviceKey(key); } export function normalizeAbsPathLexically(p) { return foldSegmentsLexically(p, true); } export function foldRedundantPathSegments(p) { return foldSegmentsLexically(p, false); } function foldSegmentsLexically(p, foldParents) { p = stripWin32ExtendedPrefix(p); const isUnc = p.startsWith("\\\\"); const drive = /^[A-Za-z]:/.exec(p)?.[0] ?? ""; const winForm = isUnc || drive !== "" || (!p.startsWith("/") && p.includes("\\")); const sep = winForm && p.includes("\\") ? "\\" : "/"; const splitter = winForm ? /[\\/]+/ : /\/+/; let prefix = drive; let rest = p.slice(drive.length + (isUnc ? 2 : 0)); let abs = rest.startsWith("/") || (winForm && rest.startsWith("\\")); if (isUnc) { const segs = rest.split(splitter).filter((seg) => seg !== ""); const host = segs.shift() ?? ""; const share = segs.shift(); prefix = sep + sep + host + (share === undefined ? "" : sep + share); rest = segs.join(sep); abs = true; } const outSegs = []; for (const seg of rest.split(splitter)) { if (seg === "" || seg === ".") continue; if (seg === "..") { if (!foldParents) { outSegs.push(seg); continue; } if (outSegs.length > 0 && outSegs[outSegs.length - 1] !== "..") outSegs.pop(); else if (!abs) outSegs.push(".."); continue; } outSegs.push(seg); } if (outSegs.length === 0) return abs ? (isUnc ? prefix : prefix + sep) : prefix === "" ? "." : prefix; return prefix + (abs ? sep : "") + outSegs.join(sep); } const BINARY_EXTENSIONS = new Set([ ".exe", ".dll", ".so", ".dylib", ".o", ".a", ".obj", ".lib", ".bin", ".class", ".pyc", ".pyo", ".wasm", ".app", ".msi", ".deb", ".rpm", ".node", ".rlib", ".zip", ".tar", ".gz", ".tgz", ".bz2", ".xz", ".7z", ".rar", ".jar", ".war", ".ear", ".lz4", ".zst", ".z", ".png", ".jpg", ".jpeg", ".gif", ".webp", ".bmp", ".tiff", ".tif", ".ico", ".heic", ".avif", ".mp3", ".wav", ".flac", ".ogg", ".m4a", ".aac", ".wma", ".aiff", ".opus", ".mp4", ".mov", ".avi", ".mkv", ".webm", ".wmv", ".flv", ".m4v", ".mpeg", ".mpg", ".pdf", ".doc", ".docx", ".xls", ".xlsx", ".ppt", ".pptx", ".odt", ".ods", ".odp", ".woff", ".woff2", ".ttf", ".otf", ".eot", ".psd", ".ai", ".eps", ".sketch", ".fig", ".xd", ".blend", ".3ds", ".max", ".swf", ".fla", ".db", ".sqlite", ".sqlite3", ".mdb", ".idx", ".dat", ".data", ".lockb", ".pdb", ".ipa", ".apk", ".dmg", ".iso", ".img", ]); export function hasBinaryExtension(path) { const dot = path.lastIndexOf("."); if (dot < 0) return false; return BINARY_EXTENSIONS.has(path.slice(dot).toLowerCase()); } const IMAGE_MIME_BY_EXT = { ".png": "image/png", ".jpg": "image/jpeg", ".jpeg": "image/jpeg", ".gif": "image/gif", ".webp": "image/webp", }; export function imageMimeForRead(path) { const dot = path.lastIndexOf("."); if (dot < 0) return undefined; return IMAGE_MIME_BY_EXT[path.slice(dot).toLowerCase()]; } const asciiBytes = (s) => Array.from(s, (c) => c.charCodeAt(0)); export const BINARY_MAGIC_SIGNATURES = [ { format: "ZIP archive", segments: [{ offset: 0, bytes: [0x50, 0x4b, 0x03, 0x04] }] }, { format: "ZIP archive", segments: [{ offset: 0, bytes: [0x50, 0x4b, 0x05, 0x06] }] }, { format: "ZIP archive", segments: [{ offset: 0, bytes: [0x50, 0x4b, 0x07, 0x08] }] }, { format: "gzip archive", segments: [{ offset: 0, bytes: [0x1f, 0x8b, 0x08] }] }, ...[0x31, 0x32, 0x33, 0x34, 0x35, 0x36, 0x37, 0x38, 0x39].map((level) => ({ format: "bzip2 archive", segments: [{ offset: 0, bytes: [0x42, 0x5a, 0x68, level] }], })), { format: "xz archive", segments: [{ offset: 0, bytes: [0xfd, 0x37, 0x7a, 0x58, 0x5a, 0x00] }] }, { format: "zstd archive", segments: [{ offset: 0, bytes: [0x28, 0xb5, 0x2f, 0xfd] }] }, { format: "lz4 archive", segments: [{ offset: 0, bytes: [0x04, 0x22, 0x4d, 0x18] }] }, { format: "compress archive", segments: [{ offset: 0, bytes: [0x1f, 0x9d] }] }, { format: "7-Zip archive", segments: [{ offset: 0, bytes: [0x37, 0x7a, 0xbc, 0xaf, 0x27, 0x1c] }] }, { format: "RAR archive", segments: [{ offset: 0, bytes: [0x52, 0x61, 0x72, 0x21, 0x1a, 0x07, 0x00] }] }, { format: "RAR archive", segments: [{ offset: 0, bytes: [0x52, 0x61, 0x72, 0x21, 0x1a, 0x07, 0x01, 0x00] }] }, { format: "tar archive", segments: [{ offset: 257, bytes: asciiBytes("ustar") }] }, { format: "ar archive (static library or Debian package)", segments: [{ offset: 0, bytes: asciiBytes("!<arch>\n") }] }, { format: "RPM package", segments: [{ offset: 0, bytes: [0xed, 0xab, 0xee, 0xdb] }] }, { format: "ISO 9660 disc image", segments: [{ offset: 32769, bytes: asciiBytes("CD001") }] }, { format: "PNG image", segments: [{ offset: 0, bytes: [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a] }] }, { format: "JPEG image", segments: [{ offset: 0, bytes: [0xff, 0xd8, 0xff] }] }, { format: "GIF image", segments: [{ offset: 0, bytes: asciiBytes("GIF87a") }] }, { format: "GIF image", segments: [{ offset: 0, bytes: asciiBytes("GIF89a") }] }, { format: "TIFF image", segments: [{ offset: 0, bytes: [0x49, 0x49, 0x2a, 0x00] }] }, { format: "TIFF image", segments: [{ offset: 0, bytes: [0x4d, 0x4d, 0x00, 0x2a] }] }, { format: "BMP image", segments: [{ offset: 0, bytes: asciiBytes("BM") }, { offset: 6, bytes: [0x00, 0x00, 0x00, 0x00] }] }, { format: "ICO image", segments: [{ offset: 0, bytes: [0x00, 0x00, 0x01, 0x00] }] }, { format: "Photoshop document", segments: [{ offset: 0, bytes: asciiBytes("8BPS") }] }, { format: "WebP image", segments: [{ offset: 0, bytes: asciiBytes("RIFF") }, { offset: 8, bytes: asciiBytes("WEBP") }] }, { format: "RIFF audio (WAV)", segments: [{ offset: 0, bytes: asciiBytes("RIFF") }, { offset: 8, bytes: asciiBytes("WAVE") }] }, { format: "RIFF video (AVI)", segments: [{ offset: 0, bytes: asciiBytes("RIFF") }, { offset: 8, bytes: asciiBytes("AVI ") }] }, { format: "AIFF audio", segments: [{ offset: 0, bytes: asciiBytes("FORM") }, { offset: 8, bytes: asciiBytes("AIFF") }] }, { format: "Ogg container", segments: [{ offset: 0, bytes: asciiBytes("OggS") }, { offset: 4, bytes: [0x00] }] }, { format: "FLAC audio", segments: [{ offset: 0, bytes: asciiBytes("fLaC") }] }, ...[0x02, 0x03, 0x04].map((major) => ({ format: "MP3 audio (ID3 tag)", segments: [{ offset: 0, bytes: [0x49, 0x44, 0x33, major] }], })), { format: "ISO base media container (MP4/MOV/HEIC)", segments: [{ offset: 4, bytes: asciiBytes("ftyp") }] }, { format: "Matroska/WebM container", segments: [{ offset: 0, bytes: [0x1a, 0x45, 0xdf, 0xa3] }] }, { format: "ASF container (WMV/WMA)", segments: [{ offset: 0, bytes: [0x30, 0x26, 0xb2, 0x75, 0x8e, 0x66, 0xcf, 0x11, 0xa6, 0xd9, 0x00, 0xaa, 0x00, 0x62, 0xce, 0x6c] }] }, { format: "FLV video", segments: [{ offset: 0, bytes: [0x46, 0x4c, 0x56, 0x01] }] }, { format: "MPEG program stream", segments: [{ offset: 0, bytes: [0x00, 0x00, 0x01, 0xba] }] }, { format: "MPEG video stream", segments: [{ offset: 0, bytes: [0x00, 0x00, 0x01, 0xb3] }] }, { format: "ELF binary", segments: [{ offset: 0, bytes: [0x7f, 0x45, 0x4c, 0x46] }] }, { format: "Mach-O binary", segments: [{ offset: 0, bytes: [0xfe, 0xed, 0xfa, 0xce] }] }, { format: "Mach-O binary", segments: [{ offset: 0, bytes: [0xfe, 0xed, 0xfa, 0xcf] }] }, { format: "Mach-O binary", segments: [{ offset: 0, bytes: [0xce, 0xfa, 0xed, 0xfe] }] }, { format: "Mach-O binary", segments: [{ offset: 0, bytes: [0xcf, 0xfa, 0xed, 0xfe] }] }, { format: "Java class file or Mach-O universal binary", segments: [{ offset: 0, bytes: [0xca, 0xfe, 0xba, 0xbe] }] }, { format: "WebAssembly module", segments: [{ offset: 0, bytes: [0x00, 0x61, 0x73, 0x6d] }] }, { format: "OLE compound document (Office 97-2003 or MSI)", segments: [{ offset: 0, bytes: [0xd0, 0xcf, 0x11, 0xe0, 0xa1, 0xb1, 0x1a, 0xe1] }] }, { format: "SQLite database", segments: [{ offset: 0, bytes: [...asciiBytes("SQLite format 3"), 0x00] }] }, { format: "WOFF font", segments: [{ offset: 0, bytes: asciiBytes("wOFF") }] }, { format: "WOFF2 font", segments: [{ offset: 0, bytes: asciiBytes("wOF2") }] }, { format: "OpenType font", segments: [{ offset: 0, bytes: asciiBytes("OTTO") }] }, { format: "TrueType font", segments: [{ offset: 0, bytes: [0x00, 0x01, 0x00, 0x00] }] }, { format: "TrueType font collection", segments: [{ offset: 0, bytes: asciiBytes("ttcf") }] }, ]; export function binaryMagicFormat(bytes) { for (const signature of BINARY_MAGIC_SIGNATURES) { let matched = true; for (const segment of signature.segments) { if (bytes.length < segment.offset + segment.bytes.length) { matched = false; break; } for (let i = 0; i < segment.bytes.length; i++) { if (bytes[segment.offset + i] !== segment.bytes[i]) { matched = false; break; } } if (!matched) break; } if (matched) return signature.format; } return undefined; } export function isBinaryContent(sample) { if (sample.length === 0) return false; let nonPrintable = 0; for (let i = 0; i < sample.length; i++) { const c = sample.charCodeAt(i); if (c === 0) return true; if (c >= 0xd800 && c <= 0xdbff) { const next = i + 1 < sample.length ? sample.charCodeAt(i + 1) : -1; if (next < 0xdc00 || next > 0xdfff) return true; i++; continue; } if (c >= 0xdc00 && c <= 0xdfff) return true; if (c === 0xfffe || c === 0xffff) return true; if ((c < 0x20 && c !== 0x09 && c !== 0x0a && c !== 0x0d && c !== 0x0c && c !== 0x0b) || c === 0xfffd) { nonPrintable++; } } return nonPrintable / sample.length > 0.1; } export function isUncPath(path) { return path.startsWith("\\\\") || path.startsWith("//"); } function isWin32DeviceNamespacePath(p) { return p.startsWith("\\\\.\\") || p.startsWith("\\\\./"); } function stripWin32ExtendedPrefix(p) { if (!p.startsWith("\\\\?\\")) return p; const rest = p.slice(4); const unc = /^UNC\\/i.exec(rest); const body = unc !== null ? rest.slice(unc[0].length) : /^[A-Za-z]:\\/.test(rest) ? rest.slice(3) : undefined; if (body === undefined) return p; if (body !== "") { for (const seg of body.split("\\")) { if (seg === "" || seg === "." || seg === "..") return p; if (seg.endsWith(".") || seg.endsWith(" ") || seg.includes("/")) return p; if (WIN_RESERVED_RE.test(seg)) return p; } } return unc ? "\\\\" + rest.slice(unc[0].length) : rest; } export function hasWin32AliasedComponent(p) { if (!isWinFormPath(p)) return false; const parts = p.split(/[\\/]+/).filter((seg) => seg.length > 0); const tail = p.startsWith("\\\\") || p.startsWith("//") ? parts.slice(2) : parts; return tail.some((seg) => seg !== "." && seg !== ".." && /[. ]$/.test(seg)); } export function pathFamilyOf(bases) { const tree = bases.root ?? bases.cwd; return tree === undefined ? undefined : isWinFormPath(tree) ? "win" : "posix"; } export function familyFor(source, p) { return source === "own" ? (isWinFormPath(p) ? "win" : "posix") : source; } export function isAbsoluteForFamily(family, p) { return familyFor(family, p) === "win" ? /^[A-Za-z]:[\\/]/.test(p) || isUncSpelling(p) : p.startsWith("/"); } export const WIN32_NAMESPACE_REFUSALS = Object.freeze(["device_namespace", "unmappable_namespace", "unc_on_posix_tree", "drive_relative"]); export const WIN32_NAMESPACE_REFUSAL_TEXT = { device_namespace: "is a Win32 device-namespace path (\\\\.\\ names a raw device object, not a file)", unmappable_namespace: "is a Win32 extended-length namespace path with no DOS-path equivalent (only \\\\?\\<drive>: and \\\\?\\UNC\\ forms name files)", unc_on_posix_tree: "is a UNC (\\\\host\\share) spelling, which names no file on a POSIX tree", drive_relative: "is drive-relative (a drive letter with no separator after the colon), a location no tree can locate", }; export function win32NamespaceScreen(spelled, family) { if (family === "win" && /^[\\/]{2}[?.][\\/]/.test(spelled) && !/^\\\\[?.]\\/.test(spelled)) spelled = spelled.replace(/\//g, "\\"); if (isWin32DeviceNamespacePath(spelled)) return { ok: false, refuse: "device_namespace" }; const mapped = stripWin32ExtendedPrefix(spelled); if (mapped.startsWith("\\\\?\\")) return { ok: false, refuse: "unmappable_namespace" }; if (mapped.startsWith("\\\\") && family === "posix") return { ok: false, refuse: "unc_on_posix_tree" }; if (family === "win" && /^[A-Za-z]:(?![\\/])/.test(mapped)) return { ok: false, refuse: "drive_relative" }; return { ok: true, spelled: mapped }; } export function pathIdentityOf(spelled, family, anchor) { if (typeof spelled !== "string") return { ok: false, reason: "relative" }; if (family !== "win") { if (!spelled.startsWith("/")) return { ok: false, reason: "relative" }; return { ok: true, identity: mintIdentity("", spelled.split("/"), false) }; } const screened = win32NamespaceScreen(spelled, "win"); if (!screened.ok) return { ok: false, reason: screened.refuse }; let s = screened.spelled; if (s.startsWith("/") && isAbsoluteForFamily("win", s.slice(1))) s = s.slice(1); if (isUncSpelling(s)) { const segs = s.slice(2).split(/[\\/]+/).filter((x) => x !== ""); const host = segs.shift(); const share = segs.shift(); return { ok: true, identity: mintIdentity(`//${host}/${share}`, segs, true) }; } const drive = /^([A-Za-z]):[\\/]/.exec(s) ?? /^[\\/]([A-Za-z])(?=[\\/]|$)/.exec(s); if (drive !== null) return { ok: true, identity: mintIdentity(`/${drive[1].toLowerCase()}`, s.slice(2).split(/[\\/]/), false) }; if (s.startsWith("/") || s.startsWith("\\")) { const root = anchor === undefined ? undefined : pathIdentityOf(anchor, "win"); if (root === undefined || !root.ok) return { ok: false, reason: "unanchored" }; return { ok: true, identity: mintIdentity(identityAnchorOf(root.identity), s.slice(1).split(/[\\/]/), false) }; } return { ok: false, reason: "relative" }; } function mintIdentity(anchor, segments, stripTail) { const out = []; for (const seg of segments) { if (seg === "" || seg === ".") continue; if (seg === "..") { if (out.length > 0) out.pop(); continue; } const stripped = stripTail ? seg.slice(0, trailingDotSpaceStart(seg)) : seg; out.push(stripped.length > 0 ? stripped : seg); } return (out.length === 0 ? anchor || "/" : `${anchor}/${out.join("/")}`); } function trailingDotSpaceStart(seg) { let end = seg.length; while (end > 0 && (seg[end - 1] === "." || seg[end - 1] === " ")) end -= 1; return end; } function identityAnchorOf(identity) { return identity.startsWith("//") ? identity.split("/").slice(0, 4).join("/") : identity.slice(0, 2); } export function isUncIdentity(identity) { return identity.startsWith("//"); } export function isPathIdentity(s, family) { const r = pathIdentityOf(s, family); return r.ok && r.identity === s; } export function isIdentityDir(identity, family) { if (family !== "win") return identity !== "/"; return identity.length > identityAnchorOf(identity).length; } export function spellingOfIdentity(identity, family) { if (family !== "win") return identity; if (isUncIdentity(identity)) return identity.replace(/\//g, "\\"); return `${identity.slice(1, 2)}:${(identity.length > 2 ? identity.slice(2) : "/").replace(/\//g, "\\")}`; } export function carriesDotSegment(spelled, family) { return (family === "win" ? /(^|[\\/])\.{1,2}([\\/]|$)/ : /(^|\/)\.{1,2}(\/|$)/).test(spelled); } export function clipSpelling(spelled) { return spelled.length > 512 ? `${spelled.slice(0, 512)}…` : spelled; } export function isShellRootedSpellingUnmapped(family, spelled) { return family === "win" && (spelled.startsWith("/") || spelled.startsWith("\\")); } export function nativeUncSpellingOf(family, spelled) { return family === "win" && isUncSpelling(spelled) ? `\\\\${spelled.slice(2).replace(/\//g, "\\")}` : spelled; } export function foldTrailingSeparators(spelled, family) { let end = spelled.length; while (end > 0 && (spelled[end - 1] === "/" || (family === "win" && spelled[end - 1] === "\\"))) end -= 1; if (end === spelled.length) return spelled; const stripped = spelled.slice(0, end); const identity = pathIdentityOf(stripped, family); if (!identity.ok || !isIdentityDir(identity.identity, family)) return spelled; return stripped; } export function joinForFamily(family, base, rel) { if (family === "posix") return `${base.replace(/\/+$/, "")}/${rel}`; if (rel.startsWith("/") || rel.startsWith("\\")) { const root = /^[A-Za-z]:/.exec(base)?.[0] ?? /^[\\/]{2}[^\\/]+[\\/]+[^\\/]+/.exec(base)?.[0]; return root === undefined ? rel : root + rel; } return `${base.replace(/[\\/]+$/, "")}${base.includes("\\") ? "\\" : "/"}${rel}`; } export function anchorSpelling(family, base, spelled) { return isAbsoluteForFamily(family, spelled) ? spelled : joinForFamily(family, base, spelled); } export function lexicalViewOf(spelled, base) { return normalizeAbsPathLexically(anchorSpelling(pathFamilyOf({ root: base }), base, spelled)); } function within(rootCanonical, p) { if (!rootCanonical) return false; const family = pathFamilyOf({ root: rootCanonical }); const rootN = pathIdentityOf(rootCanonical, family); const pN = pathIdentityOf(p, family); if (!rootN.ok || !pN.ok) return false; if (pN.identity === rootN.identity) return true; return pN.identity.startsWith(rootN.identity.endsWith(SEP) ? rootN.identity : rootN.identity + SEP); } export async function resolveKey(env, rootCanonical, path, signal, baseCwd, additionalRoots, exactFileReadExemption, readDeny, containmentFace) { const views = await readPath(env, path, baseCwd ?? rootCanonical, { root: rootCanonical, ...(baseCwd !== undefined ? { cwd: baseCwd } : {}) }, { intent: "resolve", views: readDeny !== undefined ? "chain" : "canonical" }, signal); const canon = views.canonical ?? (await canonicalizeTarget(env, path, signal, pathFamilyOf({ root: rootCanonical }), baseCwd)); if (!canon.ok) return { ok: false, violation: { code: "invalid", message: canon.message } }; const key = canon.key; const additionalRootsCanonical = typeof additionalRoots === "function" ? additionalRoots() : additionalRoots; if (isBlockedDevicePath(key)) { return { ok: false, violation: { code: "invalid", message: `path "${path}" is a blocked device/special file (reading it can hang the process); refused.` } }; } if (readDeny !== undefined) { const judged = { aliasResolved: canon.aliasResolved === true, hops: views.hops }; const lexical = views.lexical?.kind === "path" ? spellingOfIdentity(views.lexical.identity, views.lexical.family) : undefined; let hit = readDeny.matchTarget(key, lexical, judged); const write = readDeny.face === "write"; if (hit === null) { const home = await homeViewsOf(env, readDeny, signal).of(key); if (home.view !== undefined) hit = readDeny.matchTarget(key, home.view, judged); if (hit === null && home.unread !== undefined) { return { ok: false, violation: { code: write ? "write_path_denied" : "read_path_denied", message: homeViewUnreadSentence(path, home.unread, write ? "write" : "read"), target: key } }; } } if (hit !== null) { return { ok: false, violation: { code: write ? "write_path_denied" : "read_path_denied", message: readDenySentence({ target: path, canonical: key, pattern: hit.pattern, ...(hit.matchedView !== undefined ? { matchedView: hit.matchedView } : {}), ...(write ? { face: "write" } : {}) }), target: key, pattern: hit.pattern, }, }; } } if (skipsRootsContainment(containmentFace)) { if (isUncPath(key) && isWinFormPath(key) && !within(rootCanonical, key) && !additionalRootsCanonical?.some((r) => within(r, key))) { return { ok: false, violation: { code: "invalid", message: `path "${path}" names a network (UNC) location outside the declared roots; refused even under an open containment face — an access against an undeclared share can hang on the SMB mount timeout and offers credentials to the remote host.`, }, }; } return { ok: true, key }; } if (!within(rootCanonical, key) && !additionalRootsCanonical?.some((r) => within(r, key))) { if (exactFileReadExemption?.(key) === true) return { ok: true, key }; const roots = [rootCanonical, ...(additionalRootsCanonical ?? [])]; return { ok: false, violation: { code: "path_not_in_root", message: `path "${path}" ${key !== path ? `(canonical target: "${key}") ` : ""}resolves outside the allowed root${roots.length > 1 ? "s" : ""} (${roots.join(", ")}); access denied. ${PATH_NOT_IN_ROOT_ESCAPE_HINT}`, target: key, roots, }, }; } return { ok: true, key }; } export async function canonicalizeTarget(env, path, signal, family, baseCwd) { const screened = win32NamespaceScreen(path, family); if (!screened.ok) { return { ok: false, message: `path "${clipSpelling(path)}" ${WIN32_NAMESPACE_REFUSAL_TEXT[screened.refuse]}; refused (${screened.refuse}).`, refused: screened.refuse }; } const unprefixed = nativeUncSpellingOf(family, screened.spelled); const spelled = family === "posix" && unprefixed.startsWith("//") ? unprefixed.replace(/^\/+/, "/") : unprefixed; const target = baseCwd ? anchorSpelling(family, baseCwd, spelled) : spelled; let thrownByProbe; let seenThrow = false; const probe = probeRealTarget(env, path, target, family, signal).catch((e) => { thrownByProbe = e; seenThrow = true; throw e; }); try { const answer = await withGateTimeout(probe, DEFAULT_GATE_AWAIT_TIMEOUT_MS, signal, "pathCanonicalization"); return answer.ok ? { ...answer, key: foldTrailingSeparators(answer.key, family) } : answer; } catch (err) { if (seenThrow && err === thrownByProbe) throw err; return { ok: false, message: `cannot resolve path "${path}": ${thrownText(err)}` }; } } async function probeRealTarget(env, path, target, family, signal) { const absR = await env.absolutePath(target, signal); if (!absR.ok) return { ok: false, message: `cannot resolve path "${path}": ${absR.error.message}` }; let abs = absR.value; for (let hop = 0; hop < 16; hop++) { const exists = await env.exists(abs, signal); if (!exists.ok) { return { ok: false, message: `cannot determine whether "${path}" exists: ${exists.error.message}`, unresolvedSymlink: true }; } if (!exists.value) { const newPath = await canonicalizeNewPath(env, abs, family, signal); if (!newPath.ok) { return { ok: false, message: newPath.message, unresolvedSymlink: true }; } return { ok: true, key: newPath.key }; } const canon = await env.canonicalPath(abs, signal); if (canon.ok) return { ok: true, key: canon.value, aliasResolved: true }; const info = await env.fileInfo(abs, signal); if (!info.ok) { return { ok: false, message: `cannot determine whether "${path}" is a symlink: ${info.error.message}`, unresolvedSymlink: true }; } if (info.value.kind !== "symlink") { const viaAncestors = await canonicalizeNewPath(env, abs, family, signal, { skipLeaf: true }); if (!viaAncestors.ok) { return { ok: false, message: viaAncestors.message, unresolvedSymlink: true }; } return { ok: true, key: viaAncestors.key }; } if (!env.readLink) { return { ok: false, message: `cannot resolve symlink "${path}" (env has no readLink)`, unresolvedSymlink: true }; } const link = await env.readLink(abs, signal); if (!link.ok) { return { ok: false, message: `cannot read symlink "${path}": ${link.error.message}`, unresolvedSymlink: true }; } const screenedTarget = win32NamespaceScreen(link.value, family); if (!screenedTarget.ok) { return { ok: false, message: `cannot resolve symlink "${path}": its target "${clipSpelling(link.value)}" ${WIN32_NAMESPACE_REFUSAL_TEXT[screenedTarget.refuse]}; refused (${screenedTarget.refuse}).`, refused: screenedTarget.refuse, }; } const hopSpelling = family === "win" ? screenedTarget.spelled : link.value; const linkTarget = anchorSpelling(family, parentDir(abs), hopSpelling); const reabs = await env.absolutePath(linkTarget, signal); if (!reabs.ok) { return { ok: false, message: `cannot resolve symlink target "${linkTarget}" for "${path}": ${reabs.error.message}`, unresolvedSymlink: true }; } abs = reabs.value; } return { ok: false, message: `symlink chain for "${path}" exceeded the hop cap; target unresolved`, unresolvedSymlink: true }; } function parentDir(abs) { const i = lastSepIndex(abs); if (i <= 0) return SEP; const head = abs.slice(0, i); return /^[A-Za-z]:$/.test(head) ? head + abs[i] : head; } function uncAnchorIndex(parts) { let seen = 0; for (let i = 0; i < parts.length; i++) { if (parts[i] === "") continue; seen++; if (seen === 2) return i; } return parts.length; } async function canonicalizeNewPath(env, abs, family, signal, opts) { const sep = nativeSepOf(abs); const winForm = family === "win" || isWinFormPath(abs); const parts = winForm ? abs.split(/[\\/]/) : abs.split("/"); const tail = []; const floor = family === "win" && isUncSpelling(abs) ? uncAnchorIndex(parts) : 1; if (opts?.skipLeaf === true && parts.length > 1) tail.push(parts.pop()); while (parts.length > floor) { const candidate = parts.join(sep) || sep; const ex = await env.exists(candidate, signal); if (!ex.ok) { return { ok: false, message: `cannot determine whether ancestor "${candidate}" exists: ${ex.error.message}` }; } if (ex.value) { const canon = await env.canonicalPath(candidate, signal); if (!canon.ok) { return { ok: false, message: `cannot canonicalize existing ancestor "${candidate}": ${canon.error.message}` }; } const tailSegments = tail.reverse(); const canonSep = nativeSepOf(canon.value); if (tailSegments.length === 0) return { ok: true, key: canon.value }; return { ok: true, key: [canon.value.replace(family === "win" ? /[\\/]+$/ : /\/+$/, ""), ...tailSegments].join(canonSep) }; } tail.push(parts.pop()); } return { ok: true, key: abs }; } export function violationText(toolName, v) { return `Error (${toolName}): ${v.message}`; } export function violationDetails(v) { if (v.code === "read_path_denied" && v.target !== undefined && v.pattern !== undefined) return readPathDeniedCard(v.target, v.pattern); return v.code === "path_not_in_root" && v.target !== undefined ? { type: "path_not_in_root", code: "path_not_in_root", target: v.target, roots: v.roots ?? [] } : undefined; } export function readPathDeniedCard(target, pattern) { return { type: "read_path_denied", code: "read_path_denied", target, pattern }; } export function pathDenialCardOf(evidence) { return evidence?.face === "read" ? readPathDeniedCard(evidence.view, evidence.rule) : undefined; } export function withinAnyRoot(rootsCanonical, p) { return rootsCanonical.some((r) => within(r, p)); } export function rootAdmittingArtifactStore(storePhysical, rootsCanonical) { const storeFamily = pathFamilyOf({ root: storePhysical }); return rootsCanonical.find((root) => pathFamilyOf({ root }) === storeFamily && (withinAnyRoot([root], storePhysical) || withinAnyRoot([storePhysical], root))); } export const PATH_NOT_IN_ROOT_ESCAPE_HINT = "(This boundary applies to the structured file tools. If you genuinely need content outside the allowed root(s), ask for the directory to be added to the deployment's additionalDirectories — or additionalReadDirectories for read-only access; a deployment that wants reads or writes open everywhere can declare readFace / writeFace: \"open\" instead of listing directories.)"; export function requireRead(state, key) { const entry = state.get(key); if (entry === undefined || entry.isPartialView) { return { code: "not_read", message: "File has not been read yet. Read it first before writing to it.", ...(entry?.isPartialView ? { partialView: true } : {}), }; } return undefined; } export const OVERSIZE_READ_ESCAPE_HINT = "(This file is over the Read tool's whole-file byte cap, so a default Read is refused — read it in slices with explicit offset/limit to satisfy the read-first rule, or inspect/transform it with bash (e.g. `sed -n`, `grep`) instead.)"; export const PARTIAL_VIEW_READ_ESCAPE_HINT = "(Your last Read of this file returned only a PARTIAL view — the output token cap paginated it, so a default Read will keep returning the same page. Re-read it with explicit offset/limit (start from the page marker's next-page hint) until you have seen the part you are about to change; an explicit slice that fits satisfies the read-first rule. Or inspect/transform it with bash (e.g. `sed -n`, `grep`) instead.)"; export const READ_REFUSED_ESCAPE_HINT = "(If the Read tool refuses this file (binary/unknown encoding), overwrite or convert it with bash instead — e.g. `rm` + rewrite, or `iconv`.)"; export function checkNoChange(oldString, newString) { if (oldString === newString) { return { code: "invalid", message: "No changes to make: old_string and new_string are exactly the same." }; } return undefined; } export function checkStale(entry, currentHash) { if (entry === undefined || entry.hash !== currentHash) { return { code: "stale", message: "File has been modified since read, either by the user or by a linter. Read it again before attempting to write it." }; } return undefined; } export function countOccurrences(haystack, needle) { if (needle === "") return 0; return haystack.split(needle).length - 1; } export function similarNameSuggestion(siblingNames, missingName) { const stemOf = (n) => { const dot = n.lastIndexOf("."); return dot > 0 ? n.slice(0, dot) : n; }; const missingLower = missingName.toLowerCase(); const missingStem = stemOf(missingName); const caseHit = siblingNames.find((n) => n !== missingName && n.toLowerCase() === missingLower); if (caseHit) return caseHit; return siblingNames.find((n) => n !== missingName && stemOf(n) === missingStem); } const CURLY_TO_STRAIGHT = { "‘": "'", "’": "'", "“": '"', "”": '"' }; export function normalizeQuotes(s) { return s.replace(/[‘’“”]/g, (c) => CURLY_TO_STRAIGHT[c] ?? c); } export function resolveQuoteMatch(content, oldString) { if (oldString === "") return undefined; if (content.includes(oldString)) return oldString; const normOld = normalizeQuotes(oldString); const idx = normalizeQuotes(content).indexOf(normOld); if (idx === -1) return undefined; return content.substring(idx, idx + oldString.length); } const UNICODE_ESCAPE_RE = /\\u[0-9a-fA-F]{4}/; const NON_ASCII_RE = /[\u0080-\uffff]/; function decodeUnicodeEscapes(s) { return s.replace(/(\\\\)|\\u([0-9a-fA-F]{4})/g, (m, dbl, hex) => (dbl !== undefined ? m : String.fromCharCode(parseInt(hex, 16)))); } function escapedSpellingPattern(s) { let out = ""; for (let i = 0; i < s.length; i++) { const code = s.charCodeAt(i); if (code >= 128) { out += "\\\\u"; for (const d of code.toString(16).padStart(4, "0")) out += d >= "a" ? `[${d}${d.toUpperCase()}]` : d; } else out += s[i].replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); } return out; } export function resolveEscapeMatch(content, oldString) { if (oldString === "") return undefined; if (UNICODE_ESCAPE_RE.test(oldString)) { const decoded = decodeUnicodeEscapes(oldString); if (decoded !== oldString && content.includes(decoded)) return decoded; } if (NON_ASCII_RE.test(oldString)) { let hit = null; try { hit = content.match(new RegExp(escapedSpellingPattern(oldString))); } catch { hit = null; } if (hit) return hit[0]; } return undefined; } export function adaptNewStringEscapes(oldString, matchedOld, newString) { if (oldString === matchedOld) return newString; if (NON_ASCII_RE.test(oldString)) { let full; try { full = new RegExp(`^${escapedSpellingPattern(oldString)}$`); } catch { full = undefined; } if (full?.test(matchedOld)) { const hexOf = new Map(); let upper = 0; let lower = 0; for (let i = 0, at = 0; i < oldString.length; i++) { const code = oldString.charCodeAt(i); if (code >= 128) { const hex = matchedOld.slice(at + 2, at + 6); hexOf.set(code, hex); for (const d of hex) { if (d >= "a" && d <= "f") lower++; else if (d >= "A" && d <= "F") upper++; } at += 6; } else at += 1; } return newString.replace(/[\u0080-\uffff]/g, (ch) => { const code = ch.charCodeAt(0); const known = hexOf.get(code); if (known !== undefined) return `\\u${known}`; const hex = code.toString(16).padStart(4, "0"); return `\\u${upper > lower ? hex.toUpperCase() : hex}`; }); } } if (UNICODE_ESCAPE_RE.test(oldString) && decodeUnicodeEscapes(oldString) === matchedOld) return decodeUnicodeEscapes(newString); return newString; } export function escapeMatchWasAttempted(oldString) { return UNICODE_ESCAPE_RE.test(oldString) || NON_ASCII_RE.test(oldString); } export const ESCAPE_MATCH_MISS_NOTE = "\n(note: Edit also tried swapping \\uXXXX escapes and their characters; neither form matched, so the mismatch is likely elsewhere in old_string. Re-read the file and copy the exact surrounding text.)"; function isQuoteOpenerPosition(chars, i) { if (i === 0) return true; const p = chars[i - 1]; return p === " " || p === "\t" || p === "\n" || p === "\r" || p === "(" || p === "[" || p === "{" || p === "—" || p === "–"; } export function adaptNewStringQuotes(matchedOld, newString) { const wantsDouble = matchedOld.includes("“") || matchedOld.includes("”"); const wantsSingle = matchedOld.includes("‘") || matchedOld.includes("’"); if (!wantsDouble && !wantsSingle) return newString; const chars = [...newString]; const out = []; for (let i = 0; i < chars.length; i++) { const c = chars[i]; if (wantsDouble && c === '"') out.push(isQuoteOpenerPosition(chars, i) ? "“" : "”"); else if (wantsSingle && c === "'") { const prev = i > 0 ? chars[i - 1] : undefined; const next = i < chars.length - 1 ? chars[i + 1] : undefined; const letterBefore = prev !== undefined && /\p{L}/u.test(prev); const letterAfter = next !== undefined && /\p{L}/u.test(next); if (letterBefore && letterAfter) out.push("’"); else out.push(isQuoteOpenerPosition(chars, i) ? "‘" : "’"); } else out.push(c); } return out.join(""); } export function deletionOldString(content, oldString, newString) { if (newString !== "") return oldString; if (!oldString.endsWith("\n") && content.includes(oldString + "\n")) return oldString + "\n"; return oldString; } export const EDIT_ECHO_MAX_CHARS = 200; function boundEditEcho(oldString) { if (oldString.length <= EDIT_ECHO_MAX_CHARS) return oldString; return `${sliceHeadSafe(oldString, EDIT_ECHO_MAX_CHARS)}… (truncated)`; } export function checkEditMatch(content, oldString, replaceAll, truncated) { if (oldString === "") { return { code: "invalid", message: "old_string must not be empty." }; } const n = countOccurrences(content, oldString); if (n === 0) { return { code: "ambiguous_edit", message: `String to replace not found in file.${truncated ? " (note: the read was truncated — it may be in the unshown portion)" : ""}\nString: ${boundEditEcho(oldString)}`, }; } if (n > 1 && !replaceAll) { return { code: "ambiguous_edit", message: `Found ${n} matches of the string to replace, but replace_all is false. To replace all occurrences, set replace_all to true. To replace only one occurrence, please provide more context to uniquely identify the instance.${truncated ? " (the read was truncated; some matches may be in the unshown portion)" : ""}\nString: ${boundEditEcho(oldString)}`, }; } return undefined; }