src / workspace / boundary.ts
src / workspace / boundary.ts
import { realpath as realpathWithCallback } from "node:fs";
import { lstat, mkdir, realpath } from "node:fs/promises";
import {
basename,
dirname,
isAbsolute,
join,
relative,
resolve,
sep,
} from "node:path";
import { promisify } from "node:util";
import { AgenticError } from "../core/errors";
/**
* `realpath.native` is `GetFinalPathNameByHandle` on Windows, and it is the
* only resolver that answers with the name on disk: the promise-API `realpath`
* hands back `GIT~1` unchanged, while the native one returns `.git`. It also
* collapses NTFS alternate data streams (`x::$DATA` -> `x`) and case variants.
* `node:fs/promises` exposes no `.native`, so the callback form is promisified.
*/
const realpathNative = promisify(
realpathWithCallback.native as (
path: string,
callback: (error: NodeJS.ErrnoException | null, resolved: string) => void,
) => void,
);
/**
* The 8.3 short-name shape Windows generates for a long name: at most eight
* characters, a tilde, a generation number, and an extension of at most three.
* `NtfsDisable8dot3NameCreation` defaults to 2 (short names still generated on
* the system volume), so `GIT~1` is `.git` on a stock install — a path that
* shares no characters with the name every protected-path glob is written
* against. Deliberately narrower than "any tilde": a long name such as
* `draft-version~12.md` is never an alias and stays editable.
*/
const WINDOWS_SHORT_NAME = /^[^.]{1,8}~\d{1,6}(?:\.[^.]{0,3})?$/;
export interface BoundaryOptions {
protectedPatterns?: string[];
}
function normalizeForMatch(value: string): string {
return value.replaceAll("\\", "/").replace(/^\.\//, "");
}
function escapeRegex(value: string): string {
return value.replace(/[|\\{}()[\]^$+?.]/g, "\\$&");
}
function globToRegex(pattern: string): RegExp {
const normalized = normalizeForMatch(pattern.trim());
let out = "^";
for (let i = 0; i < normalized.length; i++) {
const char = normalized[i];
if (char === "*") {
if (normalized[i + 1] === "*") {
if (normalized[i + 2] === "/") {
out += "(?:.*/)?";
i += 2;
} else {
out += ".*";
i++;
}
} else {
out += "[^/]*";
}
} else if (char === "?") {
out += "[^/]";
} else {
out += escapeRegex(char);
}
}
out += "$";
return new RegExp(out, process.platform === "win32" ? "i" : "");
}
/**
* `ENOTDIR` — a component of the path is a regular file — means the path cannot
* exist, which is what Windows reports as `ENOENT`. Treating the two alike keeps
* path probing platform-independent and stops a raw Node error (which carries
* the absolute host path) escaping to a caller.
*/
function missing(error: unknown): boolean {
const code = (error as NodeJS.ErrnoException).code;
return code === "ENOENT" || code === "ENOTDIR";
}
async function exists(path: string): Promise<boolean> {
try {
await lstat(path);
return true;
} catch (error) {
if (missing(error)) return false;
throw error;
}
}
export class WorkspaceBoundary {
public readonly root: string;
public readonly realRoot: string;
/** `realRoot` as `realpath.native` spells it; the base for canonical identities. */
private readonly canonicalRoot: string;
private readonly protectedMatchers: RegExp[];
private constructor(
root: string,
realRoot: string,
canonicalRoot: string,
options: BoundaryOptions,
) {
this.root = root;
this.realRoot = realRoot;
this.canonicalRoot = canonicalRoot;
const defaults = [".git", ".git/**", ".agentic", ".agentic/**"];
this.protectedMatchers = [...defaults, ...(options.protectedPatterns ?? [])]
.filter((pattern) => pattern.trim().length > 0)
.map(globToRegex);
}
public static async create(
rootPath: string,
options: BoundaryOptions = {},
): Promise<WorkspaceBoundary> {
if (!rootPath.trim()) {
throw new AgenticError("INVALID_INPUT", "A workspace path is required.");
}
const root = resolve(rootPath);
await mkdir(root, { recursive: true });
const realRoot = await realpath(root);
// A workspace whose own path cannot be canonicalised still works: the
// literal-string protection check and the lexical alias rejection stand on
// their own, and `realRoot` is the conservative base for the rest.
let canonicalRoot = realRoot;
try {
canonicalRoot = await realpathNative(root);
} catch {
canonicalRoot = realRoot;
}
return new WorkspaceBoundary(root, realRoot, canonicalRoot, options);
}
public relativePath(absolutePath: string): string {
const value = relative(this.root, absolutePath);
return normalizeForMatch(value || ".");
}
public isProtected(relativePath: string): boolean {
const normalized = normalizeForMatch(relativePath);
return this.protectedMatchers.some((matcher) => matcher.test(normalized));
}
public async resolveRead(requestedPath: string): Promise<string> {
const lexical = this.resolveLexically(requestedPath);
let resolvedReal: string;
try {
resolvedReal = await realpath(lexical);
} catch (error) {
if (missing(error)) {
throw new AgenticError("NOT_FOUND", `Path does not exist: ${requestedPath}`);
}
throw error;
}
this.assertRealInside(resolvedReal, requestedPath);
return lexical;
}
public async resolveWrite(
requestedPath: string,
options: { allowProtected?: boolean } = {},
): Promise<string> {
const lexical = this.resolveLexically(requestedPath);
const rel = this.relativePath(lexical);
if (!options.allowProtected) {
// Both spellings are tested: the requested one, and the canonical
// identity of the file it actually names. A glob matched only against
// the string the model typed protects a name, not a file.
const canonical = await this.canonicalRelativePath(lexical);
if (this.isProtected(rel) || this.isProtected(canonical)) {
throw new AgenticError(
"PROTECTED_PATH",
`Model edits are not allowed for protected path: ${canonical}`,
{ path: canonical },
);
}
}
await this.assertNoSymlinkComponents(lexical, rel);
let probe = lexical;
while (!(await exists(probe))) {
const parent = dirname(probe);
if (parent === probe) break;
probe = parent;
}
const realAncestor = await realpath(probe);
this.assertRealInside(realAncestor, requestedPath);
if (await exists(lexical)) {
const info = await lstat(lexical);
if (info.isSymbolicLink()) {
throw new AgenticError(
"OUTSIDE_WORKSPACE",
`Writes through symbolic links are rejected: ${rel}`,
{ path: rel },
);
}
this.assertRealInside(await realpath(lexical), requestedPath);
}
return lexical;
}
public resolveInternal(relativePath: string): string {
const lexical = this.resolveLexically(relativePath);
this.assertInternalRelative(this.relativePath(lexical), relativePath);
return lexical;
}
public async resolveInternalRead(relativePath: string): Promise<string> {
const lexical = this.resolveInternal(relativePath);
return await this.resolveRead(this.relativePath(lexical));
}
public async resolveInternalWrite(relativePath: string): Promise<string> {
const lexical = this.resolveInternal(relativePath);
return await this.resolveWrite(this.relativePath(lexical), { allowProtected: true });
}
private async assertNoSymlinkComponents(
lexicalPath: string,
relativePath: string,
): Promise<void> {
const rel = relative(this.root, lexicalPath);
if (!rel || rel === ".") return;
let current = this.root;
for (const segment of rel.split(sep)) {
if (!segment || segment === ".") continue;
current = join(current, segment);
let info;
try {
info = await lstat(current);
} catch (error) {
if (missing(error)) break;
throw error;
}
if (info.isSymbolicLink()) {
throw new AgenticError(
"OUTSIDE_WORKSPACE",
`Writes through symbolic-link path components are rejected: ${relativePath}`,
{ path: relativePath, symlink: this.relativePath(current) },
);
}
}
}
private assertInternalRelative(relativePath: string, requestedPath: string): void {
if (!(relativePath === ".agentic" || relativePath.startsWith(".agentic/"))) {
throw new AgenticError(
"INTERNAL",
`Internal path must remain under .agentic: ${requestedPath}`,
);
}
}
/**
* The canonical workspace-relative identity of an absolute in-workspace
* path: the deepest ancestor that exists, resolved with `realpath.native`,
* with the not-yet-created tail re-attached. Protection decisions use this
* rather than the requested string, so an OS-level alias for a protected
* file matches the glob written against the file's real name.
*
* Falls back to the lexical view whenever the identity cannot be
* established. That is not a hole: `assertRealInside` still owns
* containment, the literal-string check still runs, and on Windows the
* alias spellings are refused lexically before this is ever reached.
*/
private async canonicalRelativePath(lexicalPath: string): Promise<string> {
const tail: string[] = [];
let probe = lexicalPath;
while (!(await exists(probe))) {
const parent = dirname(probe);
if (parent === probe) return this.relativePath(lexicalPath);
tail.unshift(basename(probe));
probe = parent;
}
let canonical: string;
try {
canonical = await realpathNative(probe);
} catch {
return this.relativePath(lexicalPath);
}
const full = tail.length > 0 ? join(canonical, ...tail) : canonical;
const rel = relative(this.canonicalRoot, full);
if (rel.startsWith(`..${sep}`) || rel === ".." || isAbsolute(rel)) {
return this.relativePath(lexicalPath);
}
return normalizeForMatch(rel || ".");
}
/**
* Refuses the two spellings that let one Windows file answer to a name no
* protected-path glob is written against. Kept as a lexical rule even though
* `canonicalRelativePath` also catches the resolvable ones: it needs no
* filesystem state, so it holds for a path whose target does not exist yet
* (`.env:alt` creates a hidden stream on a protected `.env`), and it keeps
* the string the model typed and the file it names in one-to-one
* correspondence everywhere else in the plugin.
*/
private assertNoWindowsAlias(relativePath: string, requestedPath: string): void {
if (process.platform !== "win32") return;
for (const segment of relativePath.split(/[\\/]/)) {
if (!segment || segment === "." || segment === "..") continue;
if (segment.includes(":")) {
throw new AgenticError(
"INVALID_INPUT",
`Path components may not contain ':' on Windows — it names an NTFS alternate data stream, not a file: ${requestedPath}`,
{ path: requestedPath },
);
}
if (WINDOWS_SHORT_NAME.test(segment)) {
throw new AgenticError(
"INVALID_INPUT",
`'${segment}' is a Windows 8.3 short-name alias; use the full name: ${requestedPath}`,
{ path: requestedPath },
);
}
}
}
private resolveLexically(requestedPath: string): string {
if (requestedPath.includes("\0")) {
throw new AgenticError("INVALID_INPUT", "Paths may not contain NUL bytes.");
}
const value = requestedPath.trim() || ".";
const candidate = resolve(this.root, value);
const rel = relative(this.root, candidate);
if (rel.startsWith(`..${sep}`) || rel === ".." || isAbsolute(rel)) {
throw new AgenticError(
"OUTSIDE_WORKSPACE",
`Path escapes the workspace: ${requestedPath}`,
{ workspace: this.root },
);
}
// Only the in-workspace part is checked: the workspace root itself is the
// user's choice and may legitimately carry a drive letter or a short name.
this.assertNoWindowsAlias(rel, requestedPath);
return candidate;
}
private assertRealInside(realPath: string, requestedPath: string): void {
const rel = relative(this.realRoot, realPath);
if (rel.startsWith(`..${sep}`) || rel === ".." || isAbsolute(rel)) {
throw new AgenticError(
"OUTSIDE_WORKSPACE",
`Resolved path escapes the workspace: ${requestedPath}`,
{ resolved: realPath, workspace: this.realRoot },
);
}
}
}
import { realpath as realpathWithCallback } from "node:fs";
import { lstat, mkdir, realpath } from "node:fs/promises";
import {
basename,
dirname,
isAbsolute,
join,
relative,
resolve,
sep,
} from "node:path";
import { promisify } from "node:util";
import { AgenticError } from "../core/errors";
/**
* `realpath.native` is `GetFinalPathNameByHandle` on Windows, and it is the
* only resolver that answers with the name on disk: the promise-API `realpath`
* hands back `GIT~1` unchanged, while the native one returns `.git`. It also
* collapses NTFS alternate data streams (`x::$DATA` -> `x`) and case variants.
* `node:fs/promises` exposes no `.native`, so the callback form is promisified.
*/
const realpathNative = promisify(
realpathWithCallback.native as (
path: string,
callback: (error: NodeJS.ErrnoException | null, resolved: string) => void,
) => void,
);
/**
* The 8.3 short-name shape Windows generates for a long name: at most eight
* characters, a tilde, a generation number, and an extension of at most three.
* `NtfsDisable8dot3NameCreation` defaults to 2 (short names still generated on
* the system volume), so `GIT~1` is `.git` on a stock install — a path that
* shares no characters with the name every protected-path glob is written
* against. Deliberately narrower than "any tilde": a long name such as
* `draft-version~12.md` is never an alias and stays editable.
*/
const WINDOWS_SHORT_NAME = /^[^.]{1,8}~\d{1,6}(?:\.[^.]{0,3})?$/;
export interface BoundaryOptions {
protectedPatterns?: string[];
}
function normalizeForMatch(value: string): string {
return value.replaceAll("\\", "/").replace(/^\.\//, "");
}
function escapeRegex(value: string): string {
return value.replace(/[|\\{}()[\]^$+?.]/g, "\\$&");
}
function globToRegex(pattern: string): RegExp {
const normalized = normalizeForMatch(pattern.trim());
let out = "^";
for (let i = 0; i < normalized.length; i++) {
const char = normalized[i];
if (char === "*") {
if (normalized[i + 1] === "*") {
if (normalized[i + 2] === "/") {
out += "(?:.*/)?";
i += 2;
} else {
out += ".*";
i++;
}
} else {
out += "[^/]*";
}
} else if (char === "?") {
out += "[^/]";
} else {
out += escapeRegex(char);
}
}
out += "$";
return new RegExp(out, process.platform === "win32" ? "i" : "");
}
/**
* `ENOTDIR` — a component of the path is a regular file — means the path cannot
* exist, which is what Windows reports as `ENOENT`. Treating the two alike keeps
* path probing platform-independent and stops a raw Node error (which carries
* the absolute host path) escaping to a caller.
*/
function missing(error: unknown): boolean {
const code = (error as NodeJS.ErrnoException).code;
return code === "ENOENT" || code === "ENOTDIR";
}
async function exists(path: string): Promise<boolean> {
try {
await lstat(path);
return true;
} catch (error) {
if (missing(error)) return false;
throw error;
}
}
export class WorkspaceBoundary {
public readonly root: string;
public readonly realRoot: string;
/** `realRoot` as `realpath.native` spells it; the base for canonical identities. */
private readonly canonicalRoot: string;
private readonly protectedMatchers: RegExp[];
private constructor(
root: string,
realRoot: string,
canonicalRoot: string,
options: BoundaryOptions,
) {
this.root = root;
this.realRoot = realRoot;
this.canonicalRoot = canonicalRoot;
const defaults = [".git", ".git/**", ".agentic", ".agentic/**"];
this.protectedMatchers = [...defaults, ...(options.protectedPatterns ?? [])]
.filter((pattern) => pattern.trim().length > 0)
.map(globToRegex);
}
public static async create(
rootPath: string,
options: BoundaryOptions = {},
): Promise<WorkspaceBoundary> {
if (!rootPath.trim()) {
throw new AgenticError("INVALID_INPUT", "A workspace path is required.");
}
const root = resolve(rootPath);
await mkdir(root, { recursive: true });
const realRoot = await realpath(root);
// A workspace whose own path cannot be canonicalised still works: the
// literal-string protection check and the lexical alias rejection stand on
// their own, and `realRoot` is the conservative base for the rest.
let canonicalRoot = realRoot;
try {
canonicalRoot = await realpathNative(root);
} catch {
canonicalRoot = realRoot;
}
return new WorkspaceBoundary(root, realRoot, canonicalRoot, options);
}
public relativePath(absolutePath: string): string {
const value = relative(this.root, absolutePath);
return normalizeForMatch(value || ".");
}
public isProtected(relativePath: string): boolean {
const normalized = normalizeForMatch(relativePath);
return this.protectedMatchers.some((matcher) => matcher.test(normalized));
}
public async resolveRead(requestedPath: string): Promise<string> {
const lexical = this.resolveLexically(requestedPath);
let resolvedReal: string;
try {
resolvedReal = await realpath(lexical);
} catch (error) {
if (missing(error)) {
throw new AgenticError("NOT_FOUND", `Path does not exist: ${requestedPath}`);
}
throw error;
}
this.assertRealInside(resolvedReal, requestedPath);
return lexical;
}
public async resolveWrite(
requestedPath: string,
options: { allowProtected?: boolean } = {},
): Promise<string> {
const lexical = this.resolveLexically(requestedPath);
const rel = this.relativePath(lexical);
if (!options.allowProtected) {
// Both spellings are tested: the requested one, and the canonical
// identity of the file it actually names. A glob matched only against
// the string the model typed protects a name, not a file.
const canonical = await this.canonicalRelativePath(lexical);
if (this.isProtected(rel) || this.isProtected(canonical)) {
throw new AgenticError(
"PROTECTED_PATH",
`Model edits are not allowed for protected path: ${canonical}`,
{ path: canonical },
);
}
}
await this.assertNoSymlinkComponents(lexical, rel);
let probe = lexical;
while (!(await exists(probe))) {
const parent = dirname(probe);
if (parent === probe) break;
probe = parent;
}
const realAncestor = await realpath(probe);
this.assertRealInside(realAncestor, requestedPath);
if (await exists(lexical)) {
const info = await lstat(lexical);
if (info.isSymbolicLink()) {
throw new AgenticError(
"OUTSIDE_WORKSPACE",
`Writes through symbolic links are rejected: ${rel}`,
{ path: rel },
);
}
this.assertRealInside(await realpath(lexical), requestedPath);
}
return lexical;
}
public resolveInternal(relativePath: string): string {
const lexical = this.resolveLexically(relativePath);
this.assertInternalRelative(this.relativePath(lexical), relativePath);
return lexical;
}
public async resolveInternalRead(relativePath: string): Promise<string> {
const lexical = this.resolveInternal(relativePath);
return await this.resolveRead(this.relativePath(lexical));
}
public async resolveInternalWrite(relativePath: string): Promise<string> {
const lexical = this.resolveInternal(relativePath);
return await this.resolveWrite(this.relativePath(lexical), { allowProtected: true });
}
private async assertNoSymlinkComponents(
lexicalPath: string,
relativePath: string,
): Promise<void> {
const rel = relative(this.root, lexicalPath);
if (!rel || rel === ".") return;
let current = this.root;
for (const segment of rel.split(sep)) {
if (!segment || segment === ".") continue;
current = join(current, segment);
let info;
try {
info = await lstat(current);
} catch (error) {
if (missing(error)) break;
throw error;
}
if (info.isSymbolicLink()) {
throw new AgenticError(
"OUTSIDE_WORKSPACE",
`Writes through symbolic-link path components are rejected: ${relativePath}`,
{ path: relativePath, symlink: this.relativePath(current) },
);
}
}
}
private assertInternalRelative(relativePath: string, requestedPath: string): void {
if (!(relativePath === ".agentic" || relativePath.startsWith(".agentic/"))) {
throw new AgenticError(
"INTERNAL",
`Internal path must remain under .agentic: ${requestedPath}`,
);
}
}
/**
* The canonical workspace-relative identity of an absolute in-workspace
* path: the deepest ancestor that exists, resolved with `realpath.native`,
* with the not-yet-created tail re-attached. Protection decisions use this
* rather than the requested string, so an OS-level alias for a protected
* file matches the glob written against the file's real name.
*
* Falls back to the lexical view whenever the identity cannot be
* established. That is not a hole: `assertRealInside` still owns
* containment, the literal-string check still runs, and on Windows the
* alias spellings are refused lexically before this is ever reached.
*/
private async canonicalRelativePath(lexicalPath: string): Promise<string> {
const tail: string[] = [];
let probe = lexicalPath;
while (!(await exists(probe))) {
const parent = dirname(probe);
if (parent === probe) return this.relativePath(lexicalPath);
tail.unshift(basename(probe));
probe = parent;
}
let canonical: string;
try {
canonical = await realpathNative(probe);
} catch {
return this.relativePath(lexicalPath);
}
const full = tail.length > 0 ? join(canonical, ...tail) : canonical;
const rel = relative(this.canonicalRoot, full);
if (rel.startsWith(`..${sep}`) || rel === ".." || isAbsolute(rel)) {
return this.relativePath(lexicalPath);
}
return normalizeForMatch(rel || ".");
}
/**
* Refuses the two spellings that let one Windows file answer to a name no
* protected-path glob is written against. Kept as a lexical rule even though
* `canonicalRelativePath` also catches the resolvable ones: it needs no
* filesystem state, so it holds for a path whose target does not exist yet
* (`.env:alt` creates a hidden stream on a protected `.env`), and it keeps
* the string the model typed and the file it names in one-to-one
* correspondence everywhere else in the plugin.
*/
private assertNoWindowsAlias(relativePath: string, requestedPath: string): void {
if (process.platform !== "win32") return;
for (const segment of relativePath.split(/[\\/]/)) {
if (!segment || segment === "." || segment === "..") continue;
if (segment.includes(":")) {
throw new AgenticError(
"INVALID_INPUT",
`Path components may not contain ':' on Windows — it names an NTFS alternate data stream, not a file: ${requestedPath}`,
{ path: requestedPath },
);
}
if (WINDOWS_SHORT_NAME.test(segment)) {
throw new AgenticError(
"INVALID_INPUT",
`'${segment}' is a Windows 8.3 short-name alias; use the full name: ${requestedPath}`,
{ path: requestedPath },
);
}
}
}
private resolveLexically(requestedPath: string): string {
if (requestedPath.includes("\0")) {
throw new AgenticError("INVALID_INPUT", "Paths may not contain NUL bytes.");
}
const value = requestedPath.trim() || ".";
const candidate = resolve(this.root, value);
const rel = relative(this.root, candidate);
if (rel.startsWith(`..${sep}`) || rel === ".." || isAbsolute(rel)) {
throw new AgenticError(
"OUTSIDE_WORKSPACE",
`Path escapes the workspace: ${requestedPath}`,
{ workspace: this.root },
);
}
// Only the in-workspace part is checked: the workspace root itself is the
// user's choice and may legitimately carry a drive letter or a short name.
this.assertNoWindowsAlias(rel, requestedPath);
return candidate;
}
private assertRealInside(realPath: string, requestedPath: string): void {
const rel = relative(this.realRoot, realPath);
if (rel.startsWith(`..${sep}`) || rel === ".." || isAbsolute(rel)) {
throw new AgenticError(
"OUTSIDE_WORKSPACE",
`Resolved path escapes the workspace: ${requestedPath}`,
{ resolved: realPath, workspace: this.realRoot },
);
}
}
}