jenkins b66c762f5d hermes(webui): add HUX card UI models with contract-locked suites
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
2026-08-24 04:12:03 -03:00

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};
}