Wovyr — the trust layer for AI-generated interfaces
A procurement agent reorders lab supplies. Two takes.
binary .\target\debug\wovyr.exe
policy examples/policies/default-ui-policy.yaml
state C:\Users\Punar\AppData\Local\Temp\wovyr-demo-vLt1Sh
a scratch HOME — the real ~/.wovyr is never touched
✓ server up — pid 42824, healthz {"status":"ok","version":"0.4.1"}
──────────────────────────────────────────────────────────────────────────
ACT 1 The agent is poisoned. It asks for a card number.
──────────────────────────────────────────────────────────────────────────
▸ A compromised vendor page steers the model into composing this frame:
│ text_input name: card_number label: "Card number"
│ button action: pay label: "Continue"
POST /api/v1/workflows (examples/workflows/ui-checkout-block.yaml)
✗ execution failed — the trust layer refused the frame
▸ Was it ever visible to a human? Ask the only route a renderer can pull from:
GET /api/v1/ui/frames
✓ 0 frames. It was blocked before becoming visible — not filtered after.
There is no raw-HTML node and no credential-input component in the protocol
at all, so this is a structural guarantee, not a blocklist that can be evaded.
▸ The refusal is not just a log line. It's in the tamper-evident chain:
│ action ui.frame.block
│ outcome denied
│ reason policy `default@v1` rule `sensitive_input`: input `card_number` is
│ named/labelled like a credential or PII prompt (matched token `card`);
│ generated frames must never collect credentials (policy `default@v1`)
│ actor wovyr-ui
│ seq 1 hash cdaa4ea8d12e… prev e3af956634c7…
✓ the rule that fired is named in the record — an auditor can reconstruct why
──────────────────────────────────────────────────────────────────────────
ACT 2 Take two: the safe frame. It presents, with a content hash.
──────────────────────────────────────────────────────────────────────────
POST /api/v1/workflows (examples/workflows/ui-checkout-approve.yaml)
✓ frame presented — activity confirm
│ frame_id uif-312ea719291448d8
│ frame_hash 9caabdbdab09fb7035a662b04feadf519e94095c9288ffbc35c39ef99e173786
▸ What the human will actually see (a constrained vocabulary, nothing else):
│ Confirm order
│ Reorder 3 boxes of pipette tips from LabSupply Co?
│ Vendor LabSupply Co
│ Total $412.80
│ PO number [________] (required)
│ [ Approve ] [ Cancel ]
the execution is now suspended, durably, waiting on a human
──────────────────────────────────────────────────────────────────────────
ACT 3 Now break it. SIGKILL the server before anyone decides.
──────────────────────────────────────────────────────────────────────────
✗ kill -9 42824 (no graceful shutdown, no drain, no flush)
✓ server is gone — the port refuses connections
▸ Restart it. Same state directory, brand-new process:
✓ server back up — pid 57140, healthz {"status":"ok","version":"0.4.1"}
GET /api/v1/ui/frames
│ frame_id uif-312ea719291448d8
│ frame_hash 9caabdbdab09fb7035a662b04feadf519e94095c9288ffbc35c39ef99e173786
✓ identical to before the kill — id and hash, byte for byte
✓ the pending decision survived a termination it could not catch
──────────────────────────────────────────────────────────────────────────
ACT 4 The human decides. Only inside the vocabulary the frame declared.
──────────────────────────────────────────────────────────────────────────
▸ First, something the frame never offered — action "launch":
POST /api/v1/ui/decisions/uif-312ea719291448d8
{"action":"launch"}
✗ 400 validation_failed — refused at the boundary, never reached the workflow
▸ Now the real approval, with the PO number the frame required:
POST /api/v1/ui/decisions/uif-312ea719291448d8
{"action":"approve","values":{"po_number":"PO-4471"}}
✓ decided — the workflow resumes
✓ execution completed
▸ The decision is now part of the execution's durable event log, not a side note:
│ activity_completed id: confirm
│ action approve
│ values {"po_number":"PO-4471"}
│ decided_by procurement-admin (the verified principal, not a client claim)
│ decided_at_ms 1786028797790
│ frame_hash 9caabdbdab09fb7035a662b0… (binds the decision to exactly what was shown)
✓ you cannot claim a human approved something other than what they saw
──────────────────────────────────────────────────────────────────────────
ACT 5 Prove it. The whole story, hash-linked, in order.
──────────────────────────────────────────────────────────────────────────
▸ Every record the server wrote for this session — nothing filtered out:
✓ seq 0 workflow.execution.submit demo-poisoned-1786028726 prev → e3af9566…
✗ seq 1 ui.frame.block uif-ffb833a180ec02ca prev e3af9566… → cdaa4ea8…
✓ seq 2 workflow.execution.submit demo-checkout-1786028726 prev cdaa4ea8… → 245cda8a…
✓ seq 3 ui.frame.present uif-312ea719291448d8:9ca prev 245cda8a… → 40f7ab69…
✓ seq 4 ui.decision.submit uif-312ea719291448d8:9ca prev 40f7ab69… → d57c1023…
✓ chain verified client-side: 5 entries, 4/4 prev_hash values match their predecessor
each hash is a keyed HMAC, so an attacker with full write access to the log
can neither forge an interior entry (no key) nor truncate the tail undetected
(a monotonic head anchor is written separately on every append).
══════════════════════════════════════════════════════════════════════════
What just happened, in one line each:
✗ a credential input never reached a human — structurally impossible
✓ a legitimate frame rendered, hashed, and pended durably
✓ kill -9 changed nothing: same frame_id, same frame_hash
✗ an undeclared action was refused before the workflow saw it
✓ every step is provable to an auditor, in order, tamper-evident
══════════════════════════════════════════════════════════════════════════
Self-hosted. One Rust binary. Runs air-gapped.
wovyr.com · cargo install wovyr-cli