Standalone per-card browser model/security/view modules for HUX-01..10 plus node+pytest suites that read the hux.v1 contract schemas directly. Reconciled drift found on integration: the activity model now accepts all 32 hux.event.v1 kinds (delegation.*, memory.suppressed, memory.retrieval_removed, budget.exhausted, side_effect.*), the autonomy model carries the external_side_effect capability, and the foundation boundary test now asserts the shipped static HUX surface exists on disk and that images never bake activated HUX_FLAGS. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BvMSXH8VH2tMWXanb8SJdf
192 lines
11 KiB
TypeScript
192 lines
11 KiB
TypeScript
/** 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<Capability>([
|
|
"write_files", "shell", "network", "send_message", "memory_write",
|
|
"artifact_write", "spend_tokens", "delegate", "deploy",
|
|
"external_side_effect",
|
|
]);
|
|
|
|
export function autonomyControlsEnabled(flags?: Iterable<string>): 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<string, unknown>;
|
|
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<string, unknown>;
|
|
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<Capability, GrantDecision>;
|
|
const seen = new Set<Capability>();
|
|
for (const rawGrant of value.grants) {
|
|
if (!rawGrant || typeof rawGrant !== "object" || Array.isArray(rawGrant)) return null;
|
|
const grant = rawGrant as Record<string, unknown>;
|
|
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<string, unknown>;
|
|
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<string, unknown>;
|
|
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<string, unknown>;
|
|
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<Record<Capability, GrantDecision>>,
|
|
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<string, unknown>, run: ActiveRun,
|
|
identity: HuxIdentity): StopReceiptView | null {
|
|
const requestedBy = raw?.requested_by as Record<string, unknown> | 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};
|
|
}
|