blob: 8f11f0afa8f7e31fabb9d58afa6fe5f131f9d8d9 [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 { realpath, stat } from 'node:fs/promises';
import { isAbsolute, relative, resolve, sep } from 'node:path';
import { ARTIFACT_IMAGE_PREVIEW_MAX_BYTES } from '@maka/core/artifacts';
import {
checkedChatImage,
ImageSourceError,
localImagePath,
type ChatImageBytes,
} from './chat-image-source.js';
/** Avatar capture is narrower than Read (even for a full-access Session).
* The cwd is the invoking tool Session's persisted workspace, never the Host cwd
* or the conversation owner's workspace. Keep the normal Session filesystem
* authority as well: this containment check is not a replacement for it.
*/
export async function readAvatarImageSource(
source: string,
signal: AbortSignal,
ports: {
workspace(): Promise<string>;
readLocal(path: string, signal: AbortSignal): Promise<ChatImageBytes>;
download(source: string, signal: AbortSignal): Promise<ChatImageBytes>;
},
): Promise<ChatImageBytes> {
const boundedSignal = AbortSignal.any([signal, AbortSignal.timeout(30_000)]);
boundedSignal.throwIfAborted();
const path = localImagePath(source);
if (path === undefined) {
// The Host downloader uses public-target DNS pinning, manual redirects
// (each hop is checked), streamed byte limits and its outbound proxy policy.
const image = await ports.download(source, boundedSignal);
boundedSignal.throwIfAborted();
return checkedChatImage(image.bytes);
}
const cwd = await ports.workspace();
if (!isAbsolute(cwd)) throw new ImageSourceError('not_allowed');
const root = await realpath(cwd);
const target = await realpath(resolve(root, path));
const rel = relative(root, target);
if (!rel || rel === '..' || rel.startsWith(`..${sep}`) || isAbsolute(rel))
throw new ImageSourceError('not_allowed');
const before = await stat(target);
if (!before.isFile()) throw new ImageSourceError('not_allowed');
if (before.size > ARTIFACT_IMAGE_PREVIEW_MAX_BYTES) throw new ImageSourceError('too_large');
boundedSignal.throwIfAborted();
const image = await ports.readLocal(target, boundedSignal);
boundedSignal.throwIfAborted();
// Reject observed replacements during capture. As with the existing Session
// image reader, this is not an OS-level defense against concurrent hostile
// filesystem mutation; that requires descriptor-relative runtime/worker reads.
const after = await stat(target);
if (
(await realpath(resolve(root, path))) !== target ||
before.dev !== after.dev ||
before.ino !== after.ino ||
before.size !== after.size ||
before.mtimeMs !== after.mtimeMs
)
throw new ImageSourceError('not_allowed');
return checkedChatImage(image.bytes);
}