import { existsSync } from "node:fs"; import { mkdtemp, rm, writeFile } from "node:fs/promises"; import { homedir, tmpdir } from "node:os"; import { dirname, join } from "node:path"; import type { PermissionConfig } from "./config.ts"; import { expandHome } from "./config.ts"; import type { Grant } from "./grants.ts"; export interface SandboxStatus { available: boolean; binaryPath?: string; error?: string; } export interface PreparedSandboxCommand { command: string; dispose(): Promise; } let cachedStatus: SandboxStatus | undefined; export async function sandboxStatus(): Promise { if (cachedStatus) return cachedStatus; try { const landstrip = (await import("@landstrip/landstrip")) as { binaryPath?: (platform?: string, arch?: string) => string; }; if (typeof landstrip.binaryPath !== "function") { return (cachedStatus = { available: false, error: "Landstrip binaryPath export is unavailable" }); } const binaryPath = landstrip.binaryPath(); if (!existsSync(binaryPath)) { return (cachedStatus = { available: false, error: `Landstrip binary not found at ${binaryPath}` }); } return (cachedStatus = { available: true, binaryPath }); } catch (error) { return (cachedStatus = { available: false, error: error instanceof Error ? error.message : String(error), }); } } function quote(value: string): string { return `'${value.replace(/'/g, `'\\''`)}'`; } function sandboxSensitivePatterns(patterns: readonly string[]): string[] { return patterns.map((pattern) => expandHome(pattern)); } export async function prepareSandboxCommand( command: string, config: PermissionConfig, workspaceRoots: readonly string[], sensitivePatterns: readonly string[], grants: readonly Grant[], ): Promise { if (!config.runtime.sandboxBash) return undefined; const status = await sandboxStatus(); if (!status.available || !status.binaryPath) { if (config.runtime.requireLandstrip) throw new Error(status.error ?? "Landstrip is unavailable"); return undefined; } const applicable = grants.filter( (grant) => grant.kind === "bash" && (grant.command === undefined || grant.command === command), ); const readGrants = applicable .filter((grant) => grant.capability === "read" || grant.capability === "write") .map((grant) => grant.pathPrefix); const writeGrants = applicable .filter((grant) => grant.capability === "write") .map((grant) => grant.pathPrefix); const home = homedir(); const policyDir = await mkdtemp(join(tmpdir(), "pi-permission-")); const policyPath = join(policyDir, "policy.json"); const sensitive = sandboxSensitivePatterns(sensitivePatterns); const scratch = process.platform === "win32" ? [] : ["/tmp", "/private/tmp"]; const workspaceProfile = config.profiles[config.zones.workspace.profile]; const externalProfile = config.profiles[config.zones.external.profile]; const sensitiveProfile = config.profiles[config.zones.sensitive.profile]; const allowRead = [ ...scratch, "/dev/null", join(home, ".gitconfig"), join(home, ".config", "git", "config"), ...readGrants, ]; const allowWrite = [...scratch, "/dev/null", ...writeGrants]; if (workspaceProfile.read === "allow") allowRead.push(...workspaceRoots); if (workspaceProfile.write === "allow") allowWrite.push(...workspaceRoots); if (externalProfile.read === "allow") allowRead.push("/"); if (externalProfile.write === "allow") allowWrite.push("/"); if (sensitiveProfile.read === "allow") allowRead.push(...sensitive); if (sensitiveProfile.write === "allow") allowWrite.push(...sensitive); const denyRead = [ ...(externalProfile.read === "allow" ? [] : [dirname(home)]), ...(sensitiveProfile.read === "allow" ? [] : sensitive), ]; const denyWrite = sensitiveProfile.write === "allow" ? [] : sensitive; const policy = { filesystem: { allowRead, denyRead, allowWrite, denyWrite, }, network: { allowNetwork: config.runtime.network, allowLocalBinding: false, allowAllUnixSockets: false, allowUnixSockets: [], httpProxyPort: null, }, windows: { appContainerMode: "standard", allowLoopback: false, }, }; await writeFile(policyPath, JSON.stringify(policy, null, 2), { encoding: "utf8", mode: 0o600 }); const wrapped = `${status.binaryPath} run -p ${quote(policyPath)} -- bash -c ${quote(command)}`; return { command: wrapped, async dispose() { await rm(policyDir, { recursive: true, force: true }); }, }; }