blob: a4769dea7550e26524e893586b54a87d744d7841 [file]
// packages/runtime/src/filesystem-executor.ts
// The single authority for where the built-in file tools may reach.
//
// One decision — the active ExecutionBoundary — picks the backend and the path
// scope; the tools carry no policy branch and no executor carries a containment
// rule of its own. Before this seam existed each file tool repeated the same
// worker-versus-executor branch, and the fallback executor hard-coded a session-cwd
// containment that no permission profile actually declares. A bypass boundary
// skipped the worker, so that undeclared rule became the only arbiter and made
// "full access" stricter than ask mode, which grants :slash_tmp outright (#2083).
import { Buffer } from 'node:buffer';
import { realpath } from 'node:fs/promises';
import { isAbsolute } from 'node:path';
import type { ExecutionBoundary } from '@maka/core/sandbox-boundary';
import type { PermissionMode } from '@maka/core/permission';
import type { PermissionProfile } from '@maka/core/permission-profile';
import { computeEditedSource } from './edit-replace.js';
import { createUnifiedDiff } from './unified-diff.js';
import { withFileWriteLock } from './file-write-lock.js';
import type {
FilesystemWorkerClient,
FilesystemWorkerClientOperation,
} from './filesystem-worker/client.js';
import type { ImageMimeType } from './image-file.js';
import type { FilesystemWorkerResult } from './filesystem-worker/protocol.js';
import { resolveCanonicalDirectoryEntryTarget } from './path-containment.js';
import { normalizeSandboxBoundaryPath } from './sandbox-boundary-path.js';
import { SandboxCommandError } from './sandbox/errors.js';
import type {
WorkspaceEditExecutor,
WorkspaceApplyPatchExecutor,
WorkspacePathScope,
WorkspaceSearchExecutor,
WorkspaceWriteExecutor,
} from './workspace-executor.js';
/** A file operation, named the same way on every backend. `cwd` is supplied per call. */
type FilesystemBackendOperation = FilesystemWorkerClientOperation;
export type FilesystemOperation = Exclude<FilesystemBackendOperation, { kind: 'apply_patch' }>;
/**
* The result shape every backend answers with.
*
* It is the worker protocol's union with one substitution: image bytes stay
* bytes. Base64 is how the worker's JSON transport carries them, not part of
* this contract, so the worker-backed backend decodes once at its own edge and
* the host-local backend hands its buffer straight through.
*/
export type FilesystemResult =
| Exclude<FilesystemWorkerResult, { kind: 'read_image' }>
| { kind: 'read_image'; bytes: Uint8Array; mimeType: ImageMimeType };
export interface FilesystemExecuteInput {
operation: FilesystemOperation;
cwd: string;
executionBoundary?: ExecutionBoundary;
/** Only consulted when no boundary is present; the boundary always wins. */
permissionMode?: PermissionMode;
abortSignal?: AbortSignal;
}
type FilesystemBackendExecuteInput = Omit<FilesystemExecuteInput, 'operation'> & {
operation: FilesystemBackendOperation;
};
export type ApplyPatchOperation =
| { type: 'create_file'; path: string; diff: string }
| { type: 'delete_file'; path: string }
| { type: 'update_file'; path: string; diff: string };
export interface FilesystemApplyPatchInput extends Omit<FilesystemExecuteInput, 'operation'> {
operation: ApplyPatchOperation;
}
export interface ApplyPatchResult {
status: 'completed';
}
export interface FilesystemExecutor {
/**
* Run one operation under the authority of the boundary it carries. A mutating
* operation holds the target's write lock for its whole read-modify-write, so
* no caller has to know that a lock exists or how its key is spelled.
*/
execute(input: FilesystemExecuteInput): Promise<FilesystemResult>;
applyPatch(input: FilesystemApplyPatchInput): Promise<ApplyPatchResult>;
}
/** The workspace primitives the host-local backend drives. */
export type FilesystemWorkspaceExecutor = WorkspaceWriteExecutor &
WorkspaceEditExecutor &
Partial<WorkspaceApplyPatchExecutor> &
WorkspaceSearchExecutor;
export interface BoundaryFilesystemExecutorInput {
workspace: FilesystemWorkspaceExecutor;
worker?: Pick<FilesystemWorkerClient, 'execute'>;
/** Explicit embedding policy handed to the worker instead of a mode default. */
permissionProfile?: PermissionProfile;
}
/**
* The path scope a boundary authorises.
*
* `bypass` is the user asking for no restrictions, and it already means exactly
* that for Bash, which runs untransformed on the host under the same boundary.
* Every other boundary — including a missing one, which is an embedder that
* never opted in — stays workspace-scoped.
*/
function pathScopeForBoundary(boundary: ExecutionBoundary | undefined): WorkspacePathScope {
return boundary?.kind === 'bypass' ? 'host' : 'workspace';
}
/** Operations that read, modify and write back, and so must hold the target's lock. */
function mutates(operation: FilesystemOperation): boolean {
return (
operation.kind === 'write' || operation.kind === 'edit' || operation.kind === 'format_json'
);
}
/**
* Compose the backends behind one boundary-driven decision.
*
* - `managed` → the sandboxed worker, which enforces the boundary's profile.
* - `bypass` → host-local execution with host path scope.
* - `external` → the injected workspace executor, whose own workspace is the
* whole filesystem it can address; it never falls back to host access.
* - absent → the worker when one is wired, otherwise workspace-scoped local
* execution. This is the embedding default and deliberately the narrow one.
*/
export function createBoundaryFilesystemExecutor(
input: BoundaryFilesystemExecutorInput,
): FilesystemExecutor {
const local = createWorkspaceFilesystemExecutor(input.workspace);
/** The worker that owns this boundary, or undefined when the workspace backend does. */
const workerFor = (
boundary: ExecutionBoundary | undefined,
): Pick<FilesystemWorkerClient, 'execute'> | undefined => {
if (boundary?.kind === 'bypass' || boundary?.kind === 'external') return undefined;
if (input.worker) return input.worker;
if (boundary?.kind !== 'managed') return undefined;
throw new SandboxCommandError({
domain: 'filesystem',
stage: 'capability',
reason: 'requires_bypass',
recoverable: false,
profileName: boundary.profile.name ?? boundary.profile.type,
message:
'Managed filesystem execution is unavailable because the sandboxed worker cannot be enforced.',
});
};
async function run(call: FilesystemBackendExecuteInput): Promise<FilesystemResult> {
const worker = workerFor(call.executionBoundary);
if (!worker) return await local.execute(call, pathScopeForBoundary(call.executionBoundary));
const result = await worker.execute({
operation: call.operation,
// The worker is host-local by definition, so a session opened through a
// symlinked cwd must reach it under the real path — otherwise the same
// file arrives under two identities. The workspace backend is left the cwd
// it was given: an isolated or remote workspace path is not the host's to
// rewrite, and its own resolvers canonicalise what they need.
cwd: await canonicalExistingPath(call.cwd),
...(call.executionBoundary ? { executionBoundary: call.executionBoundary } : {}),
mode: call.permissionMode ?? 'ask',
...(input.permissionProfile ? { permissionProfile: input.permissionProfile } : {}),
...(call.abortSignal ? { abortSignal: call.abortSignal } : {}),
});
if (result.kind === 'read_image') {
return {
kind: 'read_image',
bytes: Buffer.from(result.base64, 'base64'),
mimeType: result.mimeType,
};
}
return result;
}
async function writeLockTarget(
call: Omit<FilesystemExecuteInput, 'operation'>,
path: string,
semantics: 'target' | 'entry' = 'target',
): Promise<string> {
const worker = workerFor(call.executionBoundary);
if (!worker)
return (
await input.workspace.writeLockKey({
cwd: call.cwd,
path,
semantics,
})
).key;
if (semantics === 'entry') {
return (await resolveCanonicalDirectoryEntryTarget(call.cwd, path)).path;
}
return (
await normalizeSandboxBoundaryPath({
path,
access: 'write',
scope: 'exact',
cwd: await canonicalExistingPath(call.cwd),
})
).enforcementPath;
}
return {
async execute(call) {
if (!mutates(call.operation)) return await run(call);
// Canonicalisation without any containment check, so a target the policy
// goes on to reject still takes the same lock as its other spellings. The
// key is derived from the same canonicalisation the backend will resolve
// with, or the lock-key space and the resolved-path space drift apart.
const key = await writeLockTarget(call, call.operation.path);
return await withFileWriteLock(key, () => run(call));
},
async applyPatch(call) {
const { operation, ...common } = call;
const semantics = operation.type === 'update_file' ? 'target' : 'entry';
const key = await writeLockTarget(common, operation.path, semantics);
return await withFileWriteLock(key, async () => {
const backendOperation: FilesystemWorkerClientOperation =
operation.type === 'delete_file'
? { kind: 'apply_patch', path: operation.path, action: 'delete' }
: {
kind: 'apply_patch',
path: operation.path,
action: operation.type === 'create_file' ? 'create' : 'update',
diff: operation.diff,
};
const result = await run({ ...common, operation: backendOperation });
if (result.kind !== 'apply_patch') {
throw new Error(`ApplyPatch backend returned ${JSON.stringify(result.kind)}.`);
}
return { status: 'completed' };
});
},
};
}
interface WorkspaceFilesystemBackend {
execute(
input: FilesystemBackendExecuteInput,
scope: WorkspacePathScope,
): Promise<FilesystemResult>;
}
/**
* Run an operation directly against the host (or an isolated workspace), with the
* path scope the caller derived from the boundary. This backend enforces the
* scope it is given and decides nothing else.
*/
function createWorkspaceFilesystemExecutor(
workspace: FilesystemWorkspaceExecutor,
): WorkspaceFilesystemBackend {
return {
async execute({ operation, cwd, abortSignal }, scope) {
switch (operation.kind) {
case 'read': {
const { path } = await workspace.resolveExistingPath({
cwd,
path: operation.path,
label: 'Read',
scope,
});
const result = await workspace.readFile({
cwd,
path,
...(operation.offset !== undefined ? { offset: operation.offset } : {}),
...(operation.limit !== undefined ? { limit: operation.limit } : {}),
});
if ('bytes' in result) {
return { kind: 'read_image', bytes: result.bytes, mimeType: result.mimeType };
}
return { kind: 'read', content: result.content };
}
case 'write': {
const { path } = await workspace.resolveWritablePath({
cwd,
path: operation.path,
label: 'Write',
scope,
});
// Read-before-write: an overwrite's diff is what tells the reader
// what was lost. Only a missing file means the whole content is
// new — an unreadable or binary existing file leaves the previous
// state unknown, and claiming `--- /dev/null` would report the
// file as created.
let previous: 'new' | 'unknown' | string;
try {
const read = await workspace.readFile({ cwd, path });
previous = 'bytes' in read ? 'unknown' : read.content;
} catch (error) {
const code = (error as NodeJS.ErrnoException).code;
previous = code === 'ENOENT' || code === 'ENOTDIR' ? 'new' : 'unknown';
}
const written = await workspace.writeFile({ cwd, path, content: operation.content });
const diff =
previous === 'unknown'
? undefined
: createUnifiedDiff(
written.path,
previous === 'new' ? undefined : previous,
operation.content,
);
return {
kind: 'write',
ok: true,
path: written.path,
bytes: written.bytes,
...(diff !== undefined ? { diff } : {}),
};
}
case 'apply_patch': {
if (!workspace.applyPatch) throw new Error('Workspace does not support ApplyPatch');
const common = { cwd, path: operation.path, label: 'ApplyPatch', scope };
const patched = await workspace.applyPatch(
operation.action === 'delete'
? { ...common, action: 'delete' }
: { ...common, action: operation.action, diff: operation.diff },
);
return { kind: 'apply_patch', ok: true, path: patched.path };
}
case 'edit': {
const { path } = await workspace.resolveExistingPath({
cwd,
path: operation.path,
label: 'Edit',
scope,
});
const read = await workspace.readFile({ cwd, path });
if ('bytes' in read) throw new Error('Edit does not support image files.');
const edited = computeEditedSource(
read.content,
operation.oldString,
operation.newString,
operation.path,
);
await workspace.writeFile({ cwd, path, content: edited.content });
const diff = createUnifiedDiff(path, read.content, edited.content);
return {
kind: 'edit',
ok: true,
path,
replacements: 1,
matchedVia: edited.matchedVia,
startLine: edited.startLine,
endLine: edited.endLine,
...(diff !== undefined ? { diff } : {}),
};
}
case 'format_json': {
const { path } = await workspace.resolveExistingPath({
cwd,
path: operation.path,
label: 'FormatJson',
scope,
});
const read = await workspace.readFile({ cwd, path });
if ('bytes' in read) throw new Error('FormatJson does not support image files.');
const original = read.content;
const bytesBefore = Buffer.byteLength(original, 'utf8');
let parsed: unknown;
try {
parsed = JSON.parse(original);
} catch (error) {
return {
kind: 'format_json',
ok: false,
valid: false,
error: `FormatJson: invalid JSON: ${(error as Error).message}`,
path,
bytesBefore,
byteDelta: 0,
changed: false,
};
}
const value = operation.sortKeys ? sortKeysDeep(parsed) : parsed;
const formatted = JSON.stringify(value, null, 2);
const { bytes: bytesAfter } = await workspace.writeFile({
cwd,
path,
content: formatted,
});
const diff =
formatted === original ? undefined : createUnifiedDiff(path, original, formatted);
return {
kind: 'format_json',
ok: true,
valid: true,
path,
bytesBefore,
bytesAfter,
byteDelta: bytesAfter - bytesBefore,
changed: formatted !== original,
...(diff !== undefined ? { diff } : {}),
};
}
case 'glob': {
assertGlobPatternInScope(operation.pattern, scope);
const { path: base } = await workspace.resolveExistingPath({
cwd,
path: operation.path,
label: 'Glob cwd',
scope,
});
const { files } = await workspace.globFiles({
cwd: base,
pattern: operation.pattern,
...(operation.limit !== undefined ? { limit: operation.limit } : {}),
});
return { kind: 'glob', files };
}
case 'grep': {
const { path } = await workspace.resolveExistingPath({
cwd,
path: operation.path,
label: 'Grep',
scope,
});
const { matches } = await workspace.grepFiles({
cwd,
pattern: operation.pattern,
path,
...(operation.glob ? { glob: operation.glob } : {}),
maxCountPerFile: operation.maxCountPerFile,
limit: operation.limit,
timeoutMs: operation.timeoutMs,
...(abortSignal ? { abortSignal } : {}),
});
return { kind: 'grep', matches };
}
}
},
};
}
/**
* A glob pattern is expanded by the walker rather than resolved as a path, so
* its escapes have to be caught lexically. Under host scope there is nothing to
* escape from and the pattern is left alone.
*/
function assertGlobPatternInScope(pattern: string, scope: WorkspacePathScope): void {
if (scope === 'host') return;
if (isAbsolute(pattern) || pattern.split(/[\\/]+/).includes('..')) {
throw new Error('Glob pattern must stay inside session cwd');
}
}
/** The canonical spelling of an existing directory, or the input when it is not resolvable here. */
async function canonicalExistingPath(path: string): Promise<string> {
return await realpath(path).catch(() => path);
}
// Object.fromEntries creates own data properties, so special keys like
// "__proto__" are preserved instead of triggering the inherited setter.
function sortKeysDeep(value: unknown): unknown {
if (Array.isArray(value)) return value.map(sortKeysDeep);
if (value !== null && typeof value === 'object' && !(value instanceof Date)) {
return Object.fromEntries(
Object.keys(value)
.sort()
.map((key) => [key, sortKeysDeep((value as Record<string, unknown>)[key])]),
);
}
return value;
}