import * as fs from "node:fs"; import * as path from "node:path"; import type { DetectedProject } from "../project-detector/detector"; export type PolicyDecision = "allow" | "warn" | "preflight" | "block" | "ask" | "require_approval"; export type PolicyScope = "global" | "workspace" | "project" | "task"; export interface PolicyRule { id: string; description?: string; enabled?: boolean; when?: { projectType?: string; tool?: string; commandRegex?: string; taskCategory?: string; }; action: PolicyDecision; message?: string; } export interface LayeredPolicy { version: number; name: string; scope: PolicyScope; project: { name: string; root: string; types: string[]; }; workflow: Record; commandPolicy: Record; rules: PolicyRule[]; artifactPolicy: Record; todoPolicy: Record; memoryPolicy: Record; extensionPolicy: Record; } export interface PolicyLoadWarning { scope: PolicyScope; path: string; message: string; } export interface LoadedLayeredPolicy { merged: LayeredPolicy; warnings: PolicyLoadWarning[]; loadedPaths: Record; } export const DEFAULT_POLICY: LayeredPolicy = { version: 1, name: "default policy", scope: "task", project: { name: "auto", root: "auto", types: ["auto"], }, workflow: { requireDiagnosisBeforeEdit: false, requirePlanBeforeRiskyCommand: false, oneNextStepAfterFailure: true, stopOnTaskDrift: false, stopOnPopupError: false, stopOnEmptyOutput: false, stopOnArtifactMismatch: false, }, commandPolicy: { safeRead: "allow", build: "preflight", install: "require_reason", destructive: "require_approval", deploy: "require_explicit_user_request", database: "require_backup_or_dry_run", network: "require_reason", unknown: "ask", }, rules: [], artifactPolicy: { verifyOutputExists: false, verifyOutputTimestamp: false, verifyHashWhenCopyingArtifacts: false, verifyNonEmptyOutputDir: false, }, todoPolicy: { maxActiveTodos: 3, blockUnrelatedTodos: false, requireUserApprovalForNewWorkstream: false, }, memoryPolicy: { detectCorrections: false, proposeRulePromotion: false, autoPromote: false, storeCandidateRules: false, }, extensionPolicy: { preferInstalledExtensions: true, requireAuditBeforeNewExtension: false, }, }; function isRecord(value: unknown): value is Record { return typeof value === "object" && value !== null && !Array.isArray(value); } function isPolicyRule(value: unknown): value is PolicyRule { return isRecord(value) && typeof value["id"] === "string" && typeof value["action"] === "string"; } function cloneDefault(project: DetectedProject): LayeredPolicy { return { ...DEFAULT_POLICY, project: { name: path.basename(project.root) || "auto", root: project.root, types: project.types, }, workflow: { ...DEFAULT_POLICY.workflow }, commandPolicy: { ...DEFAULT_POLICY.commandPolicy }, rules: [...DEFAULT_POLICY.rules], artifactPolicy: { ...DEFAULT_POLICY.artifactPolicy }, todoPolicy: { ...DEFAULT_POLICY.todoPolicy }, memoryPolicy: { ...DEFAULT_POLICY.memoryPolicy }, extensionPolicy: { ...DEFAULT_POLICY.extensionPolicy }, }; } export function policyPathsFor(cwd: string): Record { const home = process.env.USERPROFILE || process.env.HOME || cwd; return { global: path.join(home, ".pi", "minimax-policy.json"), workspace: path.join(path.dirname(cwd), ".pi", "minimax-policy.json"), project: path.join(cwd, ".pi", "minimax-policy.json"), task: path.join(cwd, ".pi", "minimax-policy.session.json"), }; } export function readPolicyFile(policyPath: string, scope: PolicyScope): { policy: Record | null; warning: PolicyLoadWarning | null } { if (!policyPath || !fs.existsSync(policyPath)) { return { policy: null, warning: null }; } try { const raw = fs.readFileSync(policyPath, "utf-8"); const parsed: unknown = JSON.parse(raw); if (!isRecord(parsed)) { return { policy: null, warning: { scope, path: policyPath, message: "Policy JSON root must be an object. Using fallback." }, }; } return { policy: parsed, warning: null }; } catch (error) { const message = error instanceof Error ? error.message : String(error); return { policy: null, warning: { scope, path: policyPath, message: `Policy JSON invalid: ${message}` }, }; } } export function mergePolicy(base: LayeredPolicy, override: Record | null, scope: PolicyScope): LayeredPolicy { if (!override) return base; return { ...base, ...override, scope, project: { ...(base.project || {}), ...(isRecord(override.project) ? override.project : {}), } as LayeredPolicy["project"], workflow: { ...(base.workflow || {}), ...(isRecord(override.workflow) ? override.workflow : {}), }, commandPolicy: { ...(base.commandPolicy || {}), ...(isRecord(override.commandPolicy) ? override.commandPolicy : {}), }, rules: [ ...base.rules, ...((Array.isArray(override.rules) ? override.rules : []).filter(isPolicyRule)), ], artifactPolicy: { ...(base.artifactPolicy || {}), ...(isRecord(override.artifactPolicy) ? override.artifactPolicy : {}), }, todoPolicy: { ...(base.todoPolicy || {}), ...(isRecord(override.todoPolicy) ? override.todoPolicy : {}), }, memoryPolicy: { ...(base.memoryPolicy || {}), ...(isRecord(override.memoryPolicy) ? override.memoryPolicy : {}), }, extensionPolicy: { ...(base.extensionPolicy || {}), ...(isRecord(override.extensionPolicy) ? override.extensionPolicy : {}), }, }; } export function loadMergedPolicy(cwd: string, project: DetectedProject, taskPolicy: Record | null = null): LoadedLayeredPolicy { const loadedPaths = policyPathsFor(cwd); const warnings: PolicyLoadWarning[] = []; let merged = cloneDefault(project); for (const scope of ["global", "workspace", "project"] as PolicyScope[]) { if (scope === "task") continue; const { policy, warning } = readPolicyFile(loadedPaths[scope], scope); if (warning) warnings.push(warning); merged = mergePolicy(merged, policy, scope); } merged = mergePolicy(merged, taskPolicy, "task"); merged.project = { ...merged.project, name: merged.project?.name || path.basename(project.root) || "auto", root: merged.project?.root || project.root, types: Array.isArray(merged.project?.types) && merged.project.types.length ? merged.project.types : project.types, }; return { merged, warnings, loadedPaths }; }