Skip to content

Local encryption v2: session-based unlock and the approval panel - #1063

Draft
theoephraim wants to merge 71 commits into
mainfrom
unlock-panel-v2
Draft

Local encryption v2: session-based unlock and the approval panel#1063
theoephraim wants to merge 71 commits into
mainfrom
unlock-panel-v2

Conversation

@theoephraim

Copy link
Copy Markdown
Member

WIP, not ready for review. Opened early for visibility. See "Still to do" below; several decisions are still open and the panel copy is still moving.

Local encryption v2: locally encrypted values unlock once per session instead of re-prompting every five minutes, and the unlock prompt becomes a panel varlock draws itself.

The five minute limit was never a policy, it was Apple's biometric reuse cap leaking into the product. Values now encrypt to a software identity key held wrapped by the device key, so session length is varlock's decision rather than the platform's.

What is here

  • Identity layer and v2 payloads. Values encrypt to an identity key; v1 device-key payloads still decrypt unchanged, dispatched on the version byte.
  • Daemon session model. Grants keyed by (session, key) with a 12 hour hard cap, dual wall and monotonic deadlines, and a lockOn policy (screen lock / sleep / explicit only).
  • An approval panel drawn by the daemon. It names what is being unlocked (each key, its values grouped by the file that defined them, and the value cache alongside them), and who is asking (the process chain from the launched app down to the running command, with the session root marked and per-process signature posture).
  • Two questions instead of one. How long the approval lasts (once / 10min / 1hr / custom / this session), and whether it covers the whole vault or only the values listed. The narrow answer is enforced by the daemon against the ciphertexts it was shown, not filtered client side.
  • Session visibility. varlock sessions, varlock lock --current / --session <id>, and a menu bar listing with per-session locking.
  • Upgrade path. Existing values keep working with no action. varlock encrypt --upgrade (with --dry-run) moves them to v2.

Notable fixes found along the way

  • The panel could attribute an interpreter's code signature to varlock's unsigned JavaScript. The peer check is a binary name allowlist plus kernel status bits, not an identity check, so bunx varlock load rendered as "signed" while describing bun. Interpreted code now reports as interpreted, and a posture claim is only ever stated next to the process it describes.
  • Inline biometric rendering was previously believed impossible on macOS 26. It was our own bug: presenting the panel inside a DispatchQueue.main.sync work item starves LocalAuthentication's rendering, and a bound but unrendered view also suppresses the system alert. Fixed, and guarded by a pixel check in the e2e.
  • A globally installed varlock silently shadowed a project's local one, because the child process was resolved from PATH before node_modules/.bin.

Still to do

  • Forced upgrade flow: the legacy panel should perform the upgrade rather than describing it.
  • Codesign the released CLI so sessions.peerPosture can default to strict.
  • Validate the risk based defaults against real usage. An agent session currently preselects the narrow answer, which may prove noisy.
  • Rust daemon has no panel; it reports parity values and ignores panel only inputs.
  • WSL and Windows session behaviour is unfinished.
  • Branch is 9 commits behind main.

Testing

Swift 375, Rust 137, vitest 1959. Two e2e scripts cover unlock ordering (setup prompt before the panel, no system alert during an embedded approval, no prompt raised by a lock) and a layout check pins the action row position across panel states.

Groundwork for team vaults, with no change to how existing values load.

- parse varlock() arguments as <scheme>:<payload> against a scheme table
  (only "local" is registered); an unregistered scheme now reports
  `unknown varlock() scheme "x"` instead of trying to decrypt the payload.
  Prefixless payloads keep loading as local, since base64 has no colon
- add buildVarlockReference(scheme, ciphertext) and route both write-back
  paths (the encrypt command and prompt mode) plus the copy/paste hint
  through it, so no call site assembles a reference by hand
- validate the payload version byte before dispatching to any backend, so a
  future v2 payload fails with "upgrade varlock" rather than a decryption
  error from a native binary
- thread keyId explicitly through the resolver batch queue, promptSecret, and
  the encrypt command, and move the shared default key id into a leaf module
- carry per-key requireAuth metadata from the native status output through
  decrypt routing; no binary reports it yet, so every key still gates as before
Values were encrypted straight to the device key, which ties them to one
machine and, on macOS, to the enclave's 5-minute biometric reuse window.
This adds an identity key in between: device key -> identity key -> values.

- identity.ts: software P-256 key pair stored at <user varlock dir>/
  identities/default.json (0600), private key wrapped via ECIES to each
  device key that may unwrap it. Created on first use, and creation is
  safe against concurrent first-use from multiple processes.
- crypto.ts: payload version 0x02 means "encrypted to an identity key".
  Same wire format as v1; encrypt() picks the version, decrypt() and
  assertSupportedPayloadVersion() accept both, and v3 is now the clear
  "upgrade varlock" case.
- index.ts routes on the version byte. New encryptions target the identity
  on the file backend only; hardware backends keep writing v1 and refuse
  v2 with a clear error, because unwrapping in TS would put the identity
  private key in this process.
- migrate command (hidden) rewrites v1 values in env files as v2.
- cache entries follow the same routing as env values.
- types.ts carries the session-grant and decrypt-v2 IPC shapes the daemon
  work needs. Types only, wired nowhere.

_VARLOCK_DISABLE_IDENTITY=1 falls back to device-direct v1 encryption.
Removes the _VARLOCK_DISABLE_IDENTITY escape hatch. Real rollback is
installing the previous version: v1 values keep decrypting forever, so the
worst failure is not being able to encrypt new values until a patch. As a
test seam, the explicit { target: 'device' } option on encryptValue is the
honest mechanism, and the tests now use it.

Replaces the hidden `varlock migrate` command with a hidden
`varlock encrypt --upgrade` flag, plus a hidden `--dry-run` that previews
the pass. Bare `encrypt` is unchanged: plaintext values only. With
--upgrade it also re-encrypts already-encrypted values to the current
target, through the same write-back path, leaving untouched values alone.
--upgrade with no --file covers every env file in the graph.

migrate.ts becomes re-encrypt.ts and stays command-independent. It now
takes a source (which values to pick up, and how to open them) and a
target (where they land) instead of hardcoding v1 to v2, because `encrypt`
is meant to become the single re-encryption verb: a future --to <vault>
will move values between targets, and rotation and cloud migration will
drive the same core.
Adds the Swift daemon side of identity-backed local encryption: the daemon
can hold an identity key on behalf of an unlocked session, so v2 payloads
decrypt without the key ever entering the varlock process.

Two enclave keys, doing different jobs. The existing biometric device key is
the custody key: it holds the identity private key wrapped at rest, so opening
a session always costs one user-presence check. A session key is created per
unlock with .privateKeyUsage only and lives solely in daemon memory (no
keydata file is written). At unlock the identity key is unwrapped once through
the custody key and immediately re-wrapped under the session key; only that
blob is held, and later decrypts unwrap it silently. Ending a session scrubs
the session key data, which crypto-erases everything held under it. Nothing is
persisted, so a restart simply costs one scan next time.

The single-scan unlock is driven by LAContext.evaluatePolicy and handed to the
enclave operation via the authenticated context. probe-session-unlock verifies
that on real hardware without anyone counting sheets, by using
interactionNotAllowed to turn a would-be second prompt into a failed phase.
Verified single-scan on macOS 26.1.

New daemon actions: unlock-session, decrypt-v2 (batched), list-sessions, and a
per-session form of invalidate-session. Grants are keyed by (session x key)
with once/session/duration scopes, capped at 12h from the session's first
unlock. ping now reports protocolVersion 2. prompt-secret can encrypt the
captured value to an identity public key so only ciphertext crosses the socket.
The daemon no longer idle-quits while a session is live.

ECIES gets a shared implementation covering software and enclave keys, pinned
against a fixture generated by the TypeScript crypto.ts so the two cannot drift.
Sessions were erased on screen lock and sleep alike. That is right for some
people and much too eager for others, so it becomes a policy: lockOn is
screenLock (both events), sleep (sleep only, sessions survive the screen
locking), or none (nothing but TTL expiry, the 12h cap, or an explicit lock).
The default is now sleep rather than the previous screen-lock behavior.

Resolution order is per-session override, then machine config, then the
built-in default. The override rides on unlock-session and is stored on the
session, so the lock observers judge each session individually: a screenLock
session is erased by the same event a none session in the same daemon shrugs
off. Machine config reuses the user-level config.json varlock already keeps,
under a sessions.lockOn field, read fresh at each unlock so an edit needs no
restart. It is never read from project config, since a project must not get to
weaken how long this machine holds keys. A missing file or section is silent; a
value that is present and unrecognized is reported on stderr and skipped,
falling through to the next source rather than failing the unlock.

The hard cap stays fixed, and an explicit lock still takes everything. That
last part was a gap: the menu bar Lock dropped biometric contexts but left
identity sessions held, so it now goes through the explicit-lock path.

Notification observers now do nothing but call handleLockEvent, which makes the
policy behavior testable without real sleep events. Only willSleepNotification
counts as sleep; display sleep and fast user switching are screen-lock events,
so a display that sleeps after a couple of idle minutes does not read as the
machine sleeping.

The effective policy is reported on each session in list-sessions, and on the
unlock result along with which source decided it.
A gated key now raises a panel before anything else happens. The daemon
draws it, since it is the process that verified the peer and holds the
keys, so it is the only party that can say truthfully who is asking.

- unlock-session shows the panel first, then drives the single-scan
  LAContext handoff. Cancel answers APPROVAL_DENIED; a machine with no
  window server answers NO_UI rather than skipping the question.
- a second unlock in the same session asks only about what is new, and
  asks nothing when every key requested is already covered.
- request-approval is the same panel with a generic subject, for the
  proxy. Nothing is unlocked and nothing is recorded.
- keys created with --auth-every-time never take a lasting grant: the
  panel offers once alone for them and every batch asks again.
- the client may send display decoration (value counts, project). It is
  drawn as secondary; the derived requester lines stay primary.
- first gated key on a machine gets a one-time setup panel.

The decision logic lives in IdentitySessions with no AppKit, so what to
ask, which scopes to offer, and what the panel says are all unit tested
headless.
…he wall clock

Every grant deadline is now a pair: an epoch-ms instant for display and a
CLOCK_MONOTONIC_RAW instant taken at the same moment, and whichever runs out
first ends the grant. Setting the system clock backwards can no longer buy a
session more time, and the 12h cap holds across a suspend because the raw
monotonic clock keeps counting while the machine sleeps.

Remaining time in list-sessions comes from that pair rather than from the wall
clock, so the number a client sees cannot be moved either. The unlock planner
now reads a remaining window instead of an expiry instant, which takes the
question of which clock to trust out of it entirely.
…ntext

decrypt-v2 now appends a line to an append-only JSONL log under the user
varlock dir before it unwraps anything, and refuses the call with
AUDIT_WRITE_FAILED if that line cannot be written and read back. Unlocks are
recorded the same way and hand their keys back if the record fails, so the
daemon never holds a session nothing says it opened. Invalidations are recorded
best effort: refusing to erase key material because a log line would not write
is the wrong way round.

Records carry the timestamp, session id, key ids, identity, payload count,
scope, and a requester line derived from the peer process. No plaintext,
ciphertext, or key material goes in, and the file is 0600 in a 0700 directory.
…s name

Peer verification now also asks the kernel two things about the connecting
process: whether a debugger or tracer is attached to it (CS_DEBUGGED or
P_TRACED), and whether it is running with the Hardened Runtime. Each check has
its own stderr line and its own error code, so which one fired is never a guess.

How hard they bite depends on the daemon's own code signature, which is the same
allowance the binary-name check already makes for dev builds. A daemon that is
not itself hardened, which is what `swift build` produces, reports posture
problems and serves the connection anyway, so working in this repo under bun
keeps working. A signed release daemon refuses a traced peer outright.

The hardening check only reports even in release, because the processes that
legitimately connect are often not hardened: the standalone varlock binary is
ad-hoc signed by `bun build --compile`, and Homebrew's node and bun are too.
`sessions.peerPosture` in the user config takes "strict" to reject on both
checks, or "warn" to only report.
The menu now lists each unlocked session as a submenu: which terminal or
process it belongs to, the keys it holds with their scope and coarse time left,
how much of the 12h limit remains, what will end it, and a Lock This Session
item that drops only that one. The old lock action is now Lock All, unchanged in
what it does.

A "Lock Sessions On" submenu reads and writes sessions.lockOn in the user config
file, preserving every other key in it and refusing to write over a file it
could not parse. The checkmark shows the machine default; per-session overrides
are shown on their rows and stay read-only there, since a session's policy was
settled when the user approved the unlock.

The icon is now the passive indicator: an SF Symbols closed lock when nothing is
held, an open one, with a count past the first, while sessions are live. Nothing
ticks; the menu recomputes when it opens.
…mpt-secret check passing on a timeout

The prompt-secret check accepted any failure, so it was passing on a 30s
timeout: the daemon drew the secret dialog before validating the recipient key,
and nothing was there to dismiss it. The daemon now checks the key first and
answers MALFORMED_PUBLIC_KEY immediately, and the check asserts that code with a
3s timeout. Every expectError call now names the code it expects.

The end-to-end run also restarts the daemon mid-suite and asserts that no grant
comes back with it, which is the memory-only promise the whole design rests on,
and covers the authorization log: written, growing, free of plaintext,
ciphertext, and key material, and denying decrypts and unlocks while it cannot
be written.
…d peer posture

Covers what the menu shows and what each item does, why per-session policies
are read-only there, the dual wall-clock and monotonic deadlines, the record
written before any plaintext is released, and which posture checks reject on
which builds, including why the Hardened Runtime check only reports for now.
Adds a by-hand checklist for the menu, which needs a person and a menu bar.
…can miss it

The daemon announced itself ready, built its status item, and only then turned
off the default action for SIGTERM and armed the dispatch sources that replace
it. A SIGTERM arriving in that stretch could be swallowed while nothing was
listening for it, and the daemon would sit there holding session keys with no
way left to ask it to stop. The end-to-end run reproduced it in roughly one run
in three, leaving a daemon alive against a deleted config home.

Both sources are now created and resumed before the ready line, and before any
AppKit setup, and the default action is turned off only after that. Between the
two, the worst case is the default action, which at least ends the process.

The end-to-end teardown also waits for each daemon to actually die and
escalates to SIGKILL, reporting that as a failed check rather than quietly
tidying up after a daemon that ignored a request to stop.
…t daemon

The Windows and Linux daemon now implements the same identity session ops the
macOS daemon does: unlock-session, decrypt-v2, list-sessions, and the
per-session form of invalidate-session, with protocolVersion 3 on ping.

A grant is keyed by (session x key), where the session comes from the peer
process and never from the message. Scopes are once/session/duration, capped at
12 hours, with deadlines held on both the wall clock and a sleep-inclusive
monotonic clock so moving the system clock cannot buy a grant more life. Every
authorization is appended to the same JSONL log the Swift daemon writes, and read
back off disk before any plaintext is returned.

Two things differ from macOS on purpose. There is no approval panel, since
neither platform has a trusted display for the daemon to draw on yet, so Windows
Hello (or polkit/PAM) is the only prompt. And the identity key is held in guarded
memory rather than re-wrapped under a per-session TPM key: a fixed-size,
mlock'd, MADV_DONTDUMP allocation that is zeroized when the session ends, with
core dumps disabled and PR_SET_DUMPABLE cleared at startup.

Sleep and screen lock arrive from PowerRegisterSuspendResumeNotification and
WTSRegisterSessionNotification on Windows, and from logind's PrepareForSleep and
session Lock signals on Linux. Desktop screensaver locks are not wired yet; the
daemon's ready line reports which triggers it actually has.

The ECIES v2 path is checked against the same fixture the Swift tests read, so
all three implementations are pinned to what crypto.ts writes.
Values encrypted to an identity key (v2) could only be read on the file
backend, so hardware backends refused them and kept writing device-encrypted
values. This wires them up to the daemon that can do it, which is the last
piece the identity layer needed to be usable.

Reading routes a whole batch through the daemon: one unlock-session naming
every key in the batch, carrying per-key item counts and the project dir name
as panel decoration, then one batched decrypt-v2 per key. A grant that dies
between the two comes back as NO_SESSION_GRANT and is retried once. A refusal
arrives as something a person can act on: a declined unlock says so rather
than reporting a decryption failure, and NO_UI explains that unlocking needs a
graphical session and points at --no-auth for headless hosts. Grants opened in
this process are remembered, so a second batch costs no panel at all.

Writing goes v2 everywhere, since encrypting needs only the identity public
key: no daemon, no grant, no presence check. The private half is still never
unwrapped in this process on a hardware backend. It is generated there once at
creation, wrapped to the device key immediately and dropped without being
cached, and adding a wrap for an identity created on another device is refused
rather than done quietly.

WSL is the one exception and keeps writing v1. It reaches the Windows daemon
by running the helper once per call, so each call is its own session and there
is nowhere for an unlock to live. Values there stay readable by its own daemon.

`encrypt --upgrade` and `--dry-run` are no longer hidden, now that the pass
works everywhere it can. New `varlock sessions` lists live grants as a table
or --json, and `varlock lock` gains --session <id> and --current, where
--current takes no id because the daemon resolves the caller from the
connection.

A daemon left running across an upgrade keeps serving its old protocol. The
client now reads protocolVersion from ping, and if the running daemon is too
old for the op it terminates it and lets the next connect spawn one from the
binary on disk, once, with a note on stderr. A still-old replacement means the
installed helper is old, and it says so.

The Swift helper now emits keyDetails from status, mirroring the Rust shape.
requireAuth was not recorded anywhere before: it is an access-control flag
baked into the enclave key that cannot be read back, so generate-key now
always writes the policy sidecar with both authMode and requireAuth.

This intentionally changes v1 routing for --no-auth keys. Now that a helper
reports the flag, those keys take the one-shot non-interactive path instead of
going through the daemon, which is what makes unattended hosts work without a
session. Keys created with --no-auth before the sidecar recorded it have no
record, so they keep the daemon routing they always had until regenerated.

Also passes env explicitly when invoking the native helper and spawning the
daemon. Under Bun, which the compiled varlock binary runs on, a child with no
env option inherits the env the process started with rather than the current
one, so anything set at runtime was invisible to the helper and it could read
a different key store than the caller. Node inherits the live env, so being
explicit is what makes the two agree.
unlock-session quietly fell back to the default key id when a message
arrived with no payload, or with an empty keyIds list. A client that
asked for specific keys with a malformed message got a grant for a
different key and no error, and its display metadata was dropped on the
way. decrypt-v2 already refuses a malformed message, and this is the
same kind of message.

Both daemons now answer "Missing payload" when there is no payload, and
NO_KEYS_REQUESTED when nothing resolves to a key id. The singular keyId
form is still accepted alongside keyIds, and blank entries are dropped
before the check. request-approval refuses a missing payload too.

Reading the key ids moved into UnlockRequestKeys in IdentitySessions so
the parsing is unit tested, and the empty check sits in the session
manager on both platforms so it covers every caller rather than one
call site.
The lazy-loading check reads the registration list out of the entry's
source and looks for a matching chunk in the built entry. When dist is
older than that source, a command added since the last build looks like
one that was never made lazy, which reads as a real bug and is not one.

Running through turbo cannot hit this, since test:ci dependsOn build and
a restored cache writes its outputs fresh. Running vitest directly can,
and 'bun run --filter varlock test:ci' does exactly that: the bun filter
runs the package script without turbo, so nothing rebuilds first.

Only the entry's own source is compared, since it is the only file the
assertion reads. Making every edit anywhere in src turn this red would
just be noise.
The panel and the system's authentication dialog were two windows, so an
unlock cost two gestures: approve, then scan. They are now one window.
The panel embeds an LAAuthenticationView bound to the very LAContext the
unlock will run under, armed as the panel opens, so scanning inside the
panel is the approval and no separate system dialog appears.

Nothing is modal over the panel while the prompt is armed, so the scope
controls stay live and the scan approves whatever is selected when the
finger lands. Cancel is the only refusal. A scan that does not complete
leaves the panel as it was with a Try again button; nothing re-arms on
its own, so a failing sensor cannot become a loop.

The verbose requester lines moved behind a Details disclosure. What is
left at rest is the process and terminal, the keys, the scope, and the
prompt itself.

Falls back to the previous flow (confirm button raising the system
dialog under deviceOwnerAuthentication) when biometrics are unavailable,
unenrolled, or locked out, so the device password still works. Ungated
keys are unchanged, and request-approval uses the embedded prompt only
when it asked for a biometric.

probe-embedded-unlock checks the load-bearing part on real hardware:
that a context authenticated through the embedded view still opens the
custody key with no second prompt.
…n loop

The panel's inline prompt drew its labels but no fingerprint glyph, and
the sensor did nothing. evaluatePolicy was being invoked before
NSApplication.run(), so the evaluation started with no run loop pumping
and the inline UI never attached to the view it was bound to. Because
the context is bound to that view, it does not fall back to the system
dialog either, so nothing appeared at all.

Both the probe and the panel now evaluate from inside the running loop,
and both pin the view to a real size: it reports no intrinsic size on
either axis, so a stack view is free to collapse it, and a collapsed
view draws nothing and catches no touch.

probe-embedded-unlock gains --verbose and --timeout, and always carries
a timestamped lifecycle log: canEvaluatePolicy and biometry type, that
the view is bound to the same context that gets evaluated, the view's
intrinsic and actual size, window key and app active state, when
evaluatePolicy was invoked, and its completion verbatim. A heartbeat
adds the view's subview and layer counts, which is how a prompt waiting
for a finger is told from one that never engaged.

Verified embedded-single-scan on real hardware afterwards: one scan in
the probe's own window, then two custody unwraps with no second prompt.

_VARLOCK_EMBEDDED_PROMPT=0 sends the panel back to the system dialog
without a rebuild, in case the inline view misbehaves elsewhere.
…he inline view

LAAuthenticationView is documented to render the prompt inline. On macOS
26.1 it does not: the bound view stays blank and the system presents its
own alert as a separate window. That was seen by eye in the probe and
again in the real varlock load panel, which showed a details card with
an empty square where the affordance should be.

The panel now draws its own touchid glyph and layers the system's view
on top of it. If the system renders inline its animation covers ours; if
it stays blank ours shows through. There is never an empty area, and the
wording no longer claims where the prompt will appear.

The verdict is renamed embedded-handoff-ok, because that is all it ever
established. The old name was read as proof of inline rendering, and the
two came apart: the handoff was real, the inline rendering was not.

No reliable detection signal exists, and the probe now says so. Checking
whether the bound view drew anything is a false positive, since it builds
layers and subviews whether or not it presents. Scanning for an
authentication agent window is a false negative, since none was listed
during a run that visibly presented one. Both are logged as raw
observations rather than trusted.

Bundle identity is not the explanation either: the probe behaves the same
from the signed .app bundle, and every macOS distribution shape already
ships that bundle, so varlock load was already running bundled when the
blank affordance was seen.
The daemon's approval panel never started its Touch ID evaluation. The
panel drew, with a glyph, and the sensor did nothing, and no system
prompt appeared either, because a bound LAAuthenticationView suppresses
the standard alert while its evaluation is not running. There was no
scan surface anywhere and every visible part looked right.

The IPC handler is on a background queue, so the panel is drawn inside a
DispatchQueue.main.sync work item and spins a nested modal loop there.
The main queue is serial, so the block that armed the evaluation, the
120s timeout, and the evaluation's own completion handler could not run
until that enclosing item returned, which does not happen while the
panel is up. The probe never hit this: it owns its run loop.

Everything the panel schedules now goes through MainLoop, which posts
run-loop blocks and timers in the common and modal-panel modes. The
panel also takes the front once the modal is running, and again after a
check ends, so it is not left behind whatever stole focus.

scripts/e2e-panel-arming.ts pins it: with a real gated key it asserts
the evaluation is invoked within 5s of the panel opening, and that the
_VARLOCK_EMBEDDED_PROMPT=0 fallback waits for its button instead. It
reproduced the bug before the fix and passes after. Assertions are on
the order of the flow's effects, not on timing, so someone using the
machine cannot turn it red.

_VARLOCK_PANEL_DEBUG=1 streams the panel lifecycle to the daemon's
stderr: context instance and binding, window state, the moment the
evaluation is invoked, its completion, and heartbeats.

Also adds probe-laright, a spike into whether LARight is the API that
renders inline. It is not, here: authorize succeeds while our view draws
nothing and coreautha has a window on screen. Its key path is blocked
earlier anyway, since LARightStore.saveRight fails with a missing
entitlement. Both recorded in the README.
The glyph was static, so the panel gave no sign of whether the sensor
was actually listening. It now breathes while a check is armed, shakes
when one ends without an answer, and turns green with a small pop on
approval, holding a moment so an unlock reads as finished rather than as
the window vanishing.

Which effect applies is decided in PanelGlyph, from the flow's own
state, and the AppKit side only turns an effect into animation. That
split is what keeps the glyph honest: it cannot breathe at a moment when
nothing is listening to the sensor, which is the exact impression that
made the earlier arming bug so hard to see. After a failed scan it
shakes and then rests rather than resuming the pulse, because nothing is
armed again until Try again is pressed.

Reduce Motion drops every movement and carries the same four states in
colour and strength instead. Writing that test turned up a real gap:
idle and armed had rendered identically without motion, so a user could
not tell whether the sensor was live. Idle is now dim and armed is full
strength.

Core Animation throughout rather than NSSymbolEffect, which needs macOS
14 while this package targets 13.

The glyph is pink rather than the accent blue it had been, to sit closer
to the system's own Touch ID art. The blue was never a brand decision;
it is one constant to change if that is wrong.

Also ignores SwiftPM build output in eslint, which otherwise fails the
repo lint for anyone who has built the Swift package.
The panel can say who is asking on its own authority, but it cannot know
what the values are called: that lives in the env graph in the client. So
an unlock now carries the value names and the files that defined them, per
key, alongside room for a vault label and colour.

All of it is display only. Nothing is bound into the crypto and the daemon
checks none of it, so a stale or wrong name costs a cosmetic mismatch
rather than a failed unlock.
…g for

The panel was an NSAlert with lines of text under it, which put the key ids,
the process, and the scope choice on the same flat footing and could say
nothing about what a key actually opens.

It is now a window we draw: a top bar that names varlock, a heading that
says which keys and for which project, and one card with a row per key that
opens to the value names grouped by the file that defined them. That detail
is client-reported and the open row says so, because the daemon cannot know
what an env value is called and should not lend its credibility to a string
a caller sent.

The approve action carries the Touch ID glyph itself, so the scan and the
approval are one object rather than two things near each other, and a
machine without a sensor gets the password path as its primary button
instead of a link to it. No inline LAAuthenticationView: it draws nothing
on current macOS, and the glyph we draw breathes exactly while a check is
armed.

Adds panel-preview, which renders the same view tree to a PNG without
asking anyone anything, because a modal floating window is otherwise not a
thing you can look at on a headless session.
A pid is not an answer and one process name is barely one: "bun" tells a
person nothing, while "agent.ts, via bun, launched from iTerm2" tells them
whether this is their own work or something they should refuse.

The daemon already knows the peer's pid, so it now walks the ancestry it
can read: executable paths, the script an interpreter was handed, the app
bundle at the top with its icon, the controlling terminal, and each hop's
code-signing posture. The hop that decides what runs is emphasised, shells
and varlock itself are minor and fold away on a long chain, and an
interpreter never lends its signature to the script it was given.

A coding-agent session is named by product and start time, found from the
process or from the environment it exported. No uuid: the id is for the
audit log, where a machine reads it.

Every read is best effort and bounded by a deadline. A tree the daemon
cannot read costs the panel its detail, never its appearance.
…e chain read

demo-panel.ts walks the panel's states against a scratch config home and
ungated keys, so it can be looked at without a fingerprint and without
touching the real key store. The agent state re-runs the demo as a child
under bun with an agent environment, because the chain is read off whoever
connects and a faked one would prove nothing.

The arming check now also asserts that the ancestry read happens, finishes
quickly, and is over before the panel is drawn. That work touches other
processes and is exactly the kind of thing that can quietly grow into a
delay nobody notices until an unlock feels slow.
The session a request came from was a badge floating under the chain, which
read as a footnote about the panel rather than as part of the ancestry. It
is now a hop where it really sits: a tinted row tagged session root,
carrying the agent, the session's own title, and when it began, with the
rail below it tinted so everything running inside that session is a span you
can see. It is never folded away.

The title comes from the agent's own record of the session, matched by pid
and checked against the process start time so a recycled pid cannot put
somebody else's session on the panel. A name that is really a uuid is
dropped: an id is not something a person can check anything against.

Warnings moved onto the hop they concern, because a legend at the bottom of
the chain is a warning the reader has to match back up to a row. Icons are
real where a real one exists: the app's own icon from its bundle, the
agent's app icon, and a small tile for a tool with no bundle to ask. None of
it is resolved on the path that draws the panel.

The panel is wider so a binary path fits without truncation, and Deny is red
with a stop mark: it is a refusal, not the second of two equal choices.
…rvives

The title is the part a person recognises, and a title squeezed onto the end
of the row was the part that got truncated. The session-root row now carries
the agent, the tag, and the start time on one line, and the session's own
title on its own line where it can wrap. That row is allowed to be taller
than the others: it is the one most likely to change the answer.
…ation pick work

Selecting a scope bolded its label, and a bolder label is a wider one, so
every change of mind nudged the control and everything under it. Selection
is colour now, with the same weight in every state.

The duration choice was offered and then did nothing. Clicking the segment
opens the windows, clicking it again reopens them, the label says which one
was picked, and that window is what the grant is asked for. A menu that
cannot be drawn steps to the next window rather than leaving the control
dead. 12 hours joins the list, which is the session cap itself: a window the
grant table would have clipped was a choice that lied.
`bunx varlock load` runs varlock's dist JS out of node_modules under bun. The
peer process is bun, and bun is Developer-ID signed with the Hardened Runtime,
so the kernel's status word says "valid signature, hardened runtime" and every
word of that is about bun. The chain builder exempted varlock's own CLI from the
interpreted-script rule, so the varlock row took that answer as its own and drew
a green mark and the word "signed" over a directory of ordinary files any process
running as the user can rewrite.

The exemption's stated reason was that "the daemon verifies the peer's code
signature before it will speak to it at all". It does not. verifyPeerProcess
checks the peer's binary NAME against an allowlist that contains node and bun
precisely so that varlock's JavaScript can connect, and the posture check reads
the same status word, about the same interpreter process.

So an interpreted hop is now interpretedScript whatever it is running, and the
interpreter's real posture is carried beside it to be stated next to the
interpreter's own name, never on its own. The row still says "varlock", and now
says in words which varlock it is: the standalone binary, or its JavaScript run
by bun. Posture also splits signedOnly from unsigned, which were one answer
under a name that only described one of them.

Also: resolve the entry script for varlock too (it is how the panel answers
"which varlock"), read the version off the package.json that owns that file, and
show a launcher's whole bundle path instead of the directory it sits in, which
for anything in /Applications read "/Applications".
…it is

Claude Code's session record carries more than the title and start time the
panel was reading. Two of those fields change a decision:

  kind: an interactive session has a person in front of it who will see what
  happens next. A print or headless one does not, and "approve for this
  session" then means approving for something that keeps going unobserved. Said
  out loud on the session row, and only on positive evidence: a record with no
  kind gets no line, because "the agent did not say" is not the same fact.

  cwd: an agent working in one project asking to open another project's secrets
  is exactly the shape this panel exists to surface. Compared against the
  project the client named, on canonical paths, so a /private prefix, a symlink,
  or a worktree reached by another route is not an anomaly, and on whole
  components, so /a/project-two is not inside /a/project. Silence when either
  half is missing.

nameSource tells us when the agent generated the name itself. A generated name
is still worth showing (it tells two sessions apart) but it is not somebody's
words, so it loses the quotation marks, which are what say a person wrote this.
entrypoint and version become expanded detail.

All of it stays what it was: display-only, bounded, timeout-read, and never an
input to any decision the daemon makes.
…mething

Three changes to the chain, all of them about a reader being able to tell what
they are looking at.

A coloured dot is a legend nobody was given. Every posture answer now has its
own shape (a ticked shield for what was checked, a warning triangle for code
that was not, a question mark for a process the kernel would not discuss), its
own word once the chain is opened, and a tooltip spelling out in sentences what
was checked AND what was not. A blank space used to stand for "we are not
saying", which on a panel reads as "nothing to report": the opposite fact.

`$ varlock load` was the same small grey text as every note around it, and it is
the one line a person can match word for word against what they typed. It gets a
tinted strip, a dimmed sigil, and a monospaced face, elided in the middle so a
`--` target survives. The auto-load line's host command gets the same.

Evidence moves to full-width lines under the hop it belongs to. It used to be
crammed into the right-hand end of a row, where the Claude Code hop's path had
about forty points to live in and truncated to "~/Libra...2.1.234", which looked
like a mangled version string and was a real path with its middle eaten. The
expander now appears whenever there is anything behind it, rather than only when
hops fold away: a three-hop chain is the commonest panel there is, and its paths
and signatures were unreachable.
A `-dev` or `-preview` suffix says the running code is not the artifact the
release pipeline produced, which is worth noticing before approving. Where
varlock runs as JavaScript the daemon resolves the package on disk and reads the
version itself, so that answer is stated flatly. The compiled binary carries no
package to read, so the client sends its own and the panel draws it as
"1.17.1 (reported by the caller)" rather than as something established.

`panel-preview` also learns to take the process tree from its payload. The
situations worth looking hardest at are all awkward to arrange on purpose, and
it runs the same ExecutionChainBuilder over written-down facts rather than
adding a second rendering path.
…a key with

A cache-triggered unlock drew a bare panel: no project, no counts, nothing
saying it was the cache. That is the worst request on the panel to approve
blind, because cached values are what came back from 1Password and the other
providers rather than what is written in a .env file.

The key row's expansion becomes a list of SOURCES rather than a list of files.
A source is an env file with the value names it defined, or the value cache
with how many cached values it holds and which plugins and files filled it, and
they are drawn identically. Grouping by key is the mechanism: one key is one
grant, so everything that grant opens belongs in one list under it, and a
source kind added later slots in beside them with no new shape. That also makes
a request covering both trivial: one row, two sources.

CacheStore now describes itself for the panel, built from the cache file it has
already read, so it costs no extra IO. Display only and client-reported, like
every other line a caller sends: nothing reaches the crypto and the daemon
checks none of it. Cache keys themselves are never drawn, only the producers
they group under, since a key can spell out which item in which vault was
fetched.

A row whose caller reported nothing now says "contents not reported" instead of
leaving a blank that reads as "there is not much in here".

panel-preview gains "expandKeys", since the list is the part worth
photographing and a still cannot click.
…d first

An unlock is a session grant: the panel it draws is the only one the user
sees, and everything that asks afterwards rides it silently. The panel was
built from the payloads in the batch that triggered it, so a run with
encrypted values in .env.local and a populated value cache showed whichever
of the two happened to ask first, and the other opened moments later on an
approval that never mentioned it.

The run now declares what a grant covers before anything asks for it. The
graph declares its encrypted values at the end of its load, from the
resolvers that will actually run, and the value cache declares what it holds
when it becomes the run's store. The unlock reads the union of those with
whatever the batch itself carries, so the order the callers arrive in cannot
change what the panel says.

The header count now follows the merged source list rather than being
reported separately, so it can no longer disagree with what is listed under
it. Nothing waits on a declaration that has not happened: a source nobody has
declared yet is simply not listed, which under-promises rather than over-.
The sizes in an opened key row are the one thing on those lines a reader
compares rather than reads, and ".env - 8 values / .env.local - 4 values /
value cache - 12 values" makes that a matter of reading the same words three
times. Each source now carries its name and a small pill holding the number,
in tabular figures at a fixed height, so a column of them lines up and
compares at a glance.

Only the sources are badged. The row's own total stays "24 values", because
it is a summary line and not one of a series, and the cache's producer chips
keep their "1password - 8" form, since those counts are part of what each
chip says rather than a measure of the line it sits on.

A source whose size the client did not report draws no badge at all: an empty
pill would still be a claim, and a zero would be the wrong one.
The panel has offered one axis: how long an approval lasts. It now offers
two. Alongside the duration, you choose how much of the key an approval
opens: only the values on the panel, or anything that key can decrypt.

The narrow choice is enforced by the daemon rather than filtered by the
client. An unlock now carries the ciphertexts a grant will be asked to
open; the daemon hashes them itself and, when the narrow option is taken,
binds the grant to those digests. A later decrypt-v2 carrying anything
outside the set is refused whole, without charging the grant, and raises
the same delta prompt a brand-new key does. Nothing a client says about a
payload can widen what its grant covers.

The value cache is never item scoped, and the panel says so where the
choice is made. Its entries are machine-written and rewritten whenever a
cached value is renewed, so binding a grant to them would put a panel in
front of a normal dev loop. A cache read is covered by the daemon reading
varlock's own cache file at a path it computes, not by believing a caller
that says a payload came from the cache.
…ot as copy

The preselection rules were finding "this session is working somewhere
else" by matching the prefix of the sentence the panel draws for it. That
ties what varlock preselects to how a line of copy happens to be worded,
so the next person to improve the wording would silently switch a risk
rule off. The fact gets its own predicate and both callers read it.
Running `cargo test` in the Rust helper leaves generated JSON under
target/, which eslint then reads and reports on. SwiftPM's .build is
already ignored for exactly this reason; cargo's target was missing.
The pill pair presented breadth as a decision with two equal answers. It
is a setting with a default, and the default is broad, so it is one
checkbox: "Cover anything this vault can open", ticked. Worded in what it
covers rather than in what it switches off, which is the difference
between describing the grant and describing the plumbing.

It sits in one place, directly under the scope control, whatever the
request names. It does not move into vault rows when there are several
and back out again when there is one: a control you have to find before
you can read it is no good on a panel meant to be read in a second.

The summary sentence now frames the list rather than following it. Showing
twelve values and then opening a thirteenth was the thing that felt wrong,
so a ticked approval reads "covers anything this vault can open, not just
the 12 listed above": the list is what the grant covers now, not what
defines it. Unticked it says "only", because there it is the definition
and the daemon enforces it.

The vault is a boundary rather than a control. A broad approval reaches
other keys inside the vaults the panel showed and never one it did not,
whatever the checkbox says. Every key is in one implicit local vault
today, so it mostly holds trivially; it is written as a vault rule anyway,
since that is the only version that survives a second vault.

Per-vault breadth is deferred, not rejected. Breadth resolves per vault
internally (UnlockBreadthSelection) even though one checkbox sets it for
all of them, so adding a control per vault later is a change to the panel
and nothing else.
Breadth is not fully moot under once: a single batch can still carry a
ciphertext the panel did not list, through the gaps in what a run can
declare up front (an @cache condition resolved before the graph described
itself, a caller that never went through the graph, a fallback branch
nobody took on the pass that built the inventory). So there is a real
distinction, and this picks a half.

It keeps the narrow half. "Once" already means "just this, right now" to
anybody reading it, and a control offering "once, but also whatever else
turns up" contradicts the word above it. The case where the difference
bites is exactly the case where a prompt is wanted: the batch holds
something the panel never showed.

Hidden, not disabled. A greyed control asks why it is greyed, and the
honest answer is a paragraph about batch composition, which is not
something to make somebody read with a sensor waiting for their finger.
The checkbox hides inside its own holder and the summary is pinned to its
two-line height, so nothing under them moves as the scope changes; all
three scopes now render to the same panel height.

The summary still states the breadth, so this is not hidden state: under
once it reads "Covers only the 12 values listed above, for this one read."

Memory is untouched by it. Once is a duration answer, so the decision
carries no breadth choice at all, and an absent choice leaves that axis
exactly as it was. A narrowing chosen earlier survives, and one that was
never made is not invented, so picking once today and this session
tomorrow finds breadth back at its own resolved value.
The NSMenu is gone. It broke twice for unrelated reasons, once unwired and
then with every option greyed out because AppKit validates menu items down
a responder chain that does not reach the panel from inside its modal
session. It was the last surface here drawn in the system's appearance
rather than the panel's own, and it carried a fallback that stepped to the
next window when the menu would not open, which let a failure to draw
silently change what somebody was about to approve.

Four fixed options fit on one row, so they go on one row: revealed under
"For a set time", drawn with the panel's own segmented control, visible
without a click and chosen in one. Presets only. Typing a duration with a
sensor armed invites unit ambiguity and a validation error on a security
prompt, and tends to push people to the maximum anyway; the 12h cap is the
last option and is not settable here.

The scope segment still reads back the chosen window ("For 4 hours"), now
without the caret, which pointed at a menu that no longer opens.

The row reserves its height inside a plain holder like the breadth
checkbox, so switching scope moves nothing under it: all three scopes
render to exactly the same panel height.

Also removes what the menu alone was keeping alive: DurationPreset.next
and its test, PanelSegmentedControl's onReselect and view(at:), and a
click on an already-selected segment now does nothing rather than firing.
The panel asked "how long" with two controls: a mode pill (this session,
once, for a set time) and, revealed under it once "for a set time" was
picked, a row of windows. Two controls for one question, the second one
hidden most of the time, so a timed answer cost two clicks.

Worse, the reveal is what forced the panel to hold empty rows open. The
duration row and the breadth checkbox both reserved their height so every
answer produced the same window height, and under "once" neither is drawn:
a two-row band of nothing above the buttons.

Now there is one row, shortest first: Once, 1h, 4h, 8h, 12h, This session.
Nothing in it hides, so it reserves nothing, and the order is itself
information: it reads as a ladder you are picking a rung on. Segments size
to their own labels, so "This session" is wider than "4h" and the row still
fits the panel. Presets only, and the 12h cap is still the last rung and
still not settable from here.

The breadth checkbox still goes away under "once", and its row now goes
with it rather than being padded out. Equal heights were never the property
worth guaranteeing: what matters is that Deny and the sensor do not move
under a pointer while the scan is armed. The action row sits a fixed
distance above the panel's bottom edge, and a change in the approval
controls re-anchors the window to that edge, so the content above absorbs
the difference and the buttons stay where a finger was already heading.

panel-preview now reports the height and where the action row landed, and
scripts/panel-layout-check.ts renders every answer, with the disclosures
open and closed, and asserts that distance never changes.
Re-anchoring to the bottom edge lets the panel grow upward, and one
already sitting at the top margin would push its own heading off the
screen. Steadiness on the action row is worth a lot, but not the line
that says what is being approved, so in that one case the buttons move
instead.
The ladder was Once, four clock rungs, This session. Most of the row's
width went on the options people reach for least, and the person who
cares enough about duration to change it usually wants a number nobody
guessed. So the presets are now the two shapes a timed approval usually
has, 10min and 1hr, and the rest of the range is one rung you set.

Ten minutes is the short end on purpose: a window here is a guard around
a task, not a convenience that lasts the afternoon. The 12h cap is still
the ceiling and is no longer a rung, so reaching it means naming it.

Custom reveals a number and a min/hr toggle under the row, drawn in the
panel's own chrome, and the rung then wears the value instead of the
word so the ladder still shows its answer. Re-selecting it puts the
caret back in the field. The row collapses when Custom is not selected;
nothing reserves space for it, because the action row is anchored to the
bottom of the panel.

Typing beside an armed sensor is the part that needed care, and it is
handled by a rule rather than by a state machine: there is no committed
value behind the field, so what an approval carries is the clamped
reading of the text at that instant, and the summary sentence is
rewritten from the same reading on every keystroke. A partial number is
a prefix, so a scan mid-word can only ever land shorter than what was
aimed at. Return commits and does not reach the window's confirm
handler; Escape still refuses the panel. Nothing is ever invalid: empty,
zero, nonsense and over the cap all read as the nearest legal window.

A custom value shorter than the default is a narrowing like any other,
so it rides the existing defaultDurationMs path: remembered, then
preselected on the custom rung, showing itself, with the field primed.
No second mechanism, and the preselection is still one rule.

The device-key panel says what it grants, too. It offered "Once" while
the legacy path hands macOS a five minute reuse window, so approving
really covered up to five minutes and the panel's own note admitted it.
It states the real window now, as a fact rather than a control, since
macOS owns that window and this path cannot set it. The number comes
from the constant, so the copy cannot drift.
… checkbox does

Two pieces of feedback on the ladder that landed last commit.

The custom rung wore its value once one was set, which put a free value
at a fixed position in an ordered row. It broke the order the row exists
to show (45min sitting to the right of 1hr), and a value that happened to
land on a preset read as a duplicate of the rung beside it. It reads
Custom now, always. Nothing is lost by that: the field row is on screen
exactly when the rung is selected, so the number is already in front of
the reader, and the summary sentence states it in words. The value still
sets the rung's window, so a remembered custom answer matches it exactly
and comes back selected with the field primed.

That takes the relabelling machinery with it: the reserved segment width
existed only to stop a renamed rung reflowing the row, and every label is
fixed for the life of the panel again. The field's unset value was seeded
at 2h to make the fresh row read ascending, which was the same paper over
the same crack, so it is 30 minutes now under a rule that survives on its
own: shorter than the longest preset, so an untouched control never
asserts more than the row was already offering.

The breadth checkbox said "Cover anything this vault can open", which is
an accurate description of the grant's extent and leaves the reader to
work out what it means for them next time something is decrypted. It says
"Auto-unlock all items in this vault" now. The sentence underneath still
does the precise work, which is why the label does not have to.
@cloudflare-workers-and-pages

Copy link
Copy Markdown

Deploying with  Cloudflare Workers  Cloudflare Workers

The latest updates on your project. Learn more about integrating Git with Workers.

Status Name Latest Commit Updated (UTC)
✅ Deployment successful!
View logs
varlock-docs-mcp cdfe9e1 Sep 03 2026, 06:14 AM

@cloudflare-workers-and-pages

Copy link
Copy Markdown

Deploying with  Cloudflare Workers  Cloudflare Workers

The latest updates on your project. Learn more about integrating Git with Workers.

Status Name Latest Commit Preview URL Updated (UTC)
✅ Deployment successful!
View logs
varlock-website cdfe9e1 Commit Preview URL

Branch Preview URL
Sep 03 2026, 06:16 AM

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant