blob: 5f81cd07282456a89ef72e8244ecbb25181be400 [file]
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import { searchFiles, GrepSearchError, type GrepRunner } from '../grep-search.js';
import { promises as fs } from 'node:fs';
import { globFiles } from '../glob-search.js';
import { dirname, isAbsolute, parse, resolve } from 'node:path';
import { isPathInside } from '../path-containment.js';
import {
ripgrepMissingMessage,
ripgrepVanishedMessage,
type RipgrepEnvironment,
} from '../ripgrep-guidance.js';
import { sandboxPathApi } from './sandbox-paths.js';
import { sandboxBoundaryExpansionAllowsPath } from '@maka/core/sandbox-boundary';
import {
ApplyPatchRejectedError,
applyUpdateToContent,
createPatchedFile,
} from '../apply-patch-file.js';
import { computeEditedSource } from '../edit-replace.js';
import { readPage } from '../read-page.js';
import { createEditUnifiedDiff, createUnifiedDiff } from '../unified-diff.js';
import {
compareAndDeleteEntry,
hostVisibilityAfterWrite,
openStableTarget,
readModifyWriteThroughHandle,
StableWriteFailure,
writeThroughHandle,
} from '../file-stable-write.js';
import { isSupportedImagePath, readWorkspaceImage } from '../image-file.js';
import {
FILESYSTEM_WORKER_PROTOCOL_VERSION,
operationAccess,
operationUsesDirectoryEntry,
type FilesystemWorkerErrorCode,
type FilesystemWorkerOperation,
type FilesystemWorkerRequest,
type FilesystemWorkerResponse,
type FilesystemWorkerResult,
type FilesystemWorkerTarget,
} from './protocol.js';
import { isLikelySandboxDenial } from '../sandbox/detect.js';
// Canonicalisation must match the sandbox the worker runs in: realpath-based
// on POSIX, lexical + reparse-rejecting inside the Windows AppContainer where
// realpath is denied. See sandbox-paths.ts.
const { realpath, realpathAllowMissing, resolveCanonicalDirectoryEntryTarget } = sandboxPathApi();
export interface FilesystemWorkerOperationDependencies {
grepExecutable?: string;
/** Where this worker runs, as the Host observed it; names the install location in Grep's guidance. */
ripgrepEnvironment?: RipgrepEnvironment;
runGrep?: GrepRunner;
/** Set when the worker runs inside the Windows AppContainer sandbox. */
windowsSandboxed?: boolean;
}
export async function executeFilesystemWorkerRequest(
request: FilesystemWorkerRequest,
dependencies: FilesystemWorkerOperationDependencies = {},
): Promise<FilesystemWorkerResponse> {
try {
await assertTargetUnchanged(
request.operation.cwd,
request.operation.path,
request.expectedTarget,
operationUsesDirectoryEntry(request.operation),
operationAccess(request.operation.kind),
);
return {
version: FILESYSTEM_WORKER_PROTOCOL_VERSION,
requestId: request.requestId,
ok: true,
result: await executeFilesystemOperation(
request.operation,
request.operationBoundary,
dependencies,
request.expectedTarget,
),
};
} catch (error) {
const normalized = normalizeOperationError(error);
return {
version: FILESYSTEM_WORKER_PROTOCOL_VERSION,
requestId: request.requestId,
ok: false,
error: { code: normalized.code, message: normalized.message },
};
}
}
export async function executeFilesystemOperation(
operation: FilesystemWorkerOperation,
operationBoundary: FilesystemWorkerRequest['operationBoundary'],
dependencies: FilesystemWorkerOperationDependencies = {},
expectedTarget?: FilesystemWorkerTarget,
): Promise<FilesystemWorkerResult> {
switch (operation.kind) {
case 'read': {
const path = await resolveExistingAllowed(
operation.cwd,
operation.path,
'Read',
'read',
operationBoundary,
);
if (isSupportedImagePath(path)) {
try {
const image = await readWorkspaceImage(path);
return {
kind: 'read_image',
base64: Buffer.from(image.bytes).toString('base64'),
mimeType: image.mimeType,
};
} catch (error) {
throw operationError(
'filesystem_error',
error instanceof Error ? error.message : 'Image could not be read.',
);
}
}
const content = await fs.readFile(path, 'utf8');
try {
return { kind: 'read', ...readPage(content, operation, undefined, operation.continuation) };
} catch (error) {
throw operationError(
'invalid_request',
error instanceof Error
? error.message
: 'Read pagination failed. Use the original path again.',
);
}
}
case 'write': {
const path = await resolveWritableAllowed(
operation.cwd,
operation.path,
'Write',
operationBoundary,
);
// Pin the approved object (#2600): open once, validate the identity on
// the descriptor, and write through that descriptor — a path swap between
// validation and the write cannot divert the bytes onto the replacement.
// An approved-missing target is created exclusively; anything that
// appeared in the gap is `path_changed`, never truncated.
const handle = await openStableTarget({
path,
approvedIdentity:
typeof expectedTarget?.identity === 'object' ? expectedTarget.identity : undefined,
targetType: expectedTarget?.targetType,
});
try {
// Read-before-write (for the diff): only through the pinned descriptor.
// An approved-missing target was just created by 'wx', so it is new.
// The wire identity is three-state (#3484): 'missing' is a truthy
// string, so a truthiness test can no longer stand in for "the target
// was approved as missing" — targetType is the authority here.
let previous: 'new' | 'unknown' | string;
if (expectedTarget?.targetType === 'missing') {
previous = 'new';
} else {
try {
previous = await handle.readFile('utf8');
} catch {
previous = 'unknown';
}
}
await writeThroughHandle(handle, operation.content);
// Host visibility: if the path no longer resolves to the pinned inode,
// the bytes went to an orphan and the visible file is the replacement.
const visibility = await hostVisibilityAfterWrite(path, handle);
if (visibility) throw visibility;
const diff =
previous === 'unknown'
? undefined
: createUnifiedDiff(path, previous === 'new' ? undefined : previous, operation.content);
return {
kind: 'write',
ok: true,
path,
bytes: Buffer.byteLength(operation.content, 'utf8'),
...(diff !== undefined ? { diff } : {}),
};
} finally {
await handle.close();
}
}
case 'apply_patch': {
if (operation.action !== 'update') {
const path = await resolveDirectoryEntryAllowed(
operation.cwd,
operation.path,
operation.action === 'create' ? 'ApplyPatch create' : 'ApplyPatch delete',
operationBoundary,
);
if (operation.action === 'create') await createPatchedFile(path, operation.diff);
// Compare-and-delete (#2600): rename the entry to a tombstone,
// verify the approved identity, then unlink — a replacement swapped
// in after the check is restored and reported, never silently
// deleted.
else
await compareAndDeleteEntry({
path,
approvedIdentity:
typeof expectedTarget?.identity === 'object' ? expectedTarget.identity : undefined,
});
return { kind: 'apply_patch', ok: true, path };
}
const path = await resolveExistingAllowed(
operation.cwd,
operation.path,
'ApplyPatch update',
'write',
operationBoundary,
);
// Pin the target and apply the diff through one descriptor (#2600); a
// rejected patch propagates before any truncation, so the file is intact.
const handle = await openStableTarget({
path,
approvedIdentity:
typeof expectedTarget?.identity === 'object' ? expectedTarget.identity : undefined,
targetType: expectedTarget?.targetType,
});
try {
await readModifyWriteThroughHandle(handle, (existing) =>
applyUpdateToContent(existing, operation.diff),
);
const visibility = await hostVisibilityAfterWrite(path, handle);
if (visibility) throw visibility;
} finally {
await handle.close();
}
return { kind: 'apply_patch', ok: true, path };
}
case 'edit': {
const path = await resolveExistingAllowed(
operation.cwd,
operation.path,
'Edit',
'write',
operationBoundary,
);
const handle = await openStableTarget({
path,
approvedIdentity:
typeof expectedTarget?.identity === 'object' ? expectedTarget.identity : undefined,
targetType: expectedTarget?.targetType,
});
try {
const content = await handle.readFile('utf8');
let source: ReturnType<typeof computeEditedSource>;
try {
source = computeEditedSource(
content,
operation.oldString,
operation.newString,
operation.path,
);
} catch (error) {
throw operationError(
'edit_conflict',
error instanceof Error ? error.message : 'Edit could not be applied.',
);
}
await writeThroughHandle(handle, source.content);
const visibility = await hostVisibilityAfterWrite(path, handle);
if (visibility) throw visibility;
const diff = createEditUnifiedDiff(path, content, source.content, source);
return {
kind: 'edit',
ok: true,
path,
replacements: 1,
matchedVia: source.matchedVia,
startLine: source.startLine,
endLine: source.endLine,
...(diff !== undefined ? { diff } : {}),
};
} finally {
await handle.close();
}
}
case 'format_json': {
const path = await resolveExistingAllowed(
operation.cwd,
operation.path,
'FormatJson',
'write',
operationBoundary,
);
const handle = await openStableTarget({
path,
approvedIdentity:
typeof expectedTarget?.identity === 'object' ? expectedTarget.identity : undefined,
targetType: expectedTarget?.targetType,
});
try {
const original = await handle.readFile('utf8');
const bytesBefore = Buffer.byteLength(original, 'utf8');
let parsed: unknown;
try {
parsed = JSON.parse(original);
} catch (error) {
// Invalid JSON: return the structured failure without writing.
return {
kind: 'format_json',
ok: false,
valid: false,
path,
error: `FormatJson: invalid JSON: ${error instanceof Error ? error.message : 'parse failed'}`,
bytesBefore,
byteDelta: 0,
changed: false,
};
}
const formatted = JSON.stringify(
operation.sortKeys ? sortKeysDeep(parsed) : parsed,
null,
2,
);
if (formatted !== original) {
await writeThroughHandle(handle, formatted);
}
const visibility = await hostVisibilityAfterWrite(path, handle);
if (visibility) throw visibility;
const bytesAfter = Buffer.byteLength(formatted, 'utf8');
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 } : {}),
};
} finally {
await handle.close();
}
}
case 'glob': {
assertContainedGlobPattern(operation.pattern);
const path = await resolveExistingAllowed(
operation.cwd,
operation.path,
'Glob cwd',
'read',
operationBoundary,
);
return { kind: 'glob', ...(await globFiles({ ...operation, cwd: path })) };
}
case 'grep': {
const path = await resolveExistingAllowed(
operation.cwd,
operation.path,
'Grep',
'read',
operationBoundary,
);
// The Windows AppContainer cannot create grandchild processes (the
// desktop object is not granted to the container SID), so ripgrep
// cannot run there — and no in-process substitute preserves Grep's
// advertised regex/ripgrep contract (pattern dialect, gitignore
// filtering, glob and truncation behavior). The Windows sandbox
// preview therefore does not expose Grep: failing closed keeps the
// public contract honest until a contract-preserving search engine
// exists. Glob and Read remain available; an unsandboxed Windows
// worker never carries this marker and keeps full ripgrep behavior.
if (dependencies.windowsSandboxed) {
throw operationError(
'grep_unavailable',
'Grep is not available inside the Windows sandbox preview; use Glob and Read instead.',
);
}
const grepExecutable = dependencies.grepExecutable;
if (!grepExecutable)
throw operationError(
'grep_unavailable',
ripgrepMissingMessage(dependencies.ripgrepEnvironment),
);
const result = await searchFiles(
{
...operation,
path,
executable: grepExecutable,
// The target is canonical and absolute. Running from its filesystem root avoids
// requiring operation-scoped workers to read the broader session workspace.
cwd: parse(path).root,
timeoutMs: operation.timeoutMs,
},
dependencies.runGrep,
).catch((error: unknown) => {
// The cwd is a filesystem root, which always exists, so a spawn ENOENT
// means the executable this worker was launched with is gone — removed
// after the launch configuration checked it. Left alone it would be
// normalized to `not_found` and read as a missing search path.
if (nodeErrorCode(error) === 'ENOENT')
throw operationError(
'grep_unavailable',
ripgrepVanishedMessage(grepExecutable, dependencies.ripgrepEnvironment),
);
if (error instanceof GrepSearchError) {
throw operationError(
isLikelySandboxDenial({ stdout: '', stderr: error.message, sandboxed: true })
? 'sandbox_denied'
: 'filesystem_error',
error.message,
);
}
throw error;
});
return { kind: 'grep', ...result };
}
}
}
class FilesystemOperationError extends Error {
constructor(
readonly code: FilesystemWorkerErrorCode,
message: string,
) {
super(message);
this.name = 'FilesystemOperationError';
}
}
function operationError(
code: FilesystemWorkerErrorCode,
message: string,
): FilesystemOperationError {
return new FilesystemOperationError(code, message);
}
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;
}
function normalizeOperationError(error: unknown): FilesystemOperationError {
if (error instanceof FilesystemOperationError) return error;
if (error instanceof StableWriteFailure) {
return operationError(error.code, error.message);
}
if (error instanceof ApplyPatchRejectedError) {
return operationError('edit_conflict', error.message);
}
const code = nodeErrorCode(error);
if (code === 'ENOENT' || code === 'ENOTDIR')
return operationError('not_found', 'The requested path was not found.');
if (code === 'EACCES' || code === 'EPERM')
return operationError('filesystem_denied', 'Filesystem access was denied.');
return operationError('filesystem_error', 'Filesystem operation failed.');
}
async function assertTargetUnchanged(
cwd: string,
path: string,
expected: FilesystemWorkerTarget,
noFollowFinalSymlink = false,
access: 'read' | 'write' = 'read',
): Promise<void> {
const enforcementPath = noFollowFinalSymlink
? (await resolveCanonicalDirectoryEntryTarget(cwd, path)).path
: await realpathAllowMissing(path);
const targetType = noFollowFinalSymlink
? await lstatTargetTypeOf(enforcementPath)
: await targetTypeOf(enforcementPath);
if (enforcementPath !== expected.enforcementPath || targetType !== expected.targetType) {
throw operationError(
'path_changed',
'The approved filesystem target changed before execution.',
);
}
// Compare the on-disk identity against the one captured at authorisation
// time. This is the load-bearing check for the queue window: a path swapped
// while the call waited for the lock has a different inode even when its
// canonical path and type still match.
//
// The wire carries one required three-state identity contract (#3484):
// - { dev, ino }: CAS against the on-disk inode.
// - 'missing': T0 saw no target but T1 does — something created it while
// this call waited. Writing would clobber content the caller never saw.
// - 'unchecked': the caller deliberately does not participate in CAS.
// Reads never mutate and are exempt either way.
if (access === 'write' && expected.targetType !== 'missing') {
if (expected.identity === 'missing') {
throw operationError(
'path_changed',
'The target was created while this call waited for the lock; re-read before writing.',
);
}
if (typeof expected.identity === 'object') {
const metadata = noFollowFinalSymlink
? await fs.lstat(enforcementPath, { bigint: true })
: await fs.stat(enforcementPath, { bigint: true });
if (
String(metadata.dev) !== expected.identity.dev ||
String(metadata.ino) !== expected.identity.ino
) {
throw operationError(
'path_changed',
'The approved filesystem target changed before execution.',
);
}
}
// identity === 'unchecked': nothing to compare, nothing to fail.
}
}
async function resolveWritableAllowed(
cwd: string,
inputPath: string,
label: string,
permission: FilesystemWorkerRequest['operationBoundary'],
): Promise<string> {
const { root, candidate } = await resolveCandidate(cwd, inputPath, label, 'write', permission);
try {
const target = await realpath(candidate);
assertAllowed(root, target, label, 'write', permission);
return target;
} catch (error) {
if (nodeErrorCode(error) !== 'ENOENT') throw error;
}
// The target does not exist, but it can still be a dangling symlink, and a
// write lands on what the link names rather than on the link. Authorise the
// followed path — the same one `assertTargetUnchanged` pins the request to —
// so the worker enforces its own boundary instead of trusting the caller to
// have canonicalised the path for it.
const followed = await realpathAllowMissing(candidate);
const parent = await realpath(dirname(followed));
assertAllowed(root, followed, label, 'write', permission);
if (!isPathInside(root, parent) && !exactWriteCoversParent(permission, followed, parent)) {
throw operationError(
'path_denied',
`${label} parent was not covered by the operation boundary.`,
);
}
return followed;
}
async function resolveDirectoryEntryAllowed(
cwd: string,
inputPath: string,
label: string,
permission: FilesystemWorkerRequest['operationBoundary'],
): Promise<string> {
const target = await resolveCanonicalDirectoryEntryTarget(cwd, inputPath);
assertAllowed(target.root, target.path, label, 'write', permission);
return target.path;
}
async function resolveExistingAllowed(
cwd: string,
inputPath: string,
label: string,
access: 'read' | 'write',
permission: FilesystemWorkerRequest['operationBoundary'],
): Promise<string> {
const { root, candidate } = await resolveCandidate(cwd, inputPath, label, access, permission);
const target = await realpath(candidate);
assertAllowed(root, target, label, access, permission);
return target;
}
async function resolveCandidate(
cwd: string,
inputPath: string,
label: string,
access: 'read' | 'write',
permission: FilesystemWorkerRequest['operationBoundary'],
): Promise<{ root: string; candidate: string }> {
const root = await realpath(cwd);
const candidate = resolve(root, inputPath);
if (
!isPathInside(root, candidate) &&
!sandboxBoundaryExpansionAllowsPath(permission, candidate, access)
) {
throw operationError('path_denied', `${label} path was not covered by the operation boundary.`);
}
return { root, candidate };
}
function assertAllowed(
root: string,
target: string,
label: string,
access: 'read' | 'write',
permission: FilesystemWorkerRequest['operationBoundary'],
): void {
if (isPathInside(root, target) || sandboxBoundaryExpansionAllowsPath(permission, target, access))
return;
throw operationError('path_denied', `${label} path escaped its approved target.`);
}
function exactWriteCoversParent(
permission: FilesystemWorkerRequest['operationBoundary'],
target: string,
parent: string,
): boolean {
return (
permission.filesystem?.entries.some(
(entry) =>
entry.access === 'write' &&
entry.scope === 'exact' &&
entry.path === target &&
dirname(entry.path) === parent,
) ?? false
);
}
function assertContainedGlobPattern(pattern: string): void {
if (isAbsolute(pattern) || pattern.split(/[\\/]+/).includes('..')) {
throw operationError('path_denied', 'Glob pattern must stay inside its search root.');
}
}
async function targetTypeOf(path: string): Promise<FilesystemWorkerTarget['targetType']> {
try {
const metadata = await fs.stat(path);
if (metadata.isFile()) return 'file';
if (metadata.isDirectory()) return 'directory';
return 'other';
} catch (error) {
if (nodeErrorCode(error) === 'ENOENT') return 'missing';
throw error;
}
}
async function lstatTargetTypeOf(path: string): Promise<FilesystemWorkerTarget['targetType']> {
try {
const metadata = await fs.lstat(path);
if (metadata.isSymbolicLink()) return 'symlink';
if (metadata.isFile()) return 'file';
if (metadata.isDirectory()) return 'directory';
return 'other';
} catch (error) {
if (nodeErrorCode(error) === 'ENOENT') return 'missing';
throw error;
}
}
function nodeErrorCode(error: unknown): string | undefined {
if (!error || typeof error !== 'object' || !('code' in error)) return undefined;
return typeof error.code === 'string' ? error.code : undefined;
}