From b66c762f5d600014d49c18af2764693277fdf80d Mon Sep 17 00:00:00 2001 From: jenkins Date: Mon, 24 Aug 2026 04:12:03 -0300 Subject: [PATCH] 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 Claude-Session: https://claude.ai/code/session_01BvMSXH8VH2tMWXanb8SJdf --- .../activity/ActivityTimeline.tsx | 260 +++++++++++ .../hermes-webui-hux/activity/index.ts | 16 + .../hermes-webui-hux/activity/model.ts | 306 +++++++++++++ .../hermes-webui-hux/activity/security.ts | 76 ++++ .../hermes-webui-hux/activity/styles.css | 85 ++++ .../hermes-webui-hux/activity/types.ts | 94 ++++ .../autonomy/AutonomyControls.tsx | 181 ++++++++ .../hermes-webui-hux/autonomy/endpoints.ts | 48 ++ .../hermes-webui-hux/autonomy/index.ts | 12 + .../hermes-webui-hux/autonomy/model.ts | 191 ++++++++ .../hermes-webui-hux/autonomy/security.ts | 59 +++ .../hermes-webui-hux/autonomy/styles.css | 165 +++++++ .../hermes-webui-hux/autonomy/types.ts | 140 ++++++ .../memory/MemoryControlCenter.tsx | 346 +++++++++++++++ .../hermes-webui-hux/memory/endpoints.ts | 79 ++++ dockerfiles/hermes-webui-hux/memory/index.ts | 24 + dockerfiles/hermes-webui-hux/memory/model.ts | 239 ++++++++++ .../hermes-webui-hux/memory/security.ts | 96 ++++ .../hermes-webui-hux/memory/styles.css | 197 +++++++++ dockerfiles/hermes-webui-hux/memory/types.ts | 126 ++++++ .../modes/FriendlyModeSelector.tsx | 187 ++++++++ dockerfiles/hermes-webui-hux/modes/index.ts | 26 ++ dockerfiles/hermes-webui-hux/modes/model.ts | 312 +++++++++++++ dockerfiles/hermes-webui-hux/modes/styles.css | 178 ++++++++ dockerfiles/hermes-webui-hux/modes/types.ts | 96 ++++ .../multimodal/MultimodalChat.tsx | 166 +++++++ .../hermes-webui-hux/multimodal/endpoints.ts | 61 +++ .../hermes-webui-hux/multimodal/index.ts | 8 + .../hermes-webui-hux/multimodal/model.ts | 192 ++++++++ .../hermes-webui-hux/multimodal/security.ts | 99 +++++ .../hermes-webui-hux/multimodal/styles.css | 147 ++++++ .../hermes-webui-hux/multimodal/types.ts | 140 ++++++ .../onboarding/OnboardingSuggestions.tsx | 117 +++++ .../hermes-webui-hux/onboarding/client.ts | 145 ++++++ .../hermes-webui-hux/onboarding/contracts.ts | 32 ++ .../hermes-webui-hux/onboarding/index.ts | 20 + .../hermes-webui-hux/onboarding/model.ts | 246 +++++++++++ .../hermes-webui-hux/onboarding/security.ts | 98 ++++ .../hermes-webui-hux/onboarding/styles.css | 124 ++++++ .../hermes-webui-hux/onboarding/types.ts | 142 ++++++ .../organization/OrganizationWorkspace.tsx | 252 +++++++++++ .../hermes-webui-hux/organization/client.ts | 253 +++++++++++ .../organization/contracts.ts | 38 ++ .../hermes-webui-hux/organization/index.ts | 14 + .../hermes-webui-hux/organization/model.ts | 165 +++++++ .../hermes-webui-hux/organization/security.ts | 78 ++++ .../hermes-webui-hux/organization/styles.css | 89 ++++ .../hermes-webui-hux/organization/types.ts | 143 ++++++ .../privacy/PrivacyCenter.tsx | 165 +++++++ .../hermes-webui-hux/privacy/client.ts | 130 ++++++ .../hermes-webui-hux/privacy/contracts.ts | 28 ++ dockerfiles/hermes-webui-hux/privacy/index.ts | 16 + dockerfiles/hermes-webui-hux/privacy/model.ts | 142 ++++++ .../hermes-webui-hux/privacy/security.ts | 80 ++++ .../hermes-webui-hux/privacy/styles.css | 161 +++++++ dockerfiles/hermes-webui-hux/privacy/types.ts | 83 ++++ .../research/ResearchWorkspace.tsx | 135 ++++++ .../hermes-webui-hux/research/endpoints.ts | 46 ++ .../hermes-webui-hux/research/index.ts | 4 + .../hermes-webui-hux/research/model.ts | 150 +++++++ .../hermes-webui-hux/research/security.ts | 76 ++++ .../hermes-webui-hux/research/styles.css | 154 +++++++ .../hermes-webui-hux/research/types.ts | 110 +++++ testing/tests/test_hermes_hux_ui_activity.mjs | 412 +++++++++++++++++ testing/tests/test_hermes_hux_ui_activity.py | 67 +++ .../tests/test_hermes_hux_ui_artifacts.mjs | 361 +++++++++++++++ testing/tests/test_hermes_hux_ui_artifacts.py | 76 ++++ testing/tests/test_hermes_hux_ui_autonomy.mjs | 313 +++++++++++++ testing/tests/test_hermes_hux_ui_autonomy.py | 95 ++++ .../tests/test_hermes_hux_ui_foundation.py | 68 +++ .../test_hermes_hux_ui_foundation_node.js | 276 ++++++++++++ testing/tests/test_hermes_hux_ui_memory.mjs | 334 ++++++++++++++ testing/tests/test_hermes_hux_ui_memory.py | 76 ++++ testing/tests/test_hermes_hux_ui_modes.mjs | 305 +++++++++++++ testing/tests/test_hermes_hux_ui_modes.py | 83 ++++ .../tests/test_hermes_hux_ui_multimodal.mjs | 269 +++++++++++ .../tests/test_hermes_hux_ui_multimodal.py | 94 ++++ .../tests/test_hermes_hux_ui_onboarding.mjs | 413 +++++++++++++++++ .../tests/test_hermes_hux_ui_onboarding.py | 117 +++++ .../tests/test_hermes_hux_ui_organization.mjs | 417 ++++++++++++++++++ .../tests/test_hermes_hux_ui_organization.py | 99 +++++ testing/tests/test_hermes_hux_ui_privacy.mjs | 312 +++++++++++++ testing/tests/test_hermes_hux_ui_privacy.py | 119 +++++ testing/tests/test_hermes_hux_ui_research.mjs | 242 ++++++++++ testing/tests/test_hermes_hux_ui_research.py | 103 +++++ 85 files changed, 12439 insertions(+) create mode 100644 dockerfiles/hermes-webui-hux/activity/ActivityTimeline.tsx create mode 100644 dockerfiles/hermes-webui-hux/activity/index.ts create mode 100644 dockerfiles/hermes-webui-hux/activity/model.ts create mode 100644 dockerfiles/hermes-webui-hux/activity/security.ts create mode 100644 dockerfiles/hermes-webui-hux/activity/styles.css create mode 100644 dockerfiles/hermes-webui-hux/activity/types.ts create mode 100644 dockerfiles/hermes-webui-hux/autonomy/AutonomyControls.tsx create mode 100644 dockerfiles/hermes-webui-hux/autonomy/endpoints.ts create mode 100644 dockerfiles/hermes-webui-hux/autonomy/index.ts create mode 100644 dockerfiles/hermes-webui-hux/autonomy/model.ts create mode 100644 dockerfiles/hermes-webui-hux/autonomy/security.ts create mode 100644 dockerfiles/hermes-webui-hux/autonomy/styles.css create mode 100644 dockerfiles/hermes-webui-hux/autonomy/types.ts create mode 100644 dockerfiles/hermes-webui-hux/memory/MemoryControlCenter.tsx create mode 100644 dockerfiles/hermes-webui-hux/memory/endpoints.ts create mode 100644 dockerfiles/hermes-webui-hux/memory/index.ts create mode 100644 dockerfiles/hermes-webui-hux/memory/model.ts create mode 100644 dockerfiles/hermes-webui-hux/memory/security.ts create mode 100644 dockerfiles/hermes-webui-hux/memory/styles.css create mode 100644 dockerfiles/hermes-webui-hux/memory/types.ts create mode 100644 dockerfiles/hermes-webui-hux/modes/FriendlyModeSelector.tsx create mode 100644 dockerfiles/hermes-webui-hux/modes/index.ts create mode 100644 dockerfiles/hermes-webui-hux/modes/model.ts create mode 100644 dockerfiles/hermes-webui-hux/modes/styles.css create mode 100644 dockerfiles/hermes-webui-hux/modes/types.ts create mode 100644 dockerfiles/hermes-webui-hux/multimodal/MultimodalChat.tsx create mode 100644 dockerfiles/hermes-webui-hux/multimodal/endpoints.ts create mode 100644 dockerfiles/hermes-webui-hux/multimodal/index.ts create mode 100644 dockerfiles/hermes-webui-hux/multimodal/model.ts create mode 100644 dockerfiles/hermes-webui-hux/multimodal/security.ts create mode 100644 dockerfiles/hermes-webui-hux/multimodal/styles.css create mode 100644 dockerfiles/hermes-webui-hux/multimodal/types.ts create mode 100644 dockerfiles/hermes-webui-hux/onboarding/OnboardingSuggestions.tsx create mode 100644 dockerfiles/hermes-webui-hux/onboarding/client.ts create mode 100644 dockerfiles/hermes-webui-hux/onboarding/contracts.ts create mode 100644 dockerfiles/hermes-webui-hux/onboarding/index.ts create mode 100644 dockerfiles/hermes-webui-hux/onboarding/model.ts create mode 100644 dockerfiles/hermes-webui-hux/onboarding/security.ts create mode 100644 dockerfiles/hermes-webui-hux/onboarding/styles.css create mode 100644 dockerfiles/hermes-webui-hux/onboarding/types.ts create mode 100644 dockerfiles/hermes-webui-hux/organization/OrganizationWorkspace.tsx create mode 100644 dockerfiles/hermes-webui-hux/organization/client.ts create mode 100644 dockerfiles/hermes-webui-hux/organization/contracts.ts create mode 100644 dockerfiles/hermes-webui-hux/organization/index.ts create mode 100644 dockerfiles/hermes-webui-hux/organization/model.ts create mode 100644 dockerfiles/hermes-webui-hux/organization/security.ts create mode 100644 dockerfiles/hermes-webui-hux/organization/styles.css create mode 100644 dockerfiles/hermes-webui-hux/organization/types.ts create mode 100644 dockerfiles/hermes-webui-hux/privacy/PrivacyCenter.tsx create mode 100644 dockerfiles/hermes-webui-hux/privacy/client.ts create mode 100644 dockerfiles/hermes-webui-hux/privacy/contracts.ts create mode 100644 dockerfiles/hermes-webui-hux/privacy/index.ts create mode 100644 dockerfiles/hermes-webui-hux/privacy/model.ts create mode 100644 dockerfiles/hermes-webui-hux/privacy/security.ts create mode 100644 dockerfiles/hermes-webui-hux/privacy/styles.css create mode 100644 dockerfiles/hermes-webui-hux/privacy/types.ts create mode 100644 dockerfiles/hermes-webui-hux/research/ResearchWorkspace.tsx create mode 100644 dockerfiles/hermes-webui-hux/research/endpoints.ts create mode 100644 dockerfiles/hermes-webui-hux/research/index.ts create mode 100644 dockerfiles/hermes-webui-hux/research/model.ts create mode 100644 dockerfiles/hermes-webui-hux/research/security.ts create mode 100644 dockerfiles/hermes-webui-hux/research/styles.css create mode 100644 dockerfiles/hermes-webui-hux/research/types.ts create mode 100644 testing/tests/test_hermes_hux_ui_activity.mjs create mode 100644 testing/tests/test_hermes_hux_ui_activity.py create mode 100644 testing/tests/test_hermes_hux_ui_artifacts.mjs create mode 100644 testing/tests/test_hermes_hux_ui_artifacts.py create mode 100644 testing/tests/test_hermes_hux_ui_autonomy.mjs create mode 100644 testing/tests/test_hermes_hux_ui_autonomy.py create mode 100644 testing/tests/test_hermes_hux_ui_foundation.py create mode 100644 testing/tests/test_hermes_hux_ui_foundation_node.js create mode 100644 testing/tests/test_hermes_hux_ui_memory.mjs create mode 100644 testing/tests/test_hermes_hux_ui_memory.py create mode 100644 testing/tests/test_hermes_hux_ui_modes.mjs create mode 100644 testing/tests/test_hermes_hux_ui_modes.py create mode 100644 testing/tests/test_hermes_hux_ui_multimodal.mjs create mode 100644 testing/tests/test_hermes_hux_ui_multimodal.py create mode 100644 testing/tests/test_hermes_hux_ui_onboarding.mjs create mode 100644 testing/tests/test_hermes_hux_ui_onboarding.py create mode 100644 testing/tests/test_hermes_hux_ui_organization.mjs create mode 100644 testing/tests/test_hermes_hux_ui_organization.py create mode 100644 testing/tests/test_hermes_hux_ui_privacy.mjs create mode 100644 testing/tests/test_hermes_hux_ui_privacy.py create mode 100644 testing/tests/test_hermes_hux_ui_research.mjs create mode 100644 testing/tests/test_hermes_hux_ui_research.py diff --git a/dockerfiles/hermes-webui-hux/activity/ActivityTimeline.tsx b/dockerfiles/hermes-webui-hux/activity/ActivityTimeline.tsx new file mode 100644 index 00000000..8f0dfb7b --- /dev/null +++ b/dockerfiles/hermes-webui-hux/activity/ActivityTimeline.tsx @@ -0,0 +1,260 @@ +/** Compact, accessible HUX-01 activity timeline. The feature is default-off. */ + +import { + Activity, + AlertTriangle, + Check, + GitBranch, + ListChecks, + LoaderCircle, +} from "lucide-react"; +import { useMemo, useState } from "react"; + +import { + activityTimelineEnabled, + mergeActivityEvents, + normalizeCancellationReceipt, + surfacePolicy, +} from "./model.ts"; +import type { + ActivityItem, + CancellationReceipt, + HuxSurface, + RawActivityEvent, + RawCancellationReceipt, +} from "./types.ts"; + +interface ActivityTimelineProps { + conversationId: string; + events: readonly RawActivityEvent[]; + flags?: Iterable; + surface: HuxSurface; + status?: "connecting" | "live" | "reconnecting" | "complete"; + cancellationReceipts?: readonly RawCancellationReceipt[]; + onRetry?: () => void; +} + +const PHASE_LABELS: Readonly> = { + intent: "Intent", + action: "Action", + evidence: "Evidence", + delegation: "Delegation", + decision: "Decision", + completion: "Complete", + failure: "Failed", +}; + +function PhaseIcon({ item }: { item: ActivityItem }) { + if (item.phase === "failure") + return ; + if (item.phase === "completion") + return ; + if (item.phase === "delegation") + return ; + if (item.phase === "decision") + return ; + return ; +} + +function TimelineRow({ + item, + expanded, +}: { + item: ActivityItem; + expanded: boolean; +}) { + return ( +
  • +
    + +
    +
    +
    + + {PHASE_LABELS[item.phase]} + + +
    +

    + {item.summary} +

    + {expanded && item.evidence.length > 0 && ( +
      + {item.evidence.map((evidence, index) => ( +
    • + {evidence.label} +
    • + ))} +
    + )} +
    +
  • + ); +} + +function CancellationCard({ receipt }: { receipt: CancellationReceipt }) { + const complete = receipt.outcome !== "failed_to_cancel"; + return ( + + ); +} + +/** Render nothing unless both HUX-11 and HUX-01 have been explicitly enabled. */ +export function ActivityTimeline({ + conversationId, + events, + flags, + surface, + status = "live", + cancellationReceipts = [], + onRetry, +}: ActivityTimelineProps) { + const [showAll, setShowAll] = useState(false); + const enabled = activityTimelineEnabled(flags); + const policy = surfacePolicy(surface); + const result = useMemo( + () => mergeActivityEvents([], events, conversationId, surface), + [conversationId, events, surface], + ); + const receipts = useMemo( + () => + cancellationReceipts + .map((receipt) => normalizeCancellationReceipt(receipt, surface)) + .filter((receipt): receipt is CancellationReceipt => receipt !== null) + .slice(-3), + [cancellationReceipts, surface], + ); + if (!enabled) return null; + + const hidden = Math.max(0, result.items.length - policy.initialItems); + const visible = showAll + ? result.items + : result.items.slice(-policy.initialItems); + const reconnecting = status === "connecting" || status === "reconnecting"; + + return ( +
    +
    +
    +

    What Hermes is doing

    +

    + {reconnecting + ? "Reconnecting to durable activity…" + : `${result.items.length} recorded update${result.items.length === 1 ? "" : "s"}`} +

    +
    + {reconnecting && ( + + )} +
    + + {(result.conflicts > 0 || result.invalid > 0) && ( +
    + Some unsafe or conflicting activity was ignored. Visible history was + not rewritten. +
    + )} + +
      + {visible.map((item) => ( + + ))} +
    + + {result.items.length === 0 && ( +

    + No activity has been recorded yet. +

    + )} + {hidden > 0 && !showAll && ( + + )} + {showAll && hidden > 0 && ( + + )} + {reconnecting && onRetry && ( + + )} + {receipts.map((receipt) => ( + + ))} +
    + ); +} diff --git a/dockerfiles/hermes-webui-hux/activity/index.ts b/dockerfiles/hermes-webui-hux/activity/index.ts new file mode 100644 index 00000000..025ff23b --- /dev/null +++ b/dockerfiles/hermes-webui-hux/activity/index.ts @@ -0,0 +1,16 @@ +export { ActivityTimeline } from "./ActivityTimeline.tsx"; +export { + ACTIVITY_TIMELINE_FLAG, + FOUNDATION_FLAG, + activityTimelineEnabled, + mergeActivityEvents, + normalizeCancellationReceipt, + surfacePolicy, +} from "./model.ts"; +export type { + ActivityItem, + CancellationReceipt, + HuxSurface, + RawActivityEvent, + RawCancellationReceipt, +} from "./types.ts"; diff --git a/dockerfiles/hermes-webui-hux/activity/model.ts b/dockerfiles/hermes-webui-hux/activity/model.ts new file mode 100644 index 00000000..86d241e8 --- /dev/null +++ b/dockerfiles/hermes-webui-hux/activity/model.ts @@ -0,0 +1,306 @@ +/** Normalization, replay safety, and surface policy for HUX-01. */ + +import { + isOpaqueId, + isRfc3339Utc, + safeEvidenceLabel, + safeText, +} from "./security.ts"; +import type { + ActivityItem, + ActivityPhase, + ActivityTone, + CancellationReceipt, + HuxSurface, + MergeResult, + RawActivityEvent, + RawCancellationReceipt, + SurfacePolicy, +} from "./types.ts"; + +export const ACTIVITY_TIMELINE_FLAG = "hux.activity_timeline"; +export const FOUNDATION_FLAG = "hux.foundation"; + +const EVENT_KINDS = new Set([ + "message.user", + "message.assistant", + "decision.route", + "decision.plan", + "tool.call", + "tool.result", + "approval.requested", + "approval.resolved", + "memory.proposed", + "memory.committed", + "memory.forgotten", + "artifact.created", + "artifact.version", + "artifact.promoted", + "citation.attached", + "mode.changed", + "run.started", + "run.cancelled", + "run.completed", + "run.failed", + "privacy.notice", + "suggestion.shown", + "suggestion.dismissed", + "release.transition", + "delegation.started", + "delegation.completed", + "delegation.failed", + "memory.suppressed", + "memory.retrieval_removed", + "budget.exhausted", + "side_effect.blocked", + "side_effect.released", +]); + +const SURFACE_POLICIES: Readonly> = { + chat: { + maxItems: 80, + initialItems: 12, + evidenceLimit: 3, + cancellationEffectLimit: 4, + expandRoutineEvents: false, + }, + worker: { + maxItems: 250, + initialItems: 40, + evidenceLimit: 8, + cancellationEffectLimit: 12, + expandRoutineEvents: true, + }, + telegram: { + maxItems: 40, + initialItems: 8, + evidenceLimit: 2, + cancellationEffectLimit: 3, + expandRoutineEvents: false, + }, + voice: { + maxItems: 24, + initialItems: 6, + evidenceLimit: 1, + cancellationEffectLimit: 2, + expandRoutineEvents: false, + }, + api: { + maxItems: 250, + initialItems: 40, + evidenceLimit: 8, + cancellationEffectLimit: 12, + expandRoutineEvents: true, + }, +}; + +export function surfacePolicy(surface: HuxSurface): SurfacePolicy { + return { ...SURFACE_POLICIES[surface] }; +} + +export function activityTimelineEnabled(flags?: Iterable): boolean { + if (!flags) return false; + const enabled = new Set(flags); + return enabled.has(FOUNDATION_FLAG) && enabled.has(ACTIVITY_TIMELINE_FLAG); +} + +function phaseFor(kind: string, hasParent: boolean): ActivityPhase { + if (kind === "message.user") return "intent"; + if ( + kind === "run.failed" || + kind === "delegation.failed" || + kind === "budget.exhausted" + ) { + return "failure"; + } + if ( + kind === "run.completed" || + kind === "run.cancelled" || + kind === "message.assistant" || + kind === "delegation.completed" + ) { + return "completion"; + } + if (kind === "delegation.started") return "delegation"; + if ( + kind.startsWith("decision.") || + kind.startsWith("approval.") || + kind === "mode.changed" || + kind === "privacy.notice" || + kind === "memory.suppressed" || + kind === "memory.retrieval_removed" || + kind === "side_effect.blocked" + ) + return "decision"; + if ( + kind === "tool.result" || + kind === "citation.attached" || + kind === "artifact.version" + ) { + return "evidence"; + } + if (kind === "run.started" && hasParent) return "delegation"; + return "action"; +} + +function toneFor(phase: ActivityPhase, kind: string): ActivityTone { + if (phase === "failure") return "warning"; + if (phase === "completion") return "success"; + if (kind === "approval.requested") return "warning"; + if (phase === "action" || phase === "delegation") return "active"; + return "neutral"; +} + +function hiddenSummary(kind: string): string { + if (kind === "tool.call") return "Hermes used a tool"; + if (kind === "tool.result") return "Tool evidence was recorded"; + if (kind === "run.failed") + return "The run failed; sensitive detail is hidden"; + if (kind === "run.completed") return "The run completed"; + return "Activity recorded; sensitive detail is hidden"; +} + +function normalizeEvent( + raw: RawActivityEvent, + conversationId: string, + policy: SurfacePolicy, +): ActivityItem | null { + if (raw.schema !== "hux.event.v1" || !isOpaqueId(raw.id, "evt")) return null; + if (!Number.isInteger(raw.seq) || (raw.seq as number) < 0) return null; + if (!isRfc3339Utc(raw.ts) || raw.conversation_id !== conversationId) + return null; + if (typeof raw.kind !== "string" || !EVENT_KINDS.has(raw.kind)) return null; + const redaction = raw.redaction as { level?: unknown } | null; + const fullyHidden = + raw.sensitivity === "restricted" || redaction?.level === "full"; + const phase = phaseFor(raw.kind, typeof raw.parent_event_id === "string"); + const references = Array.isArray(raw.evidence) ? raw.evidence : []; + const evidence = references + .map((item) => safeEvidenceLabel((item as { kind?: unknown } | null)?.kind)) + .filter((item): item is NonNullable => item !== null) + .slice(0, policy.evidenceLimit); + return { + id: raw.id, + seq: raw.seq as number, + timestamp: raw.ts, + conversationId, + runId: + typeof raw.run_id === "string" + ? safeText(raw.run_id, "", 120) || undefined + : undefined, + kind: raw.kind, + phase, + tone: toneFor(phase, raw.kind), + summary: fullyHidden + ? hiddenSummary(raw.kind) + : safeText(raw.summary, hiddenSummary(raw.kind)), + evidence, + routine: phase === "action" || phase === "evidence", + }; +} + +/** + * Merge reconnect pages without duplicates. Existing sequence owners win; + * conflicting replay entries are rejected instead of rewriting visible history. + */ +export function mergeActivityEvents( + existing: readonly ActivityItem[], + incoming: readonly RawActivityEvent[], + conversationId: string, + surface: HuxSurface, +): MergeResult { + const policy = surfacePolicy(surface); + const byId = new Map(existing.map((item) => [item.id, item])); + const bySeq = new Map(existing.map((item) => [item.seq, item])); + let invalid = 0; + let replays = 0; + let conflicts = 0; + const normalized: ActivityItem[] = []; + for (const raw of incoming) { + const item = normalizeEvent(raw, conversationId, policy); + if (!item) { + invalid += 1; + continue; + } + normalized.push(item); + } + // A reconnect page is not required to preserve transport order. Sorting + // before conflict resolution makes an initial replay deterministic. + normalized.sort( + (left, right) => left.seq - right.seq || left.id.localeCompare(right.id), + ); + for (const item of normalized) { + const priorId = byId.get(item.id); + if (priorId) { + if (priorId.seq === item.seq) replays += 1; + else conflicts += 1; + continue; + } + if (bySeq.has(item.seq)) { + conflicts += 1; + continue; + } + byId.set(item.id, item); + bySeq.set(item.seq, item); + } + const ordered = [...byId.values()].sort( + (left, right) => left.seq - right.seq || left.id.localeCompare(right.id), + ); + const afterSeq = ordered.length ? ordered[ordered.length - 1].seq : -1; + const truncated = Math.max(0, ordered.length - policy.maxItems); + return { + items: truncated ? ordered.slice(-policy.maxItems) : ordered, + afterSeq, + invalid, + replays, + conflicts, + truncated, + }; +} + +export function normalizeCancellationReceipt( + raw: RawCancellationReceipt, + surface: HuxSurface, +): CancellationReceipt | null { + const outcomes = new Set([ + "cancelled", + "already_complete", + "failed_to_cancel", + ]); + if (raw.schema !== "hux.cancel_receipt.v1" || !isOpaqueId(raw.id, "rcpt")) + return null; + if (typeof raw.run_id !== "string" || !isRfc3339Utc(raw.requested_at)) + return null; + if (typeof raw.outcome !== "string" || !outcomes.has(raw.outcome)) + return null; + const policy = surfacePolicy(surface); + const effects = Array.isArray(raw.side_effects) ? raw.side_effects : []; + const sideEffects = effects + .slice(0, policy.cancellationEffectLimit) + .map((value) => { + const effect = value as { + description?: unknown; + reverted?: unknown; + } | null; + return { + description: safeText( + effect?.description, + "A side effect was recorded", + 280, + ), + reverted: effect?.reverted === true, + }; + }); + return { + id: raw.id, + runId: safeText(raw.run_id, "unknown run", 120), + requestedAt: raw.requested_at, + acknowledgedAt: isRfc3339Utc(raw.acknowledged_at) + ? raw.acknowledged_at + : undefined, + completedAt: isRfc3339Utc(raw.completed_at) ? raw.completed_at : undefined, + outcome: raw.outcome as CancellationReceipt["outcome"], + sideEffects, + omittedEffects: Math.max(0, effects.length - sideEffects.length), + }; +} diff --git a/dockerfiles/hermes-webui-hux/activity/security.ts b/dockerfiles/hermes-webui-hux/activity/security.ts new file mode 100644 index 00000000..1b519f08 --- /dev/null +++ b/dockerfiles/hermes-webui-hux/activity/security.ts @@ -0,0 +1,76 @@ +/** Display-only redaction. Raw event payloads must never cross this boundary. */ + +const CONTROL_CHARACTERS = /[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f]/g; +const PEM_BLOCK = /-----BEGIN [^-]+-----[\s\S]*?-----END [^-]+-----/gi; +const AUTH_VALUE = + /\b(password|passwd|token|api[-_ ]?key|secret|authorization|cookie)\b\s*[:=]\s*[^\s,;]+/gi; +const BEARER = /\bbearer\s+[A-Za-z0-9._~+/-]+=*/gi; +const JWT = /\beyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\b/g; +const CREDENTIAL_BLOB = + /\b(?=[A-Za-z0-9_+/#.-]{40,}\b)(?=[A-Za-z0-9_+/#.-]*[A-Za-z])(?=[A-Za-z0-9_+/#.-]*\d)[A-Za-z0-9_+/#.-]+\b/g; +const URI_USERINFO = /\b([a-z][a-z0-9+.-]*:\/\/)[^\s/@:]+:[^\s/@]+@/gi; + +/** Return bounded plain text or a safe fallback; objects are never stringified. */ +export function safeText( + value: unknown, + fallback: string, + maxLength = 280, +): string { + if (typeof value !== "string") return fallback; + const limit = Math.max(1, maxLength); + const redacted = value + .replace(PEM_BLOCK, "[redacted credential]") + .replace(AUTH_VALUE, "$1=[redacted]") + .replace(BEARER, "Bearer [redacted]") + .replace(JWT, "[redacted credential]") + .replace(URI_USERINFO, "$1[redacted]@") + .replace(CREDENTIAL_BLOB, "[redacted credential]") + .replace(CONTROL_CHARACTERS, " ") + .replace(/\s+/g, " ") + .trim(); + if (!redacted) return fallback; + return redacted.length <= limit + ? redacted + : limit === 1 + ? "…" + : `${redacted.slice(0, limit - 1).trimEnd()}…`; +} + +const EVIDENCE_LABELS: Readonly> = { + message: "Message", + tool_call: "Tool call", + tool_result: "Tool result", + artifact_version: "Artifact version", + source: "Source", + passage: "Supporting passage", + memory: "Memory record", + approval: "Approval", + run: "Run", + url: "Website", + file: "File", + build: "Build", + flux: "Flux reconciliation", + pod: "Workload", +}; + +/** Evidence labels are deliberately detached from IDs, URIs, and payloads. */ +export function safeEvidenceLabel( + kind: unknown, +): { kind: string; label: string } | null { + if (typeof kind !== "string" || !(kind in EVIDENCE_LABELS)) return null; + return { kind, label: EVIDENCE_LABELS[kind] }; +} + +export function isRfc3339Utc(value: unknown): value is string { + return ( + typeof value === "string" && + /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{1,6})?Z$/.test(value) && + !Number.isNaN(Date.parse(value)) + ); +} + +export function isOpaqueId(value: unknown, prefix?: string): value is string { + if (typeof value !== "string") return false; + if (!/^[a-z]{2,6}_[A-Za-z0-9._-]{4,80}$/.test(value)) return false; + return !prefix || value.startsWith(`${prefix}_`); +} diff --git a/dockerfiles/hermes-webui-hux/activity/styles.css b/dockerfiles/hermes-webui-hux/activity/styles.css new file mode 100644 index 00000000..e8c102c8 --- /dev/null +++ b/dockerfiles/hermes-webui-hux/activity/styles.css @@ -0,0 +1,85 @@ +/* HUX-01 is opt-in. These scoped styles do not alter the existing WebUI. */ + +.hux-activity { + border: 1px solid color-mix(in srgb, currentColor 14%, transparent); + border-radius: 0.75rem; + background: color-mix(in srgb, Canvas 94%, #12394a 6%); + color: CanvasText; + padding: 0.875rem; +} + +.hux-activity-chat { + max-height: 24rem; + overflow: auto; +} + +.hux-activity-row { + display: flex; + gap: 0.625rem; + border-radius: 0.5rem; + padding: 0.5rem; +} + +.hux-activity-row:hover, +.hux-activity-row:focus-within { + background: color-mix(in srgb, currentColor 5%, transparent); +} + +.hux-activity-marker { + display: grid; + width: 1.5rem; + height: 1.5rem; + flex: 0 0 auto; + place-items: center; + border: 1px solid color-mix(in srgb, currentColor 18%, transparent); + border-radius: 9999px; +} + +.hux-tone-active .hux-activity-marker { + color: #39c6df; +} +.hux-tone-success .hux-activity-marker { + color: #4ade80; +} +.hux-tone-warning .hux-activity-marker { + color: #f59e0b; +} + +.hux-evidence-chip { + border: 1px solid color-mix(in srgb, currentColor 12%, transparent); + border-radius: 9999px; + padding: 0.125rem 0.375rem; + font-size: 0.625rem; + color: color-mix(in srgb, currentColor 65%, transparent); +} + +.hux-activity-warning, +.hux-cancellation-receipt { + margin-top: 0.625rem; + border-radius: 0.5rem; + background: color-mix(in srgb, #f59e0b 10%, transparent); + padding: 0.5rem; + font-size: 0.6875rem; +} + +.hux-activity-more { + margin-top: 0.625rem; + min-height: 2rem; + border-radius: 0.375rem; + padding: 0.25rem 0.5rem; + font-size: 0.6875rem; + color: color-mix(in srgb, currentColor 70%, transparent); +} + +.hux-activity-more:hover, +.hux-activity-more:focus-visible { + background: color-mix(in srgb, currentColor 7%, transparent); + outline: 2px solid color-mix(in srgb, #39c6df 65%, transparent); + outline-offset: 2px; +} + +@media (prefers-reduced-motion: reduce) { + .hux-activity * { + animation: none !important; + } +} diff --git a/dockerfiles/hermes-webui-hux/activity/types.ts b/dockerfiles/hermes-webui-hux/activity/types.ts new file mode 100644 index 00000000..c7e68889 --- /dev/null +++ b/dockerfiles/hermes-webui-hux/activity/types.ts @@ -0,0 +1,94 @@ +/** Safe frontend types for the opt-in HUX-01 activity timeline. */ + +export type HuxSurface = "chat" | "worker" | "telegram" | "voice" | "api"; + +export type ActivityPhase = + | "intent" + | "action" + | "evidence" + | "delegation" + | "decision" + | "completion" + | "failure"; + +export type ActivityTone = "neutral" | "active" | "success" | "warning"; + +export interface RawActivityEvent { + schema?: unknown; + id?: unknown; + seq?: unknown; + ts?: unknown; + conversation_id?: unknown; + run_id?: unknown; + parent_event_id?: unknown; + kind?: unknown; + summary?: unknown; + detail?: unknown; + evidence?: unknown; + provenance?: unknown; + sensitivity?: unknown; + redaction?: unknown; +} + +export interface SafeEvidence { + kind: string; + label: string; +} + +export interface ActivityItem { + id: string; + seq: number; + timestamp: string; + conversationId: string; + runId?: string; + kind: string; + phase: ActivityPhase; + tone: ActivityTone; + summary: string; + evidence: SafeEvidence[]; + routine: boolean; +} + +export interface SurfacePolicy { + maxItems: number; + initialItems: number; + evidenceLimit: number; + cancellationEffectLimit: number; + expandRoutineEvents: boolean; +} + +export interface MergeResult { + items: ActivityItem[]; + afterSeq: number; + invalid: number; + replays: number; + conflicts: number; + truncated: number; +} + +export interface RawCancellationReceipt { + schema?: unknown; + id?: unknown; + run_id?: unknown; + requested_at?: unknown; + acknowledged_at?: unknown; + completed_at?: unknown; + outcome?: unknown; + side_effects?: unknown; +} + +export interface CancellationEffect { + description: string; + reverted: boolean; +} + +export interface CancellationReceipt { + id: string; + runId: string; + requestedAt: string; + acknowledgedAt?: string; + completedAt?: string; + outcome: "cancelled" | "already_complete" | "failed_to_cancel"; + sideEffects: CancellationEffect[]; + omittedEffects: number; +} diff --git a/dockerfiles/hermes-webui-hux/autonomy/AutonomyControls.tsx b/dockerfiles/hermes-webui-hux/autonomy/AutonomyControls.tsx new file mode 100644 index 00000000..a8205c49 --- /dev/null +++ b/dockerfiles/hermes-webui-hux/autonomy/AutonomyControls.tsx @@ -0,0 +1,181 @@ +/** Accessible HUX-05 UI. Parent callbacks own every request and side effect. */ + +import { Ban, Hand, Octagon, ShieldCheck } from "lucide-react"; +import { useMemo, useState } from "react"; + +import { + AUTONOMY_LEVELS, BUDGET_MAX, CAPABILITIES, GRANT_DECISIONS, + autonomyControlsEnabled, buildApprovalDecision, buildPolicyUpdate, + buildStopIntent, normalizeAutonomyPage, normalizeStopReceipt, +} from "./model.ts"; +import type { + ActiveRun, ApprovalChoice, ApprovalDecisionIntent, ApprovalView, AutonomyLevel, + BudgetDraft, Capability, GrantDecision, HuxIdentity, PolicyUpdateIntent, + RawAutonomyEnvelope, StopIntent, +} from "./types.ts"; + +interface AutonomyControlsProps { + flags?: Iterable; + identity: HuxIdentity; + conversationId: string; + page: RawAutonomyEnvelope; + activeRun?: ActiveRun | null; + stopReceipt?: Record | null; + now?: number; + busy?: boolean; + onUpdatePolicy?: (intent: PolicyUpdateIntent) => void; + onDecideApproval?: (intent: ApprovalDecisionIntent) => void; + onStop?: (intent: StopIntent) => void; +} + +const LABELS: Readonly> = { + read_files: "Read files", write_files: "Write files", shell: "Run commands", + network: "Use network", web_search: "Search the web", send_message: "Send messages", + memory_write: "Save memory", artifact_write: "Change artifacts", + spend_tokens: "Spend model tokens", delegate: "Delegate to agents", deploy: "Deploy changes", +}; + +const MODES: Readonly> = { + ask_first: {label: "Ask First", help: "Pause before actions until you approve."}, + safe: {label: "Safe Actions", help: "Read-only work may proceed; external effects still pause."}, + autonomous: {label: "Autonomous", help: "Explicitly allowed capabilities may proceed within every budget."}, +}; + +function startingBudget(page: ReturnType): BudgetDraft { + return {...page!.policy.budgets, spendCentsPerRun: 0, scopeLimit: "current_task"}; +} + +function CapabilityMatrix({grants, setGrant, busy}: { + grants: Readonly>; + setGrant: (capability: Capability, decision: GrantDecision) => void; + busy: boolean; +}) { + return
    Capability matrix +

    Missing or invalid grants are denied.

    + + {CAPABILITIES.map((capability) => + + )}
    CapabilityDecision
    {LABELS[capability]}
    +
    ; +} + +function BudgetControls({budget, setBudget, busy}: { + budget: BudgetDraft; + setBudget: (next: BudgetDraft) => void; + busy: boolean; +}) { + const number = (field: keyof typeof BUDGET_MAX, label: string, step = 1) => + ; + return
    Per-run budgets +

    Zero blocks that resource. Limits are enforced together.

    +
    + {number("wallClockSeconds", "Time (seconds)")} + {number("spendCentsPerRun", "Spend (cents)")} + {number("tokensPerRun", "Tokens", 1000)} + {number("toolCallsPerRun", "Tool calls")} + {number("delegationsPerRun", "Subagents")} + +
    +
    ; +} + +function ApprovalQueue({items, busy, decide}: { + items: readonly ApprovalView[]; + busy: boolean; + decide: (approval: ApprovalView, choice: ApprovalChoice) => void; +}) { + return
    +

    Review external side effects

    + {items.length ?
      {items.map((approval) =>
    1. + {LABELS[approval.capability]}

      {approval.summary}

      + Risk: {approval.risk}. Expires . +
      + + + + +
      +
    2. )}
    :

    No actions are waiting for approval.

    } +
    ; +} + +/** Render nothing unless foundation, timeline, and autonomy are negotiated. */ +export function AutonomyControls(props: AutonomyControlsProps) { + const enabled = autonomyControlsEnabled(props.flags); + const page = useMemo(() => enabled ? normalizeAutonomyPage(props.page, props.identity, + props.conversationId, props.now) : null, + [enabled, props.page, props.identity, props.conversationId, props.now]); + const [autonomy, setAutonomy] = useState("ask_first"); + const [grants, setGrants] = useState> | null>(null); + const [budget, setBudget] = useState(null); + if (!enabled) return null; + if (!page) return
    + Autonomy controls are unavailable because identity or policy scope could not be verified. +
    ; + const selectedAutonomy = grants ? autonomy : page.policy.autonomy; + const selectedGrants = grants || page.policy.grants; + const selectedBudget = budget || startingBudget(page); + const receipt = props.stopReceipt && props.activeRun ? + normalizeStopReceipt(props.stopReceipt, props.activeRun, props.identity) : null; + + function decide(approval: ApprovalView, choice: ApprovalChoice) { + try { props.onDecideApproval?.(buildApprovalDecision(approval, choice, props.identity, + approval.requestedAt, props.now)); } catch { /* a stale approval remains denied */ } + } + + function stop() { + if (!props.activeRun) return; + try { props.onStop?.(buildStopIntent(props.activeRun, props.identity, props.activeRun.runId)); } + catch { /* an unowned run is never stopped */ } + } + + return
    +

    Autonomy

    +

    Control what Hermes may do, how far it may go, and what always pauses.

    + +
    +
    { event.preventDefault(); + try { props.onUpdatePolicy?.(buildPolicyUpdate(page.policy, props.identity, + selectedAutonomy, selectedGrants, selectedBudget)); } catch { /* invalid budgets fail closed */ } + }}> +
    Autonomy level + {AUTONOMY_LEVELS.map((level) => )} +
    + setGrants({...selectedGrants, [capability]: decision})} /> + + + + {page.rejectedApprovals > 0 &&

    + Some approval requests were withheld because they were stale or out of scope.

    } + + {receipt &&
    +

    Stop receipt

    Outcome: {receipt.outcome}.

    +

    {receipt.stopped} effects reverted; {receipt.remaining} remain.

    +
      {receipt.effects.map((effect, index) =>
    • + {effect.description} — {effect.reverted ? "reverted" : "remaining"}
    • )}
    + {receipt.omittedEffects > 0 &&

    {receipt.omittedEffects} additional effects omitted.

    } +
    } +

    + {props.busy ? "Updating autonomy controls" : `${page.approvals.length} approvals waiting`} +

    +
    ; +} diff --git a/dockerfiles/hermes-webui-hux/autonomy/endpoints.ts b/dockerfiles/hermes-webui-hux/autonomy/endpoints.ts new file mode 100644 index 00000000..4432853b --- /dev/null +++ b/dockerfiles/hermes-webui-hux/autonomy/endpoints.ts @@ -0,0 +1,48 @@ +/** Same-origin endpoint descriptors for the future HUX-05 backend. */ + +import { ACTIVITY_TIMELINE_FLAG, AUTONOMY_FLAG, FOUNDATION_FLAG } from "./model.ts"; +import { isOpaqueId, normalizeIdentity, sameIdentity } from "./security.ts"; +import type { AutonomyEndpoint, AutonomyEndpointContract, HuxIdentity } from "./types.ts"; + +interface FoundationClient { + apiVersion: string; + identity: HuxIdentity; + enabled(flag: string): boolean; + endpoint(path: string): string; +} + +function endpoint(method: AutonomyEndpoint["method"], path: string, + responseSchema: string, requiredBody?: readonly string[]): AutonomyEndpoint { + return Object.freeze({method, path, responseSchema, ...(requiredBody ? {requiredBody} : {})}); +} + +/** Describe, but never call, the tenant-scoped HUX-05 interface. */ +export function createAutonomyEndpointContract(client: FoundationClient): AutonomyEndpointContract | null { + if (client.apiVersion !== "hux.v1" || + ![FOUNDATION_FLAG, ACTIVITY_TIMELINE_FLAG, AUTONOMY_FLAG].every((flag) => client.enabled(flag))) return null; + const identity = normalizeIdentity(client.identity); + if (!identity || !sameIdentity(identity, client.identity)) throw new TypeError("Scoped HUX identity required"); + const scoped = (root: string, id: string, prefix: string): string => { + if (!isOpaqueId(id, prefix)) throw new TypeError(`Opaque ${prefix} id required`); + return client.endpoint(`${root}/${encodeURIComponent(id)}`); + }; + return Object.freeze({ + identity: Object.freeze({...identity}), + policy: endpoint("GET", client.endpoint("/autonomy/policy"), "hux.policy.v1"), + updatePolicy: endpoint("PUT", client.endpoint("/autonomy/policy"), "hux.policy.v1", + ["expected_policy_id", "expected_updated_at", "autonomy", "grants", "budgets", "guardrails"]), + approvals: endpoint("GET", client.endpoint("/approvals?status=pending&limit=100"), "hux.approval_page.v1"), + decideApproval(approvalId: string) { + return endpoint("POST", `${scoped("/approvals", approvalId, "apr")}/decision`, "hux.approval.v1", + ["expected_run_id", "expected_conversation_id", "expected_capability", "expected_requested_at", "choice"]); + }, + stop(runId: string) { + if (!/^run_[A-Za-z0-9._-]{1,116}$/.test(runId)) throw new TypeError("Exact run id required"); + return endpoint("POST", client.endpoint(`/runs/${encodeURIComponent(runId)}/stop`), + "hux.cancel_receipt.v1", ["conversation_id"]); + }, + release: Object.freeze({schema: "hux.release.v1" as const, + requiredState: "live_verified" as const, + featureFlags: Object.freeze([FOUNDATION_FLAG, ACTIVITY_TIMELINE_FLAG, AUTONOMY_FLAG])}), + }); +} diff --git a/dockerfiles/hermes-webui-hux/autonomy/index.ts b/dockerfiles/hermes-webui-hux/autonomy/index.ts new file mode 100644 index 00000000..9afd611d --- /dev/null +++ b/dockerfiles/hermes-webui-hux/autonomy/index.ts @@ -0,0 +1,12 @@ +export { AutonomyControls } from "./AutonomyControls.tsx"; +export { createAutonomyEndpointContract } from "./endpoints.ts"; +export { + ACTIVITY_TIMELINE_FLAG, AUTONOMY_FLAG, BUDGET_MAX, CAPABILITIES, + FOUNDATION_FLAG, actionAuthorization, autonomyControlsEnabled, + buildApprovalDecision, buildPolicyUpdate, buildStopIntent, + normalizeAutonomyPage, normalizeStopReceipt, +} from "./model.ts"; +export type { + ActiveRun, ApprovalDecisionIntent, ApprovalView, AutonomyPage, BudgetDraft, + Capability, HuxIdentity, PolicyUpdateIntent, StopIntent, StopReceiptView, +} from "./types.ts"; diff --git a/dockerfiles/hermes-webui-hux/autonomy/model.ts b/dockerfiles/hermes-webui-hux/autonomy/model.ts new file mode 100644 index 00000000..dc4c47d0 --- /dev/null +++ b/dockerfiles/hermes-webui-hux/autonomy/model.ts @@ -0,0 +1,191 @@ +/** 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}; +} diff --git a/dockerfiles/hermes-webui-hux/autonomy/security.ts b/dockerfiles/hermes-webui-hux/autonomy/security.ts new file mode 100644 index 00000000..8d3a3654 --- /dev/null +++ b/dockerfiles/hermes-webui-hux/autonomy/security.ts @@ -0,0 +1,59 @@ +/** Small, fail-closed validation helpers for HUX-05. */ + +import type { HuxIdentity } from "./types.ts"; + +const ID = /^[a-z]{2,6}_[A-Za-z0-9._-]{4,80}$/; +const TENANT = /^tnt_[0-9a-f]{16,64}$/; +const USER = /^usr_[0-9a-f]{16,64}$/; +const UTC = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{1,6})?Z$/; +const CONTROLS = /[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f]/g; +const SECRET = /\b(password|token|api[-_ ]?key|secret|authorization|cookie)\b\s*[:=]\s*[^\s,;]+/gi; +const BEARER = /\bbearer\s+[A-Za-z0-9._~+/-]+=*/gi; + +export function isOpaqueId(value: unknown, prefix?: string): value is string { + return typeof value === "string" && ID.test(value) && + (!prefix || value.startsWith(`${prefix}_`)); +} + +export function isUtc(value: unknown): value is string { + return typeof value === "string" && UTC.test(value) && !Number.isNaN(Date.parse(value)); +} + +export function safeText(value: unknown, fallback: string, limit = 280): string { + if (typeof value !== "string") return fallback; + const clean = value.replace(SECRET, "$1=[redacted]").replace(BEARER, "Bearer [redacted]") + .replace(CONTROLS, " ").replace(/\s+/g, " ").trim(); + if (!clean) return fallback; + return clean.length <= limit ? clean : `${clean.slice(0, Math.max(0, limit - 1)).trimEnd()}…`; +} + +export function normalizeIdentity(raw: unknown): HuxIdentity | null { + if (!raw || typeof raw !== "object" || Array.isArray(raw)) return null; + const value = raw as Record; + const tenantRef = value.tenant_ref ?? value.tenantRef; + const userRef = value.user_ref ?? value.userRef; + const surfaces = new Set(["chat", "worker", "telegram", "voice", "api"]); + if (typeof tenantRef !== "string" || !TENANT.test(tenantRef) || + typeof userRef !== "string" || !USER.test(userRef) || + typeof value.surface !== "string" || !surfaces.has(value.surface)) return null; + return {tenantRef, userRef, surface: value.surface as HuxIdentity["surface"]}; +} + +export function sameIdentity(left: HuxIdentity, right: HuxIdentity): boolean { + return left.tenantRef === right.tenantRef && left.userRef === right.userRef && + left.surface === right.surface; +} + +export function identityKey(value: HuxIdentity): string { + return `${value.tenantRef}:${value.userRef}:${value.surface}`; +} + +export function exactKeys(value: object, keys: readonly string[]): boolean { + const actual = Object.keys(value).sort(); + return actual.length === keys.length && actual.every((key, index) => key === [...keys].sort()[index]); +} + +export function boundedInteger(value: unknown, maximum: number): number | null { + return Number.isSafeInteger(value) && (value as number) >= 0 && + (value as number) <= maximum ? value as number : null; +} diff --git a/dockerfiles/hermes-webui-hux/autonomy/styles.css b/dockerfiles/hermes-webui-hux/autonomy/styles.css new file mode 100644 index 00000000..648ca24f --- /dev/null +++ b/dockerfiles/hermes-webui-hux/autonomy/styles.css @@ -0,0 +1,165 @@ +.hux-autonomy { + color: var(--hermes-text, #e8edf7); + display: grid; + gap: 1rem; +} + +.hux-autonomy > header { + align-items: center; + display: flex; + gap: 1rem; + justify-content: space-between; +} + +.hux-autonomy h2, +.hux-autonomy h3 { + align-items: center; + display: flex; + gap: 0.45rem; + margin: 0; +} + +.hux-autonomy form { + display: grid; + gap: 1rem; +} + +.hux-autonomy fieldset, +.hux-autonomy-approvals, +.hux-autonomy-receipt { + border: 1px solid color-mix(in srgb, var(--hermes-accent, #32d6d2) 30%, transparent); + border-radius: 0.8rem; + padding: 1rem; +} + +.hux-autonomy-levels { + display: grid; + gap: 0.65rem; + grid-template-columns: repeat(auto-fit, minmax(12rem, 1fr)); +} + +.hux-autonomy-levels label { + align-items: start; + background: color-mix(in srgb, var(--hermes-panel, #121a27) 90%, transparent); + border-radius: 0.6rem; + display: flex; + gap: 0.55rem; + padding: 0.75rem; +} + +.hux-autonomy-levels small, +.hux-autonomy-approvals small { + display: block; + opacity: 0.76; +} + +.hux-autonomy-matrix table { + border-collapse: collapse; + width: 100%; +} + +.hux-autonomy-matrix th, +.hux-autonomy-matrix td { + border-block-start: 1px solid rgb(255 255 255 / 8%); + padding: 0.5rem; + text-align: start; +} + +.hux-autonomy-budgets > div { + display: grid; + gap: 0.7rem; + grid-template-columns: repeat(auto-fit, minmax(10rem, 1fr)); +} + +.hux-autonomy-budgets label { + display: grid; + gap: 0.3rem; +} + +.hux-autonomy input, +.hux-autonomy select, +.hux-autonomy button { + font: inherit; +} + +.hux-autonomy input, +.hux-autonomy select { + background: var(--hermes-input, #0d1420); + border: 1px solid rgb(255 255 255 / 18%); + border-radius: 0.4rem; + color: inherit; + padding: 0.45rem; +} + +.hux-autonomy button { + border-radius: 0.5rem; + cursor: pointer; + padding: 0.55rem 0.8rem; +} + +.hux-autonomy-stop { + align-items: center; + background: #9e2638; + border: 2px solid #ff6c7d; + color: white; + display: inline-flex; + font-weight: 700; + gap: 0.4rem; +} + +.hux-autonomy-save { + justify-self: end; +} + +.hux-autonomy-approvals ol { + display: grid; + gap: 0.75rem; + list-style: none; + padding: 0; +} + +.hux-autonomy-approvals li { + background: rgb(255 255 255 / 4%); + border-inline-start: 3px solid var(--hermes-accent, #32d6d2); + padding: 0.75rem; +} + +.hux-autonomy-approvals li[data-risk="high"] { + border-inline-start-color: #ff6c7d; +} + +.hux-autonomy-approvals li > div { + display: flex; + flex-wrap: wrap; + gap: 0.5rem; + margin-block-start: 0.65rem; +} + +.hux-autonomy-warning { + color: #ffd479; +} + +.hux-autonomy :focus-visible { + outline: 3px solid var(--hermes-focus, #71f7ef); + outline-offset: 2px; +} + +.hux-autonomy button:disabled, +.hux-autonomy input:disabled, +.hux-autonomy select:disabled { + cursor: not-allowed; + opacity: 0.55; +} + +@media (max-width: 44rem) { + .hux-autonomy > header { + align-items: stretch; + flex-direction: column; + } +} + +@media (prefers-reduced-motion: reduce) { + .hux-autonomy * { + scroll-behavior: auto; + } +} diff --git a/dockerfiles/hermes-webui-hux/autonomy/types.ts b/dockerfiles/hermes-webui-hux/autonomy/types.ts new file mode 100644 index 00000000..823cb3fb --- /dev/null +++ b/dockerfiles/hermes-webui-hux/autonomy/types.ts @@ -0,0 +1,140 @@ +/** Frontend-only contracts for default-off HUX-05 autonomy controls. */ + +export type HuxSurface = "chat" | "worker" | "telegram" | "voice" | "api"; +export type AutonomyLevel = "ask_first" | "safe" | "autonomous"; +export type GrantDecision = "allow" | "ask" | "deny"; +export type ApprovalChoice = "once" | "session" | "always" | "deny"; +export type Risk = "low" | "medium" | "high"; +export type ScopeLevel = "global" | "project" | "conversation"; +export type ScopeLimit = "current_task" | "conversation" | "project"; +export type Capability = + | "read_files" | "write_files" | "shell" | "network" | "web_search" + | "send_message" | "memory_write" | "artifact_write" | "spend_tokens" + | "delegate" | "deploy" | "external_side_effect"; + +export interface HuxIdentity { + tenantRef: string; + userRef: string; + surface: HuxSurface; +} + +export interface PolicyScope { + level: ScopeLevel; + scopeId?: string; +} + +export interface PolicyBudgets { + tokensPerRun: number; + toolCallsPerRun: number; + wallClockSeconds: number; + delegationsPerRun: number; +} + +export interface PolicyView { + id: string; + owner: string; + scope: PolicyScope; + autonomy: AutonomyLevel; + grants: Readonly>; + budgets: PolicyBudgets; + updatedAt: string; +} + +export interface BudgetDraft extends PolicyBudgets { + /** Request guardrails; the backend must enforce these atomically. */ + spendCentsPerRun: number; + scopeLimit: ScopeLimit; +} + +export interface ApprovalView { + id: string; + identityKey: string; + runId: string; + conversationId: string; + capability: Capability; + summary: string; + risk: Risk; + requestedAt: string; + expiresAt: string; + status: "pending"; +} + +export interface RawAutonomyEnvelope { + schema?: unknown; + api_version?: unknown; + identity?: unknown; + conversation_id?: unknown; + policy?: unknown; + approvals?: unknown; +} + +export interface AutonomyPage { + policy: PolicyView; + approvals: ApprovalView[]; + rejectedApprovals: number; +} + +export interface PolicyUpdateIntent { + expectedPolicyId: string; + expectedUpdatedAt: string; + identity: HuxIdentity; + scope: PolicyScope; + autonomy: AutonomyLevel; + grants: Readonly>; + budgets: PolicyBudgets; + guardrails: {spendCentsPerRun: number; scopeLimit: ScopeLimit}; +} + +export interface ApprovalDecisionIntent { + approvalId: string; + expectedRunId: string; + expectedConversationId: string; + expectedCapability: Capability; + expectedRequestedAt: string; + choice: ApprovalChoice; + identity: HuxIdentity; +} + +export interface ActiveRun { + runId: string; + conversationId: string; + tenantRef: string; + owner: string; +} + +export interface StopIntent { + runId: string; + conversationId: string; + identity: HuxIdentity; +} + +export interface StopReceiptView { + id: string; + runId: string; + outcome: "cancelled" | "already_complete" | "failed_to_cancel"; + stopped: number; + remaining: number; + effects: {description: string; reverted: boolean}[]; + omittedEffects: number; +} + +export interface AutonomyEndpoint { + method: "GET" | "PUT" | "POST"; + path: string; + responseSchema: string; + requiredBody?: readonly string[]; +} + +export interface AutonomyEndpointContract { + identity: HuxIdentity; + policy: AutonomyEndpoint; + updatePolicy: AutonomyEndpoint; + approvals: AutonomyEndpoint; + decideApproval(approvalId: string): AutonomyEndpoint; + stop(runId: string): AutonomyEndpoint; + release: { + schema: "hux.release.v1"; + requiredState: "live_verified"; + featureFlags: readonly string[]; + }; +} diff --git a/dockerfiles/hermes-webui-hux/memory/MemoryControlCenter.tsx b/dockerfiles/hermes-webui-hux/memory/MemoryControlCenter.tsx new file mode 100644 index 00000000..351e58b5 --- /dev/null +++ b/dockerfiles/hermes-webui-hux/memory/MemoryControlCenter.tsx @@ -0,0 +1,346 @@ +/** Accessible HUX-02 ledger UI. No network request is made by this module. */ + +import { EyeOff, History, Pencil, ShieldCheck, Trash2 } from "lucide-react"; +import { useMemo, useState } from "react"; + +import { + MEMORY_TOPICS, + memoryControlEnabled, + normalizeMemoryPage, +} from "./model.ts"; +import { isControlReference } from "./security.ts"; +import type { + DoNotRememberTarget, + HuxIdentity, + MemoryItem, + MemoryTopic, + RawMemoryPage, +} from "./types.ts"; + +interface MemoryControlCenterProps { + flags?: Iterable; + identity: HuxIdentity; + page: RawMemoryPage; + conversationId?: string; + messageId?: string; + busyId?: string; + onApprove?: (memoryId: string) => void; + onReject?: (memoryId: string) => void; + onEditAsNew?: (memoryId: string, content: string) => void; + onForget?: (memoryId: string) => void; + onOpenSourceMessage?: (memoryId: string) => void; + onDoNotRemember?: (target: DoNotRememberTarget) => void; + onRequireSuggestOnly?: () => void; +} + +function LedgerActions({ + item, + busy, + onApprove, + onReject, + onEdit, + onForget, + onOpenSource, +}: { + item: MemoryItem; + busy: boolean; + onApprove?: () => void; + onReject?: () => void; + onEdit?: () => void; + onForget?: () => void; + onOpenSource?: () => void; +}) { + if (item.status === "forgotten" || item.status === "expired") return null; + if (item.status === "proposed") { + return ( +
    + {item.sourceIsMessage && onOpenSource && ( + + )} + + +
    + ); + } + return ( +
    + {item.sourceIsMessage && onOpenSource && ( + + )} + {item.content !== null && ( + + )} + +
    + ); +} + +function MemoryCard({ + item, + busy, + editing, + draft, + setDraft, + beginEdit, + cancelEdit, + saveEdit, + approve, + reject, + forget, + openSource, +}: { + item: MemoryItem; + busy: boolean; + editing: boolean; + draft: string; + setDraft: (value: string) => void; + beginEdit: () => void; + cancelEdit: () => void; + saveEdit: () => void; + approve: () => void; + reject: () => void; + forget: () => void; + openSource?: () => void; +}) { + return ( +
  • +
    + {item.kind} + {item.status} +
    + {item.content === null ? ( +

    + {item.contentNotice} +

    + ) : ( +

    {item.content}

    + )} +
    +
    +
    Why
    {item.reason}
    +
    +
    +
    Source
    {item.sourceLabel}
    +
    +
    +
    Recorded by
    {item.provenanceLabel}
    +
    +
    +
    Scope
    {item.scopeLabel}
    +
    +
    +
    Sensitivity
    {item.sensitivity}
    +
    +
    +
    Approval
    +
    {item.approvalMode === "ask" ? "Ask first" : "Automatic (historical)"}
    +
    +
    +
    Retention
    {item.ttlLabel}
    +
    +
    +
    Saved
    +
    + +
    +
    +
    + {editing && ( +
    { + event.preventDefault(); + saveEdit(); + }} + > + +