/** Schema normalization and fail-closed decisions for HUX-05. */ import { normalizeCancellationReceipt } from "../activity/model.ts"; import { boundedInteger, exactKeys, identityKey, isOpaqueId, isUtc, normalizeIdentity, safeText, sameIdentity, } from "./security.ts"; import type { ActiveRun, ApprovalChoice, ApprovalDecisionIntent, ApprovalView, AutonomyLevel, AutonomyPage, BudgetDraft, Capability, GrantDecision, HuxIdentity, PolicyScope, PolicyUpdateIntent, PolicyView, RawAutonomyEnvelope, StopIntent, StopReceiptView, } from "./types.ts"; export const FOUNDATION_FLAG = "hux.foundation"; export const ACTIVITY_TIMELINE_FLAG = "hux.activity_timeline"; export const AUTONOMY_FLAG = "hux.autonomy"; export const CAPABILITIES: readonly Capability[] = [ "read_files", "write_files", "shell", "network", "web_search", "send_message", "memory_write", "artifact_write", "spend_tokens", "delegate", "deploy", "external_side_effect", ]; export const AUTONOMY_LEVELS: readonly AutonomyLevel[] = ["ask_first", "safe", "autonomous"]; export const GRANT_DECISIONS: readonly GrantDecision[] = ["allow", "ask", "deny"]; export const BUDGET_MAX = Object.freeze({ tokensPerRun: 2_000_000, toolCallsPerRun: 500, wallClockSeconds: 86_400, delegationsPerRun: 32, spendCentsPerRun: 100_000, }); const EXTERNAL = new Set([ "write_files", "shell", "network", "send_message", "memory_write", "artifact_write", "spend_tokens", "delegate", "deploy", "external_side_effect", ]); export function autonomyControlsEnabled(flags?: Iterable): boolean { if (!flags) return false; const values = new Set(flags); return [FOUNDATION_FLAG, ACTIVITY_TIMELINE_FLAG, AUTONOMY_FLAG] .every((flag) => values.has(flag)); } function normalizeScope(raw: unknown): PolicyScope | null { if (!raw || typeof raw !== "object" || Array.isArray(raw)) return null; const value = raw as Record; if (!exactKeys(value, value.level === "global" ? ["level"] : ["level", "scope_id"])) return null; if (!["global", "project", "conversation"].includes(String(value.level))) return null; if (value.level !== "global" && !isOpaqueId(value.scope_id)) return null; return {level: value.level as PolicyScope["level"], ...(value.level === "global" ? {} : {scopeId: value.scope_id as string})}; } function normalizePolicy(raw: unknown, expected: HuxIdentity): PolicyView | null { if (!raw || typeof raw !== "object" || Array.isArray(raw)) return null; const value = raw as Record; const required = ["schema", "id", "owner", "scope", "autonomy", "grants", "budgets", "provenance", "updated_at"]; if (!exactKeys(value, required) || value.schema !== "hux.policy.v1" || !isOpaqueId(value.id, "pol") || value.owner !== expected.userRef || !AUTONOMY_LEVELS.includes(value.autonomy as AutonomyLevel) || !isUtc(value.updated_at)) return null; const scope = normalizeScope(value.scope); if (!scope || !Array.isArray(value.grants) || value.grants.length > 64 || !value.budgets || typeof value.budgets !== "object" || Array.isArray(value.budgets)) return null; const grants = Object.fromEntries(CAPABILITIES.map((capability) => [capability, "deny"])) as Record; const seen = new Set(); for (const rawGrant of value.grants) { if (!rawGrant || typeof rawGrant !== "object" || Array.isArray(rawGrant)) return null; const grant = rawGrant as Record; if (!CAPABILITIES.includes(grant.capability as Capability) || !GRANT_DECISIONS.includes(grant.decision as GrantDecision) || seen.has(grant.capability as Capability)) return null; seen.add(grant.capability as Capability); grants[grant.capability as Capability] = grant.decision as GrantDecision; } const budget = value.budgets as Record; const budgetKeys = ["tokens_per_run", "tool_calls_per_run", "wall_clock_seconds", "delegations_per_run"]; if (Object.keys(budget).some((key) => !budgetKeys.includes(key))) return null; const normalized = { tokensPerRun: boundedInteger(budget.tokens_per_run ?? 0, BUDGET_MAX.tokensPerRun), toolCallsPerRun: boundedInteger(budget.tool_calls_per_run ?? 0, BUDGET_MAX.toolCallsPerRun), wallClockSeconds: boundedInteger(budget.wall_clock_seconds ?? 0, BUDGET_MAX.wallClockSeconds), delegationsPerRun: boundedInteger(budget.delegations_per_run ?? 0, BUDGET_MAX.delegationsPerRun), }; if (Object.values(normalized).some((item) => item === null)) return null; return {id: value.id, owner: value.owner as string, scope, autonomy: value.autonomy as AutonomyLevel, grants: Object.freeze({...grants}), budgets: normalized as PolicyView["budgets"], updatedAt: value.updated_at}; } function normalizeApproval(raw: unknown, expected: HuxIdentity, conversationId: string, now: number): ApprovalView | null { if (!raw || typeof raw !== "object" || Array.isArray(raw)) return null; const value = raw as Record; const keys = ["schema", "id", "run_id", "conversation_id", "capability", "request", "status", "requested_at", "expires_at"]; if (!exactKeys(value, keys) || value.schema !== "hux.approval.v1" || !isOpaqueId(value.id, "apr") || typeof value.run_id !== "string" || !value.run_id || value.run_id.length > 120 || value.conversation_id !== conversationId || !CAPABILITIES.includes(value.capability as Capability) || value.status !== "pending" || !isUtc(value.requested_at) || !isUtc(value.expires_at) || Date.parse(value.requested_at) > Date.parse(value.expires_at) || Date.parse(value.expires_at) <= now) return null; if (!value.request || typeof value.request !== "object" || Array.isArray(value.request)) return null; const request = value.request as Record; if (Object.keys(request).some((key) => !["summary", "detail", "risk", "evidence"].includes(key)) || !["low", "medium", "high"].includes(String(request.risk))) return null; return {id: value.id, identityKey: identityKey(expected), runId: value.run_id, conversationId, capability: value.capability as Capability, summary: safeText(request.summary, "Hermes requests permission"), risk: request.risk as ApprovalView["risk"], requestedAt: value.requested_at, expiresAt: value.expires_at, status: "pending"}; } export function normalizeAutonomyPage(raw: RawAutonomyEnvelope, expected: HuxIdentity, conversationId: string, now = Date.now()): AutonomyPage | null { if (!raw || typeof raw !== "object" || raw.schema !== "hux.autonomy_page.v1" || raw.api_version !== "hux.v1" || raw.conversation_id !== conversationId || !isOpaqueId(conversationId, "conv")) return null; const actual = normalizeIdentity(raw.identity); if (!actual || !sameIdentity(actual, expected)) return null; const policy = normalizePolicy(raw.policy, expected); if (!policy || !Array.isArray(raw.approvals) || raw.approvals.length > 100) return null; const approvals = raw.approvals.map((item) => normalizeApproval(item, expected, conversationId, now)); return {policy, approvals: approvals.filter((item): item is ApprovalView => item !== null), rejectedApprovals: approvals.filter((item) => item === null).length}; } function boundedDraft(draft: BudgetDraft): boolean { return boundedInteger(draft.tokensPerRun, BUDGET_MAX.tokensPerRun) !== null && boundedInteger(draft.toolCallsPerRun, BUDGET_MAX.toolCallsPerRun) !== null && boundedInteger(draft.wallClockSeconds, BUDGET_MAX.wallClockSeconds) !== null && boundedInteger(draft.delegationsPerRun, BUDGET_MAX.delegationsPerRun) !== null && boundedInteger(draft.spendCentsPerRun, BUDGET_MAX.spendCentsPerRun) !== null && ["current_task", "conversation", "project"].includes(draft.scopeLimit); } export function buildPolicyUpdate(policy: PolicyView, identity: HuxIdentity, autonomy: AutonomyLevel, grants: Readonly>, draft: BudgetDraft): PolicyUpdateIntent { if (policy.owner !== identity.userRef || !AUTONOMY_LEVELS.includes(autonomy) || !boundedDraft(draft) || CAPABILITIES.some((capability) => !GRANT_DECISIONS.includes(grants[capability]))) { throw new TypeError("Autonomy policy update is outside its bounds"); } return {expectedPolicyId: policy.id, expectedUpdatedAt: policy.updatedAt, identity: {...identity}, scope: {...policy.scope}, autonomy, grants: Object.freeze({...grants}), budgets: {tokensPerRun: draft.tokensPerRun, toolCallsPerRun: draft.toolCallsPerRun, wallClockSeconds: draft.wallClockSeconds, delegationsPerRun: draft.delegationsPerRun}, guardrails: {spendCentsPerRun: draft.spendCentsPerRun, scopeLimit: draft.scopeLimit}}; } export function buildApprovalDecision(approval: ApprovalView, choice: ApprovalChoice, identity: HuxIdentity, expectedRequestedAt: string, now = Date.now()): ApprovalDecisionIntent { if (approval.identityKey !== identityKey(identity) || approval.requestedAt !== expectedRequestedAt || approval.status !== "pending" || Date.parse(approval.expiresAt) <= now || !["once", "session", "always", "deny"].includes(choice)) { throw new TypeError("Stale or mismatched approval denied"); } return {approvalId: approval.id, expectedRunId: approval.runId, expectedConversationId: approval.conversationId, expectedCapability: approval.capability, expectedRequestedAt, choice, identity: {...identity}}; } export function actionAuthorization(policy: PolicyView | null, capability: Capability, _approval: ApprovalView | null, identity: HuxIdentity, _now = Date.now()): "allow" | "approval_required" | "deny" { if (!policy || policy.owner !== identity.userRef || !CAPABILITIES.includes(capability)) return "deny"; const decision = policy.grants[capability]; if (decision === "deny") return "deny"; const mustAsk = decision === "ask" || policy.autonomy === "ask_first" || (policy.autonomy === "safe" && EXTERNAL.has(capability)); if (!mustAsk) return "allow"; // A pending UI record is never execution authority. The server may perform // the action only after its decision endpoint atomically validates and // consumes the exact approval proof built by buildApprovalDecision(). return "approval_required"; } export function buildStopIntent(run: ActiveRun, identity: HuxIdentity, expectedRunId: string): StopIntent { if (run.runId !== expectedRunId || !run.runId || run.runId.length > 120 || run.owner !== identity.userRef || run.tenantRef !== identity.tenantRef || !isOpaqueId(run.conversationId, "conv")) throw new TypeError("Run ownership could not be verified"); return {runId: run.runId, conversationId: run.conversationId, identity: {...identity}}; } export function normalizeStopReceipt(raw: Record, run: ActiveRun, identity: HuxIdentity): StopReceiptView | null { const requestedBy = raw?.requested_by as Record | null; if (!requestedBy || requestedBy.type !== "user" || requestedBy.id !== identity.userRef || run.owner !== identity.userRef || run.tenantRef !== identity.tenantRef || raw.run_id !== run.runId) return null; const receipt = normalizeCancellationReceipt(raw, identity.surface); if (!receipt || receipt.runId !== run.runId) return null; return {id: receipt.id, runId: receipt.runId, outcome: receipt.outcome, stopped: receipt.sideEffects.filter((item) => item.reverted).length, remaining: receipt.sideEffects.filter((item) => !item.reverted).length, effects: receipt.sideEffects, omittedEffects: receipt.omittedEffects}; }