// Sandburg status report generation for the /sandburg command import type { ExtensionAPI } from "@earendil-works/pi-coding-agent"; import { existsSync, readFileSync } from "fs"; import { fileURLToPath } from "url"; import { ACTIVE_MARKER, checkBubblewrapUsable, checkSandburgHelpers, } from "./helpers.js"; import { PRIVATE_ROOTS, getExtraPrivatePathViolations } from "./private-roots.js"; import { SANDBURG_DISABLE_PROPAGATION_ENV, describeRealPiInvocation, getSandburgRuntimeState, } from "./runtime-state.js"; type ToolInfo = ReturnType[number]; type MountInfoEntry = { mountPoint: string; root: string; options: string[]; fsType: string; }; const VIRTUAL_FILESYSTEM_TYPES = new Set([ "autofs", "cgroup", "cgroup2", "configfs", "debugfs", "devpts", "devtmpfs", "fusectl", "mqueue", "proc", "pstore", "securityfs", "sysfs", "tmpfs", "tracefs", ]); const SANDBURG_TRUSTED_EXTENSIONS_ENV = "SANDBURG_TRUSTED_EXTENSIONS"; const SANDBURG_TOOL_NAMES = ["bash", "grep", "read", "write", "edit"]; const SANDBURG_REDEFINED_TOOL_NAMES = ["bash", "read", "write", "edit"]; const BUILTIN_DISCOVERY_TOOL_NAMES = ["find", "ls"]; const BUILTIN_TOOL_NAMES = ["grep", ...BUILTIN_DISCOVERY_TOOL_NAMES]; const KNOWN_TOOL_NAME_SET = new Set([...SANDBURG_TOOL_NAMES, ...BUILTIN_DISCOVERY_TOOL_NAMES]); // Pi tool-model assumptions relied on by the setup check below: // // - Pi starts with built-in tools in the registry. // - An extension can replace a built-in tool by registering a tool with the // same name. // - Extension tools are de-duplicated by name in extension load order: the // first extension owning a registered tool name wins among extension tools. // - Therefore, once sandburg’s replacements for bash/read/write/edit are the // effective tools after load/reload, later-loaded extensions cannot silently // replace them during the same extension runtime. // - A previously loaded extension that deliberately registers a protected name // later at runtime is trusted dynamic extension behavior; sandburg does not // continuously police that with per-call setup checks. // - Newly introduced tools with different names may still appear and become // active; those are user-controlled extensions, not sandburg setup failures. // We warn about them at load/reload and in /sandburg status. // // If Pi’s tool registration or override semantics change, re-audit this file // and index.ts before relying on a startup setup check being stable for the // whole session. function readProcFile(path: string): string | undefined { try { return readFileSync(path, "utf-8"); } catch { return undefined; } } function decodeMountField(value: string): string { return value.replace(/\\([0-7]{3})/g, (_match, octal: string) => String.fromCharCode(parseInt(octal, 8))); } function parseMountInfo(content: string | undefined): MountInfoEntry[] { if (!content) return []; const mounts: MountInfoEntry[] = []; for (const line of content.split("\n")) { if (!line.trim()) continue; const fields = line.split(" "); const separator = fields.indexOf("-"); if (separator < 0 || fields.length <= separator + 3) continue; mounts.push({ root: decodeMountField(fields[3]), mountPoint: decodeMountField(fields[4]), options: fields[5].split(","), fsType: decodeMountField(fields[separator + 1]), }); } return mounts; } function parseProcStatus(content: string | undefined): Record { const status: Record = {}; for (const line of content?.split("\n") ?? []) { const match = /^(\w+):\s*(.*)$/.exec(line); if (match) status[match[1]] = match[2]; } return status; } function isReadOnlyMount(mount: MountInfoEntry): boolean { return mount.options.includes("ro"); } function isVirtualMount(mount: MountInfoEntry): boolean { return VIRTUAL_FILESYSTEM_TYPES.has(mount.fsType); } function isZeroCapability(value: string | undefined): boolean { return value !== undefined && /^0+$/.test(value); } function isInitialUserNamespaceMap(map: string | undefined): boolean { return /^\s*0\s+0\s+4294967295\s*$/.test(map?.trim() ?? ""); } function isBroadHostExposureTarget(mountPoint: string): boolean { return ( mountPoint === "/" || mountPoint === "/home" || /^\/home\/[^/]+$/.test(mountPoint) || mountPoint === "/etc" || mountPoint === "/var" ); } function isSandburgToolSource(tool: ToolInfo): boolean { const path = tool.sourceInfo.path.replace(/\\/g, "/"); return ( path.endsWith("/sandburg") || path === "sandburg" || path.endsWith("/sandburg/index.ts") || path === "sandburg/index.ts" ); } function isBuiltinToolSource(tool: ToolInfo, name: string): boolean { return tool.sourceInfo.source === "builtin" && tool.sourceInfo.path === ``; } function appendPathList(lines: string[], label: string, paths: string[]) { if (paths.length === 0) { lines.push(`- ${label}: (none)`); return; } lines.push(`- ${label}:`); for (const path of paths) lines.push(` - ${path}`); } function sortedToolNames(names: Iterable, order?: string[]): string[] { const orderIndex = new Map(order?.map((name, index) => [name, index]) ?? []); return Array.from(new Set(names)).sort( (a, b) => (orderIndex.get(a) ?? Number.MAX_SAFE_INTEGER) - (orderIndex.get(b) ?? Number.MAX_SAFE_INTEGER) || a.localeCompare(b), ); } export type SandburgCheckStatus = { valid: boolean; violations: string[]; }; export type SandburgStatus = { text: string; severity: "success" | "warning"; }; export type OuterSandboxStatus = { namespaceSandboxDetected: boolean; hostWritableMounts: string[]; broadHostExposures: string[]; }; function getProbableReadmePath(): string | undefined { try { const path = fileURLToPath(new URL("../../README.md", import.meta.url)); return existsSync(path) ? path : undefined; } catch { return undefined; } } function getOuterSandboxMissingWarning(): string { const readmePath = getProbableReadmePath(); return [ "No outer sandbox for the Pi process detected.", " For effective protection, Sandburg must run inside Sandkasten or an equivalent outer sandbox.", readmePath ? ` Consult the documentation:\n ${readmePath}` : " Consult the documentation for Sandburg setup details.", ].join("\n"); } function describeToolSource(tool: ToolInfo): string { const path = tool.sourceInfo.path; switch (tool.sourceInfo.source) { case "auto": return `auto-loaded extension: ${path}`; case "local": return `local extension: ${path}`; case "cli": return `CLI extension: ${path}`; case "builtin": return `Pi built-in tool: ${path}`; default: return `${tool.sourceInfo.source} extension: ${path}`; } } function describeAdditionalToolRecord(record: AdditionalActiveToolRecord): string { return `${record.name} — ${record.trust.key}`; } function appendAdditionalToolRecords(lines: string[], label: string, records: AdditionalActiveToolRecord[]) { if (records.length === 0) { lines.push(`- ${label}: (none)`); return; } lines.push(`- ${label}:`); for (const record of records) lines.push(` - ${describeAdditionalToolRecord(record)}`); } function toolByName(pi: ExtensionAPI): Map { return new Map(pi.getAllTools().map((tool) => [tool.name, tool])); } type ExtensionToolTrust = { key: string; trusted: boolean; }; function parseTrustedExtensionKeys(value = process.env[SANDBURG_TRUSTED_EXTENSIONS_ENV]): Set { return new Set( (value ?? "") .split(",") .map((entry) => entry.trim()) .filter(Boolean), ); } function getExtensionToolTrust(sourceInfo: ToolInfo["sourceInfo"], trustedKeys: Set): ExtensionToolTrust { const key = sourceInfo.origin === "package" ? sourceInfo.source : sourceInfo.path; return { key, trusted: trustedKeys.has(key) }; } export type AdditionalActiveToolRecord = { name: string; sourceInfo: ToolInfo["sourceInfo"]; trust: ExtensionToolTrust; }; export function getAdditionalActiveToolRecords(pi: ExtensionAPI): AdditionalActiveToolRecord[] { const allTools = pi.getAllTools(); const enabledToolNames = new Set(pi.getActiveTools()); const trustedKeys = parseTrustedExtensionKeys(); const records = allTools .filter((tool) => enabledToolNames.has(tool.name) && !KNOWN_TOOL_NAME_SET.has(tool.name)) .map((tool) => ({ name: tool.name, sourceInfo: tool.sourceInfo, trust: getExtensionToolTrust(tool.sourceInfo, trustedKeys), })); const order = sortedToolNames(records.map((record) => record.name)); const orderIndex = new Map(order.map((name, index) => [name, index])); return records.sort((a, b) => (orderIndex.get(a.name) ?? 0) - (orderIndex.get(b.name) ?? 0)); } export function getAdditionalActiveToolNames(pi: ExtensionAPI): string[] { return getAdditionalActiveToolRecords(pi).map((record) => record.name); } export function getUntrustedAdditionalActiveToolRecords(pi: ExtensionAPI): AdditionalActiveToolRecord[] { return getAdditionalActiveToolRecords(pi).filter((record) => !record.trust.trusted); } export function getAdditionalActiveToolsWarning(pi: ExtensionAPI): string | undefined { const records = getUntrustedAdditionalActiveToolRecords(pi); if (records.length === 0) return undefined; return [ "Additional tools are active outside Sandburg's built-in-tool sandbox.", " Extension tools run as trusted same-process code and remain enabled.", ` Set ${SANDBURG_TRUSTED_EXTENSIONS_ENV} to suppress this warning for intentionally trusted extensions.`, " Active untrusted tools:", ...records.map((record) => ` - ${record.name}`), ].join("\n"); } export function getOuterSandboxStatus(): OuterSandboxStatus { const mounts = parseMountInfo(readProcFile("/proc/self/mountinfo")); const status = parseProcStatus(readProcFile("/proc/self/status")); const uidMap = readProcFile("/proc/self/uid_map")?.trim(); const gidMap = readProcFile("/proc/self/gid_map")?.trim(); const rootMount = mounts.find((mount) => mount.mountPoint === "/"); const noNewPrivs = status.NoNewPrivs === "1"; const capsNone = ["CapInh", "CapPrm", "CapEff", "CapBnd", "CapAmb"].every((name) => isZeroCapability(status[name])); const userNamespaceMapped = (uidMap !== undefined && !isInitialUserNamespaceMap(uidMap)) || (gidMap !== undefined && !isInitialUserNamespaceMap(gidMap)); const namespaceSandboxDetected = userNamespaceMapped || noNewPrivs || capsNone || (rootMount?.fsType === "tmpfs" && rootMount.root !== "/"); const sortedNonVirtualMounts = mounts .filter((mount) => !isVirtualMount(mount)) .sort((a, b) => a.mountPoint.localeCompare(b.mountPoint)); const hostWritableMounts = sortedNonVirtualMounts .filter((mount) => mount.fsType !== "overlay" && !isReadOnlyMount(mount)) .map((mount) => mount.mountPoint); const broadHostExposures = sortedNonVirtualMounts .filter((mount) => isBroadHostExposureTarget(mount.mountPoint)) .map((mount) => { const mode = mount.fsType === "overlay" ? "tmp overlay" : isReadOnlyMount(mount) ? "read-only" : "writable"; return `${mount.mountPoint} (${mode})`; }); return { namespaceSandboxDetected, hostWritableMounts, broadHostExposures }; } export function getOuterSandboxStartupWarnings(outerSandbox = getOuterSandboxStatus()): string[] { return [ !outerSandbox.namespaceSandboxDetected && getOuterSandboxMissingWarning(), outerSandbox.broadHostExposures.length > 0 && "Broad host exposure detected. Run /sandburg for details.", ].filter(Boolean) as string[]; } export function checkSandburgSetup(pi: ExtensionAPI, setupViolations: string[] = []): SandburgCheckStatus { const tools = toolByName(pi); const violations = new Set(setupViolations); if (process.env.SANDBURG_ACTIVE !== ACTIVE_MARKER) { violations.add("sandburg extension marker is not active."); } for (const violation of checkSandburgHelpers()) violations.add(violation); for (const violation of checkBubblewrapUsable()) violations.add(violation); for (const violation of getExtraPrivatePathViolations()) violations.add(violation); for (const name of SANDBURG_REDEFINED_TOOL_NAMES) { const tool = tools.get(name); if (tool && !isSandburgToolSource(tool)) { violations.add(`${name}: expected sandburg-managed tool; found ${describeToolSource(tool)}.`); } } for (const name of BUILTIN_TOOL_NAMES) { const tool = tools.get(name); if (tool && !isBuiltinToolSource(tool, name)) { violations.add(`${name}: expected Pi built-in tool; found ${describeToolSource(tool)}.`); } } const violationList = [...violations]; return { valid: violationList.length === 0, violations: violationList }; } export function buildSandburgStatus(pi: ExtensionAPI, sandburgCheck = checkSandburgSetup(pi), toolsDisabledUntilReload = false): SandburgStatus { const outerSandbox = getOuterSandboxStatus(); const additionalActiveToolsWarning = getAdditionalActiveToolsWarning(pi); const additionalActiveToolRecords = getAdditionalActiveToolRecords(pi); const trustedAdditionalTools = additionalActiveToolRecords.filter((record) => record.trust.trusted); const untrustedAdditionalTools = additionalActiveToolRecords.filter((record) => !record.trust.trusted); const trustedExtensionKeys = [...parseTrustedExtensionKeys()].sort(); const runtimeState = getSandburgRuntimeState(); const unknownDisableTokens = runtimeState.propagationDisable.unknownTokens; const disabledPropagation = [...runtimeState.propagationDisable.disabled].sort(); const piWrapper = runtimeState.piWrapperPropagation; const sdkPropagation = runtimeState.sdkPropagation; const warnings = [ !sandburgCheck.valid && (toolsDisabledUntilReload ? "Sandburg setup is invalid, so all tools are disabled until /reload." : "Sandburg setup is invalid."), sandburgCheck.valid && toolsDisabledUntilReload && "All tools are disabled until /reload.", additionalActiveToolsWarning, unknownDisableTokens.length > 0 && `Unknown ${SANDBURG_DISABLE_PROPAGATION_ENV} token(s): ${unknownDisableTokens.join(", ")}`, piWrapper.status === "unavailable" && `Nested Pi wrapper propagation is unavailable: ${piWrapper.violations.join("; ")}`, sdkPropagation.status === "unavailable" && `Nested Pi SDK session propagation is unavailable: ${sdkPropagation.violations.join("; ")}`, !outerSandbox.namespaceSandboxDetected && getOuterSandboxMissingWarning(), outerSandbox.broadHostExposures.length > 0 && "Broad host exposure detected.", ].filter(Boolean) as string[]; const privateRoots = PRIVATE_ROOTS; const lines = [warnings.length === 0 ? "Sandburg: OK" : "Check sandburg setup"]; if (warnings.length > 0) { lines.push(""); for (const warning of warnings) lines.push(`- ${warning}`); } if (sandburgCheck.violations.length > 0) { lines.push("", "Sandburg setup violations"); for (const violation of sandburgCheck.violations) lines.push(`- ${violation}`); } lines.push("", "Outer sandbox for the Pi process"); if (outerSandbox.namespaceSandboxDetected) { appendPathList(lines, "host-writable mounts", outerSandbox.hostWritableMounts); if (outerSandbox.broadHostExposures.length > 0) appendPathList(lines, "broad host exposure", outerSandbox.broadHostExposures); } else if (outerSandbox.broadHostExposures.length > 0) { appendPathList(lines, "broad host exposure", outerSandbox.broadHostExposures); } else { lines.push("- not detected"); } lines.push( "", "Agent tool restrictions", "- network disabled", "- private Pi/Sandburg state roots are hidden from sandboxed subprocess tools and denied to read/write/edit:", " (`ls` and `find` may still show file and directory names.)", ...privateRoots.map((path) => ` - ${path}`), ); if (additionalActiveToolRecords.length > 0 || trustedExtensionKeys.length > 0) { lines.push("", "Extension tools outside Sandburg's built-in-tool sandbox"); appendAdditionalToolRecords(lines, "trusted", trustedAdditionalTools); appendAdditionalToolRecords(lines, "untrusted", untrustedAdditionalTools); appendPathList(lines, `trusted extension keys (${SANDBURG_TRUSTED_EXTENSIONS_ENV})`, trustedExtensionKeys); } lines.push( "", "Nested Pi propagation", `- managed pi wrapper: ${piWrapper.status}${piWrapper.path ? ` (${piWrapper.path})` : ""}`, `- process PATH updated: ${piWrapper.pathUpdated ? "yes" : "no"}`, `- first pi on PATH: ${piWrapper.firstPiOnPath ?? "(not found)"}${piWrapper.firstPiOnPath ? (piWrapper.firstPiOnPathManaged ? " (Sandburg-managed)" : " (not Sandburg-managed)") : ""}`, `- real Pi invocation: ${describeRealPiInvocation(runtimeState.realPiInvocation, runtimeState.realPiInvocationUnavailableReason)}`, `- propagated Sandburg extension: ${runtimeState.resolvedSandburgExtensionPath ?? "(not initialized)"}`, `- SDK session propagation: ${sdkPropagation.status}`, "- argv[1] propagation: not enabled", `- propagation disabled: ${disabledPropagation.length > 0 ? disabledPropagation.join(", ") : "(none)"}`, ); return { text: lines.join("\n"), severity: warnings.length === 0 ? "success" : "warning" }; }