Repository Public
1/**2 * What the sandboxed shell must not read beyond the built-in secret locations: every sibling directory3 * of the mission workspace under AOS_WORKSPACE_ROOT (other projects), the owner's ~/Desktop, ~/Documents4 * and ~/Downloads, and the configured extra paths. Also the sweep of per-run temp directories.5 *6 * A directory that CONTAINS the workspace cannot be denied itself (tools must traverse it): its child7 * directories are denied instead, except the one leading to the workspace, recursively. Plain files8 * sitting directly in such an ancestor stay readable (documented limitation).9 */10import fs from 'node:fs/promises';11import os from 'node:os';12import path from 'node:path';13import type { Kernel } from '@aos/kernel';14import { isTerminal, type RunStatus } from '@aos/shared';15import { cleanupRunTmp } from '@aos/tools';1617const MAX_DENY_PATHS = 600;18// The Seatbelt profile builder refuses paths with control characters or longer than 900 bytes; skip them19// rather than make every shell call fail closed.20// eslint-disable-next-line no-control-regex21const usable = (p: string): boolean => !/[\u0000-\u001f\u007f]/.test(p) && Buffer.byteLength(p) <= 900;22const real = async (p: string): Promise<string | null> => fs.realpath(p).catch(() => null);23const within = (child: string, parent: string): boolean => child === parent || child.startsWith(parent + path.sep);2425async function denyAround(base: string, workspace: string, out: Set<string>): Promise<void> {26 if (out.size >= MAX_DENY_PATHS || within(base, workspace)) return; // the workspace itself or something inside it27 if (!within(workspace, base)) {28 if (usable(base)) out.add(base);29 return;30 }31 let entries: import('node:fs').Dirent[];32 try {33 entries = await fs.readdir(base, { withFileTypes: true });34 } catch {35 return;36 }37 for (const e of entries.sort((a, b) => (a.name < b.name ? -1 : 1))) {38 if (!e.isDirectory()) continue;39 await denyAround(path.join(base, e.name), workspace, out);40 }41}4243export interface DenyListOptions {44 workspaceAbs: string;45 workspaceRoot: string | null;46 extra: string[];47 ownerFolders: boolean;48 home?: string;49}5051export async function sandboxDenyRead(o: DenyListOptions): Promise<string[]> {52 const workspace = (await real(o.workspaceAbs)) ?? path.resolve(o.workspaceAbs);53 const out = new Set<string>();54 const bases: string[] = [];55 if (o.workspaceRoot) {56 const root = await real(o.workspaceRoot);57 if (root && root !== workspace && within(workspace, root)) bases.push(root); // only meaningful when it contains the workspace58 }59 if (o.ownerFolders) {60 const home = o.home ?? os.homedir();61 for (const d of ['Desktop', 'Documents', 'Downloads']) bases.push(path.join(home, d));62 }63 for (const p of o.extra) if (path.isAbsolute(p)) bases.push(p);64 for (const b of bases) {65 const rp = await real(b);66 if (rp) await denyAround(rp, workspace, out);67 }68 return [...out].sort();69}7071const RUN_DIR_RE = /^aos-run-([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})$/;7273/**74 * Removes the temp directory (and edit journal) of every run of THIS database that has reached a75 * terminal status. Covers the endings the run logic never sees (cancel while waiting, failure in the76 * kernel). Directories of unknown runs are left alone (another deployment may own them).77 */78export async function sweepRunTmp(k: Kernel): Promise<string[]> {79 const root = await fs.realpath(os.tmpdir());80 const ids = new Set<string>();81 for (const name of await fs.readdir(root).catch(() => [] as string[])) {82 const m = RUN_DIR_RE.exec(name);83 if (m?.[1]) ids.add(m[1]);84 }85 for (const name of await fs.readdir(path.join(k.dataDir, 'edit-journal')).catch(() => [] as string[])) {86 if (/^[0-9a-f-]{36}$/.test(name)) ids.add(name);87 }88 if (ids.size === 0) return [];89 const rows = await k.pool.query('SELECT id, status FROM runs WHERE id = ANY($1::uuid[])', [[...ids]]);90 const removed: string[] = [];91 for (const r of rows.rows as { id: string; status: RunStatus }[]) {92 if (!isTerminal(r.status)) continue;93 await cleanupRunTmp(r.id, k.dataDir).catch(() => undefined);94 removed.push(r.id);95 }96 return removed;97}98