SPB Git

Repository Public

sandbox.ts 4.1 KB 98 lines typescript

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
Files main
Menu

Theme