Local encryption v2: session-based unlock and the approval panel - #1063
Draft
theoephraim wants to merge 71 commits into
Draft
Local encryption v2: session-based unlock and the approval panel#1063theoephraim wants to merge 71 commits into
theoephraim wants to merge 71 commits into
Conversation
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.
Deploying with
|
| Status | Name | Latest Commit | Updated (UTC) |
|---|---|---|---|
| ✅ Deployment successful! View logs |
varlock-docs-mcp | cdfe9e1 | Sep 03 2026, 06:14 AM |
Deploying with
|
| 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 |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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
lockOnpolicy (screen lock / sleep / explicit only).varlock sessions,varlock lock --current/--session <id>, and a menu bar listing with per-session locking.varlock encrypt --upgrade(with--dry-run) moves them to v2.Notable fixes found along the way
bunx varlock loadrendered 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.DispatchQueue.main.syncwork 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.node_modules/.bin.Still to do
sessions.peerPosturecan default to strict.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.