diff options
| author | Adam Malczewski <[email protected]> | 2026-05-27 17:22:52 +0900 |
|---|---|---|
| committer | Adam Malczewski <[email protected]> | 2026-05-27 17:22:52 +0900 |
| commit | da57842686ebfd157396551fc76d0c18f7676335 (patch) | |
| tree | 31caa165c9c6188ee7fa27663ec832a13f6a7ac2 /packages/core/src/tools/path-utils.ts | |
| parent | 399e1509b93b9f3c56142f94b8fb2c30c2dedb2f (diff) | |
| download | dispatch-da57842686ebfd157396551fc76d0c18f7676335.tar.gz dispatch-da57842686ebfd157396551fc76d0c18f7676335.zip | |
feat: tool-output truncation+spill, read_file pagination, read_file_slice, symlink-safe path resolution
Diffstat (limited to 'packages/core/src/tools/path-utils.ts')
| -rw-r--r-- | packages/core/src/tools/path-utils.ts | 55 |
1 files changed, 55 insertions, 0 deletions
diff --git a/packages/core/src/tools/path-utils.ts b/packages/core/src/tools/path-utils.ts new file mode 100644 index 0000000..3bba0d3 --- /dev/null +++ b/packages/core/src/tools/path-utils.ts @@ -0,0 +1,55 @@ +import { realpath } from "node:fs/promises"; +import { basename, dirname, join, resolve } from "node:path"; + +/** + * Resolve a path to its canonical absolute form, following symlinks at + * every level. + * + * When the leaf does not exist (common for `write_file` creating a new + * file), walks up to the nearest existing ancestor, canonicalizes that, + * then re-appends the missing trailing segments — ensuring a symlink in + * the *middle* of the path is still resolved. Without this, a + * `workdir/escape-link/new-file.txt` write where `escape-link` points + * outside the workdir would slip the containment check. + * + * Used everywhere we compare a user-supplied path against a trusted + * root (workdir, SPILL_ROOT). Resolving symlinks consistently is the + * only reliable way to detect a symlink-in-workdir-pointing-outside + * escape; lexical-only checks let those through. + * + * Argument semantics match `path.resolve(...paths)`: later absolute + * segments override earlier ones, relative segments are joined. + */ +export async function canonicalize(...paths: string[]): Promise<string> { + const lexical = resolve(...paths); + + // Fast path: full path exists, realpath resolves all symlinks. + try { + return await realpath(lexical); + } catch { + // Path doesn't exist — fall through to ancestor walk. + } + + // Walk up until we hit an existing ancestor we can realpath, then + // re-append the missing trailing segments. This handles cases like + // write_file creating /workdir/symlink/new/dir/file.txt where the + // "symlink" segment exists but the rest doesn't. + let current = lexical; + const trailing: string[] = []; + while (true) { + const parent = dirname(current); + if (parent === current) break; // hit filesystem root + trailing.unshift(basename(current)); + try { + const realParent = await realpath(parent); + return join(realParent, ...trailing); + } catch { + current = parent; + } + } + + // No existing ancestor (pathological — e.g. the entire mount is gone). + // Return the lexical path; downstream containment checks will still + // catch obvious escapes via `..` etc. + return lexical; +} |
