feat(fleet): enforce generated environment boundary (#772)
This commit was merged in pull request #772.
This commit is contained in:
509
packages/mosaic/src/fleet/generated-env-boundary.ts
Normal file
509
packages/mosaic/src/fleet/generated-env-boundary.ts
Normal file
@@ -0,0 +1,509 @@
|
||||
import { createHash, randomUUID } from 'node:crypto';
|
||||
import { chmod, lstat, mkdir, readFile, rename, unlink, writeFile } from 'node:fs/promises';
|
||||
import { dirname, join, resolve } from 'node:path';
|
||||
|
||||
export type AgentEnvironmentKind = 'generated' | 'local';
|
||||
|
||||
export interface AgentEnvironmentDiagnostic {
|
||||
readonly code: string;
|
||||
readonly key: string;
|
||||
readonly sha256: string;
|
||||
}
|
||||
|
||||
export interface AgentEnvironmentProjectionOptions {
|
||||
readonly mosaicHome: string;
|
||||
readonly agentEnvDir: string;
|
||||
readonly agentName: string;
|
||||
readonly generated: Readonly<Record<string, string>>;
|
||||
}
|
||||
|
||||
export interface AgentEnvironmentProjectionResult {
|
||||
readonly generatedPath: string;
|
||||
readonly localPath: string;
|
||||
readonly quarantinePath?: string;
|
||||
readonly diagnostics: readonly AgentEnvironmentDiagnostic[];
|
||||
}
|
||||
|
||||
/** A projection fully validated without changing managed files. */
|
||||
export interface PreparedAgentEnvironmentProjection {
|
||||
readonly mosaicHome: string;
|
||||
readonly agentEnvDir: string;
|
||||
readonly generatedPath: string;
|
||||
readonly localPath: string;
|
||||
readonly legacyPath: string;
|
||||
readonly generated: string;
|
||||
readonly local: string;
|
||||
readonly legacy?: string;
|
||||
readonly quarantinePath?: string;
|
||||
readonly diagnostics: readonly AgentEnvironmentDiagnostic[];
|
||||
}
|
||||
|
||||
export class AgentEnvBoundaryError extends Error {
|
||||
readonly diagnostic: AgentEnvironmentDiagnostic;
|
||||
|
||||
constructor(code: string, key: string, value: string) {
|
||||
const diagnostic: AgentEnvironmentDiagnostic = {
|
||||
code,
|
||||
key,
|
||||
sha256: hashValue(value),
|
||||
};
|
||||
super(`Agent environment rejected: code=${code} key=${key} sha256=${diagnostic.sha256}`);
|
||||
this.name = 'AgentEnvBoundaryError';
|
||||
this.diagnostic = diagnostic;
|
||||
}
|
||||
}
|
||||
|
||||
export const GENERATED_AGENT_ENV_KEYS = [
|
||||
'MOSAIC_AGENT_NAME',
|
||||
'MOSAIC_AGENT_CLASS',
|
||||
'MOSAIC_AGENT_RUNTIME',
|
||||
'MOSAIC_AGENT_MODEL',
|
||||
'MOSAIC_AGENT_REASONING',
|
||||
'MOSAIC_AGENT_TOOL_POLICY',
|
||||
'MOSAIC_AGENT_WORKDIR',
|
||||
'MOSAIC_TMUX_SOCKET',
|
||||
] as const;
|
||||
|
||||
export const LOCAL_AGENT_ENV_KEYS = [
|
||||
'MOSAIC_RUNTIME_BIN',
|
||||
'MOSAIC_HEARTBEAT_RUN_DIR',
|
||||
'MOSAIC_HEARTBEAT_INTERVAL',
|
||||
'MOSAIC_CLAUDE_JSON',
|
||||
'CLAUDE_CONFIG_DIR',
|
||||
] as const;
|
||||
|
||||
const GENERATED_KEY_SET = new Set<string>(GENERATED_AGENT_ENV_KEYS);
|
||||
const LOCAL_KEY_SET = new Set<string>(LOCAL_AGENT_ENV_KEYS);
|
||||
const SENSITIVE_KEY = /(?:API[_-]?KEY|AUTH|CREDENTIAL|PASSWORD|PRIVATE|SECRET|TOKEN)/i;
|
||||
const AGENT_NAME = /^[A-Za-z0-9][A-Za-z0-9_.-]*$/;
|
||||
const POLICY_NAME = /^[a-z][a-z0-9-]*$/;
|
||||
const TMUX_SOCKET = /^[A-Za-z0-9_.-]*$/;
|
||||
const MODEL = /^[A-Za-z0-9._/:+-]*$/;
|
||||
/** Runtime launchers supported by the generated environment contract. */
|
||||
export const GENERATED_AGENT_ENV_SUPPORTED_RUNTIMES = [
|
||||
'claude',
|
||||
'codex',
|
||||
'opencode',
|
||||
'pi',
|
||||
] as const;
|
||||
|
||||
const SUPPORTED_RUNTIMES = new Set<string>(GENERATED_AGENT_ENV_SUPPORTED_RUNTIMES);
|
||||
const REASONING = new Set(['', 'low', 'medium', 'high']);
|
||||
|
||||
/** Parses a strict data-only generated or local environment file without shell evaluation. */
|
||||
export function parseAgentEnvironment(
|
||||
source: string,
|
||||
kind: AgentEnvironmentKind,
|
||||
): Readonly<Record<string, string>> {
|
||||
const values: Record<string, string> = {};
|
||||
const seen = new Set<string>();
|
||||
|
||||
for (const line of source.split('\n')) {
|
||||
if (line === '') continue;
|
||||
const match = /^([A-Z][A-Z0-9_]*)=(.*)$/.exec(line);
|
||||
if (!match) throw new AgentEnvBoundaryError('malformed-line', '(malformed)', line);
|
||||
|
||||
const [, key, value] = match;
|
||||
if (key === undefined || value === undefined) {
|
||||
throw new AgentEnvBoundaryError('malformed-line', '(malformed)', line);
|
||||
}
|
||||
if (seen.has(key)) throw new AgentEnvBoundaryError('duplicate-key', key, value);
|
||||
seen.add(key);
|
||||
if (SENSITIVE_KEY.test(key)) throw new AgentEnvBoundaryError('sensitive-key', key, value);
|
||||
if (containsShellSyntax(value)) throw new AgentEnvBoundaryError('unsafe-value', key, value);
|
||||
|
||||
if (kind === 'generated') {
|
||||
if (!GENERATED_KEY_SET.has(key)) throw new AgentEnvBoundaryError('unknown-key', key, value);
|
||||
} else {
|
||||
if (GENERATED_KEY_SET.has(key))
|
||||
throw new AgentEnvBoundaryError('generated-key-shadow', key, value);
|
||||
if (!LOCAL_KEY_SET.has(key)) throw new AgentEnvBoundaryError('unknown-key', key, value);
|
||||
}
|
||||
values[key] = value;
|
||||
}
|
||||
|
||||
if (kind === 'generated') assertGeneratedValues(values);
|
||||
else assertLocalValues(values);
|
||||
return Object.freeze(values);
|
||||
}
|
||||
|
||||
/** Renders the roster-derived generated projection in a stable, complete key order. */
|
||||
export function renderGeneratedAgentEnvironment(values: Readonly<Record<string, string>>): string {
|
||||
const normalized = normalizeGeneratedValues(values);
|
||||
return `${GENERATED_AGENT_ENV_KEYS.map((key): string => `${key}=${normalized[key]}`).join('\n')}\n`;
|
||||
}
|
||||
|
||||
/** Validates all deterministic projection inputs and target state without mutation. */
|
||||
export async function prepareAgentEnvironmentProjection(
|
||||
options: AgentEnvironmentProjectionOptions,
|
||||
): Promise<PreparedAgentEnvironmentProjection> {
|
||||
if (!AGENT_NAME.test(options.agentName)) {
|
||||
throw new AgentEnvBoundaryError('unsafe-agent-name', 'MOSAIC_AGENT_NAME', options.agentName);
|
||||
}
|
||||
|
||||
await validatePrivateProjectionDirectory(options.mosaicHome, options.agentEnvDir);
|
||||
const generatedPath = join(options.agentEnvDir, `${options.agentName}.env.generated`);
|
||||
const localPath = join(options.agentEnvDir, `${options.agentName}.env.local`);
|
||||
const legacyPath = join(options.agentEnvDir, `${options.agentName}.env`);
|
||||
const generated = renderGeneratedAgentEnvironment(options.generated);
|
||||
await assertPrivateRegularFileIfPresent(generatedPath);
|
||||
const currentLocal = await readOptionalPrivateFile(localPath);
|
||||
const parsedLocal =
|
||||
currentLocal === undefined ? {} : parseAgentEnvironment(currentLocal, 'local');
|
||||
const legacy = await readOptionalPrivateFile(legacyPath);
|
||||
const legacyDisposition =
|
||||
legacy === undefined ? emptyLegacyDisposition() : classifyLegacyEnvironment(legacy);
|
||||
|
||||
for (const [key, value] of Object.entries(legacyDisposition.localValues)) {
|
||||
if (parsedLocal[key] !== undefined && parsedLocal[key] !== value) {
|
||||
throw new AgentEnvBoundaryError('legacy-local-conflict', key, value);
|
||||
}
|
||||
}
|
||||
|
||||
const local = renderLocalAgentEnvironment({ ...legacyDisposition.localValues, ...parsedLocal });
|
||||
const quarantinePath = legacyDisposition.diagnostics.length
|
||||
? join(options.agentEnvDir, `${options.agentName}.env.quarantine`)
|
||||
: undefined;
|
||||
if (quarantinePath !== undefined) {
|
||||
const existingQuarantine = await readOptionalPrivateFile(quarantinePath);
|
||||
if (existingQuarantine !== undefined) {
|
||||
throw new AgentEnvBoundaryError('quarantine-exists', '(quarantine)', existingQuarantine);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
mosaicHome: options.mosaicHome,
|
||||
agentEnvDir: options.agentEnvDir,
|
||||
generatedPath,
|
||||
localPath,
|
||||
legacyPath,
|
||||
generated,
|
||||
local,
|
||||
...(legacy === undefined ? {} : { legacy }),
|
||||
...(quarantinePath === undefined ? {} : { quarantinePath }),
|
||||
diagnostics: legacyDisposition.diagnostics,
|
||||
};
|
||||
}
|
||||
|
||||
/** Applies a previously prepared deterministic projection. */
|
||||
export async function applyPreparedAgentEnvironmentProjection(
|
||||
prepared: PreparedAgentEnvironmentProjection,
|
||||
): Promise<AgentEnvironmentProjectionResult> {
|
||||
await ensurePrivateProjectionDirectory(prepared.mosaicHome, prepared.agentEnvDir);
|
||||
await writePrivateAtomically(prepared.generatedPath, prepared.generated);
|
||||
if (prepared.local !== '') await writePrivateAtomically(prepared.localPath, prepared.local);
|
||||
if (prepared.quarantinePath !== undefined && prepared.legacy !== undefined) {
|
||||
await writePrivateAtomically(prepared.quarantinePath, prepared.legacy);
|
||||
}
|
||||
if (prepared.legacy !== undefined) await unlink(prepared.legacyPath);
|
||||
|
||||
return {
|
||||
generatedPath: prepared.generatedPath,
|
||||
localPath: prepared.localPath,
|
||||
...(prepared.quarantinePath === undefined ? {} : { quarantinePath: prepared.quarantinePath }),
|
||||
diagnostics: prepared.diagnostics,
|
||||
};
|
||||
}
|
||||
|
||||
/** Writes deterministic projection files and explicitly removes legacy `.env` authority. */
|
||||
export async function writeAgentEnvironmentProjection(
|
||||
options: AgentEnvironmentProjectionOptions,
|
||||
): Promise<AgentEnvironmentProjectionResult> {
|
||||
return applyPreparedAgentEnvironmentProjection(await prepareAgentEnvironmentProjection(options));
|
||||
}
|
||||
|
||||
/** Creates the canonical managed roster path with private fresh-chain permissions. */
|
||||
export async function writeManagedFleetRoster(
|
||||
mosaicHome: string,
|
||||
rosterPath: string,
|
||||
content: string,
|
||||
): Promise<void> {
|
||||
const fleetDir = join(mosaicHome, 'fleet');
|
||||
const expectedRosterPath = join(fleetDir, 'roster.yaml');
|
||||
if (resolve(rosterPath) !== resolve(expectedRosterPath)) {
|
||||
throw new AgentEnvBoundaryError('unsafe-file', '(roster)', rosterPath);
|
||||
}
|
||||
await ensureManagedDirectory(mosaicHome, false);
|
||||
await ensureManagedDirectory(fleetDir, false);
|
||||
await writePrivateAtomically(rosterPath, content);
|
||||
}
|
||||
|
||||
/** Ensures the private install-derived identity used to own a named tmux server. */
|
||||
export async function ensureFleetHolderIdentity(mosaicHome: string): Promise<string> {
|
||||
const fleetDir = join(mosaicHome, 'fleet');
|
||||
const runDir = join(fleetDir, 'run');
|
||||
const identityPath = join(runDir, 'holder-owner');
|
||||
await ensureManagedDirectory(mosaicHome, false);
|
||||
await ensureManagedDirectory(fleetDir, false);
|
||||
await ensureManagedDirectory(runDir, true);
|
||||
const existing = await readOptionalPrivateFile(identityPath);
|
||||
if (existing !== undefined) {
|
||||
const identity = existing.trim();
|
||||
if (!/^[a-f0-9-]{36}$/.test(identity)) {
|
||||
throw new AgentEnvBoundaryError('unsafe-owner-identity', '(holder-owner)', existing);
|
||||
}
|
||||
return identity;
|
||||
}
|
||||
const identity = randomUUID();
|
||||
await writePrivateAtomically(identityPath, `${identity}\n`);
|
||||
return identity;
|
||||
}
|
||||
|
||||
function normalizeGeneratedValues(
|
||||
values: Readonly<Record<string, string>>,
|
||||
): Readonly<Record<string, string>> {
|
||||
const normalized: Record<string, string> = {};
|
||||
for (const key of GENERATED_AGENT_ENV_KEYS) {
|
||||
const value = values[key];
|
||||
if (value === undefined) throw new AgentEnvBoundaryError('missing-key', key, '');
|
||||
normalized[key] = value;
|
||||
}
|
||||
for (const [key, value] of Object.entries(values)) {
|
||||
if (!GENERATED_KEY_SET.has(key)) throw new AgentEnvBoundaryError('unknown-key', key, value);
|
||||
}
|
||||
assertGeneratedValues(normalized);
|
||||
return Object.freeze(normalized);
|
||||
}
|
||||
|
||||
function renderLocalAgentEnvironment(values: Readonly<Record<string, string>>): string {
|
||||
if (Object.keys(values).length === 0) return '';
|
||||
const parsed = parseAgentEnvironment(
|
||||
Object.entries(values)
|
||||
.sort(([left], [right]): number => left.localeCompare(right))
|
||||
.map(([key, value]): string => `${key}=${value}`)
|
||||
.join('\n'),
|
||||
'local',
|
||||
);
|
||||
return `${Object.entries(parsed)
|
||||
.sort(([left], [right]): number => left.localeCompare(right))
|
||||
.map(([key, value]): string => `${key}=${value}`)
|
||||
.join('\n')}\n`;
|
||||
}
|
||||
|
||||
function assertGeneratedValues(values: Readonly<Record<string, string>>): void {
|
||||
for (const key of GENERATED_AGENT_ENV_KEYS) {
|
||||
const value = values[key];
|
||||
if (value === undefined) throw new AgentEnvBoundaryError('missing-key', key, '');
|
||||
}
|
||||
const name = requiredGeneratedValue(values, 'MOSAIC_AGENT_NAME');
|
||||
const className = requiredGeneratedValue(values, 'MOSAIC_AGENT_CLASS');
|
||||
const runtime = requiredGeneratedValue(values, 'MOSAIC_AGENT_RUNTIME');
|
||||
const model = requiredGeneratedValue(values, 'MOSAIC_AGENT_MODEL');
|
||||
const reasoning = requiredGeneratedValue(values, 'MOSAIC_AGENT_REASONING');
|
||||
const toolPolicy = requiredGeneratedValue(values, 'MOSAIC_AGENT_TOOL_POLICY');
|
||||
const workingDirectory = requiredGeneratedValue(values, 'MOSAIC_AGENT_WORKDIR');
|
||||
const socket = requiredGeneratedValue(values, 'MOSAIC_TMUX_SOCKET');
|
||||
|
||||
if (!AGENT_NAME.test(name))
|
||||
throw new AgentEnvBoundaryError('unsafe-agent-name', 'MOSAIC_AGENT_NAME', name);
|
||||
if (!POLICY_NAME.test(className)) {
|
||||
throw new AgentEnvBoundaryError('unsafe-class', 'MOSAIC_AGENT_CLASS', className);
|
||||
}
|
||||
if (!SUPPORTED_RUNTIMES.has(runtime)) {
|
||||
throw new AgentEnvBoundaryError('unsupported-runtime', 'MOSAIC_AGENT_RUNTIME', runtime);
|
||||
}
|
||||
if (!MODEL.test(model))
|
||||
throw new AgentEnvBoundaryError('unsafe-model', 'MOSAIC_AGENT_MODEL', model);
|
||||
if (!REASONING.has(reasoning)) {
|
||||
throw new AgentEnvBoundaryError('unsupported-reasoning', 'MOSAIC_AGENT_REASONING', reasoning);
|
||||
}
|
||||
if (toolPolicy !== '' && !POLICY_NAME.test(toolPolicy)) {
|
||||
throw new AgentEnvBoundaryError('unsafe-tool-policy', 'MOSAIC_AGENT_TOOL_POLICY', toolPolicy);
|
||||
}
|
||||
if (!isSafeAbsolutePath(workingDirectory)) {
|
||||
throw new AgentEnvBoundaryError('unsafe-path', 'MOSAIC_AGENT_WORKDIR', workingDirectory);
|
||||
}
|
||||
if (!TMUX_SOCKET.test(socket)) {
|
||||
throw new AgentEnvBoundaryError('unsafe-socket', 'MOSAIC_TMUX_SOCKET', socket);
|
||||
}
|
||||
}
|
||||
|
||||
function requiredGeneratedValue(values: Readonly<Record<string, string>>, key: string): string {
|
||||
const value = values[key];
|
||||
if (value === undefined) throw new AgentEnvBoundaryError('missing-key', key, '');
|
||||
return value;
|
||||
}
|
||||
|
||||
function assertLocalValues(values: Readonly<Record<string, string>>): void {
|
||||
for (const [key, value] of Object.entries(values)) {
|
||||
if (key === 'MOSAIC_HEARTBEAT_INTERVAL') {
|
||||
if (!/^[1-9][0-9]*$/.test(value)) {
|
||||
throw new AgentEnvBoundaryError('invalid-interval', key, value);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (!isSafeAbsolutePath(value)) throw new AgentEnvBoundaryError('unsafe-path', key, value);
|
||||
}
|
||||
}
|
||||
|
||||
function isSafeAbsolutePath(value: string): boolean {
|
||||
return (
|
||||
value.startsWith('/') && !value.split('/').includes('..') && !/[\s"'`$\\;&|<>(){}]/.test(value)
|
||||
);
|
||||
}
|
||||
|
||||
function containsShellSyntax(value: string): boolean {
|
||||
return /["'`$\\;&|<>(){}]/.test(value);
|
||||
}
|
||||
|
||||
interface LegacyDisposition {
|
||||
readonly localValues: Readonly<Record<string, string>>;
|
||||
readonly diagnostics: readonly AgentEnvironmentDiagnostic[];
|
||||
}
|
||||
|
||||
function emptyLegacyDisposition(): LegacyDisposition {
|
||||
return { localValues: {}, diagnostics: [] };
|
||||
}
|
||||
|
||||
function classifyLegacyEnvironment(source: string): LegacyDisposition {
|
||||
const localValues: Record<string, string> = {};
|
||||
const diagnostics: AgentEnvironmentDiagnostic[] = [];
|
||||
const seen = new Set<string>();
|
||||
|
||||
for (const line of source.split('\n')) {
|
||||
if (line === '') continue;
|
||||
const match = /^([A-Z][A-Z0-9_]*)=(.*)$/.exec(line);
|
||||
if (!match || match[1] === undefined || match[2] === undefined) {
|
||||
diagnostics.push(diagnostic('malformed-line', '(malformed)', line));
|
||||
continue;
|
||||
}
|
||||
const [, key, value] = match;
|
||||
if (seen.has(key)) {
|
||||
diagnostics.push(diagnostic('duplicate-key', key, value));
|
||||
continue;
|
||||
}
|
||||
seen.add(key);
|
||||
if (GENERATED_KEY_SET.has(key)) continue;
|
||||
try {
|
||||
parseAgentEnvironment(`${key}=${value}\n`, 'local');
|
||||
localValues[key] = value;
|
||||
} catch (error: unknown) {
|
||||
if (error instanceof AgentEnvBoundaryError) diagnostics.push(error.diagnostic);
|
||||
else throw error;
|
||||
}
|
||||
}
|
||||
|
||||
return { localValues: Object.freeze(localValues), diagnostics: Object.freeze(diagnostics) };
|
||||
}
|
||||
|
||||
function diagnostic(code: string, key: string, value: string): AgentEnvironmentDiagnostic {
|
||||
return { code, key, sha256: hashValue(value) };
|
||||
}
|
||||
|
||||
function hashValue(value: string): string {
|
||||
return createHash('sha256').update(value).digest('hex');
|
||||
}
|
||||
|
||||
async function readOptionalPrivateFile(path: string): Promise<string | undefined> {
|
||||
try {
|
||||
await assertPrivateRegularFile(path);
|
||||
return await readFile(path, 'utf8');
|
||||
} catch (error: unknown) {
|
||||
if (isMissingFile(error)) return undefined;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
function isMissingFile(error: unknown): boolean {
|
||||
return typeof error === 'object' && error !== null && 'code' in error && error.code === 'ENOENT';
|
||||
}
|
||||
|
||||
async function validatePrivateProjectionDirectory(
|
||||
mosaicHome: string,
|
||||
agentEnvDir: string,
|
||||
): Promise<void> {
|
||||
const fleetDir = join(mosaicHome, 'fleet');
|
||||
const expectedAgentEnvDir = join(fleetDir, 'agents');
|
||||
if (resolve(agentEnvDir) !== resolve(expectedAgentEnvDir)) {
|
||||
throw new AgentEnvBoundaryError('unsafe-directory', '(directory)', agentEnvDir);
|
||||
}
|
||||
await assertManagedDirectoryIfPresent(mosaicHome, false);
|
||||
await assertManagedDirectoryIfPresent(fleetDir, false);
|
||||
await assertManagedDirectoryIfPresent(agentEnvDir, true);
|
||||
}
|
||||
|
||||
async function ensurePrivateProjectionDirectory(
|
||||
mosaicHome: string,
|
||||
agentEnvDir: string,
|
||||
): Promise<void> {
|
||||
await validatePrivateProjectionDirectory(mosaicHome, agentEnvDir);
|
||||
const fleetDir = join(mosaicHome, 'fleet');
|
||||
await ensureManagedDirectory(mosaicHome, false);
|
||||
await ensureManagedDirectory(fleetDir, false);
|
||||
await ensureManagedDirectory(agentEnvDir, true);
|
||||
}
|
||||
|
||||
async function ensureManagedDirectory(path: string, privateDirectory: boolean): Promise<void> {
|
||||
let created = false;
|
||||
try {
|
||||
await lstat(path);
|
||||
} catch (error: unknown) {
|
||||
if (!isMissingFile(error)) throw error;
|
||||
try {
|
||||
await mkdir(path, { recursive: true, mode: 0o700 });
|
||||
created = true;
|
||||
} catch (mkdirError: unknown) {
|
||||
if (!isAlreadyExists(mkdirError)) throw mkdirError;
|
||||
}
|
||||
}
|
||||
|
||||
await assertManagedDirectory(path, privateDirectory);
|
||||
if (created) {
|
||||
await chmod(path, 0o700);
|
||||
await assertManagedDirectory(path, privateDirectory);
|
||||
}
|
||||
}
|
||||
|
||||
function isAlreadyExists(error: unknown): boolean {
|
||||
return typeof error === 'object' && error !== null && 'code' in error && error.code === 'EEXIST';
|
||||
}
|
||||
|
||||
async function assertManagedDirectoryIfPresent(
|
||||
path: string,
|
||||
privateDirectory: boolean,
|
||||
): Promise<void> {
|
||||
try {
|
||||
await assertManagedDirectory(path, privateDirectory);
|
||||
} catch (error: unknown) {
|
||||
if (isMissingFile(error)) return;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async function assertManagedDirectory(path: string, privateDirectory: boolean): Promise<void> {
|
||||
const metadata = await lstat(path);
|
||||
if (!metadata.isDirectory()) {
|
||||
throw new AgentEnvBoundaryError('unsafe-directory', '(directory)', path);
|
||||
}
|
||||
if ((metadata.mode & 0o022) !== 0 || (privateDirectory && (metadata.mode & 0o077) !== 0)) {
|
||||
throw new AgentEnvBoundaryError('unsafe-permissions', '(directory)', path);
|
||||
}
|
||||
}
|
||||
|
||||
async function assertPrivateRegularFileIfPresent(path: string): Promise<void> {
|
||||
try {
|
||||
await assertPrivateRegularFile(path);
|
||||
} catch (error: unknown) {
|
||||
if (isMissingFile(error)) return;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async function assertPrivateRegularFile(path: string): Promise<void> {
|
||||
const metadata = await lstat(path);
|
||||
if (!metadata.isFile()) throw new AgentEnvBoundaryError('unsafe-file', '(file)', path);
|
||||
if ((metadata.mode & 0o077) !== 0) {
|
||||
throw new AgentEnvBoundaryError('unsafe-permissions', '(file)', path);
|
||||
}
|
||||
}
|
||||
|
||||
async function writePrivateAtomically(path: string, content: string): Promise<void> {
|
||||
try {
|
||||
await assertPrivateRegularFile(path);
|
||||
} catch (error: unknown) {
|
||||
if (!isMissingFile(error)) throw error;
|
||||
}
|
||||
const temporaryPath = join(dirname(path), `.${randomUUID()}.tmp`);
|
||||
await writeFile(temporaryPath, content, { encoding: 'utf8', mode: 0o600, flag: 'wx' });
|
||||
await rename(temporaryPath, path);
|
||||
}
|
||||
Reference in New Issue
Block a user