import { readFileSync } from "node:fs"; import { join } from "node:path"; import { writeJsonAtomic } from "../shared/atomic-json.ts"; import { SECURITY_FILE } from "../shared/constants.ts"; export const MUTATION_APPROVAL_VALUES = ["always", "never"] as const; export type MutationApproval = (typeof MUTATION_APPROVAL_VALUES)[number]; export const PACKAGE_OPERATIONS = [ "search", "info", "installed", "catalog", "index.status", "index.build", "updates", "check", "setup.plan", "security.read", "mirror", "setup.export", "setup.update", "install", "install_service", "restart_service", "reconcile_services", "setup.apply", "update", "update_all", "update.self", "remove", "resources.list", "resources.toggle", "security.write", "pi.status", "advisories.scan", "doctor", ] as const; export type PackageOperation = (typeof PACKAGE_OPERATIONS)[number]; export type PackageOperationClassification = "read" | "maintenance" | "code-execution" | "settings-mutation" | "security-mutation"; export interface SecuritySettings { mutationApproval: MutationApproval; } export interface SecuritySettingsPort { security(): Promise; setMutationApproval(value: MutationApproval, options?: { approved?: boolean }): Promise; } export interface PackagePermissionDecision { operation: PackageOperation; classification: PackageOperationClassification; approvalRequired: boolean; } export const DEFAULT_SECURITY_SETTINGS: SecuritySettings = { mutationApproval: "always" }; const CLASSIFICATIONS: Record = { search: "read", info: "read", installed: "read", catalog: "read", "index.status": "read", "index.build": "maintenance", updates: "read", check: "read", "setup.plan": "read", "security.read": "read", mirror: "maintenance", "setup.export": "maintenance", "setup.update": "maintenance", install: "code-execution", install_service: "code-execution", restart_service: "code-execution", reconcile_services: "code-execution", "setup.apply": "code-execution", update: "code-execution", update_all: "code-execution", "update.self": "code-execution", remove: "settings-mutation", "resources.list": "read", "resources.toggle": "settings-mutation", "security.write": "security-mutation", "pi.status": "read", "advisories.scan": "read", doctor: "read", }; export class PackageApprovalRequiredError extends Error { readonly code = "approval_required"; constructor(readonly operation: PackageOperation) { super(`approval required for package operation ${operation}`); this.name = "PackageApprovalRequiredError"; } } export function packageOperationClassification(operation: PackageOperation): PackageOperationClassification { return CLASSIFICATIONS[operation]; } export function packagePermissionDecision(settings: SecuritySettings, operation: PackageOperation): PackagePermissionDecision { const classification = packageOperationClassification(operation); const guarded = classification === "code-execution" || classification === "settings-mutation" || classification === "security-mutation"; return { operation, classification, approvalRequired: guarded && settings.mutationApproval === "always" }; } export function assertPackagePermission(settings: SecuritySettings, operation: PackageOperation, approved = false): void { if (packagePermissionDecision(settings, operation).approvalRequired && !approved) { throw new PackageApprovalRequiredError(operation); } } export function readSecuritySettings(stateDir: string): SecuritySettings { try { const value = JSON.parse(readFileSync(join(stateDir, SECURITY_FILE), "utf8")) as { mutationApproval?: unknown; installApproval?: unknown; }; const stored = value.mutationApproval ?? value.installApproval; return MUTATION_APPROVAL_VALUES.includes(stored as MutationApproval) ? { mutationApproval: stored as MutationApproval } : { ...DEFAULT_SECURITY_SETTINGS }; } catch { return { ...DEFAULT_SECURITY_SETTINGS }; } } export async function writeSecuritySettings(stateDir: string, settings: SecuritySettings): Promise { if (!MUTATION_APPROVAL_VALUES.includes(settings.mutationApproval)) throw new Error("mutationApproval must be always or never"); await writeJsonAtomic(join(stateDir, SECURITY_FILE), settings, { mode: 0o600 }); return { ...settings }; }