YNAB Pulse holds two things worth protecting:
- The YNAB Personal Access Token. A PAT is not read-only — it grants full API access to every budget on the account. Treat it as a password.
- Cached financial data.
~/.cache/io.github.elevate08.ynab-glance/overview_cache.enccontains budget names, category balances, income, and spending history - sealed with the same per-install key as the token, so the file on disk is an envelope rather than readable JSON.
The adversary this design assumes is another process running as you (or as another local user) on the same machine — a browser extension host, a compromised AUR package, a shared workstation account. Remote attackers are covered by TLS; local ones are the interesting case for a desktop widget.
| Boundary | Untrusted input | Control |
|---|---|---|
YNAB API → ynab-cli |
JSON response body | Typed deserialization; response bodies capped at 16 MiB; decimal_digits clamped; currency symbols/separators stripped of control characters and capped at 8 chars; budget/group/category names stripped of control characters and capped at 120 chars; saturating money arithmetic; entity counts capped before rendering; error bodies truncated to 200 chars |
ynab-cli stdout → QML |
JSON text | Parsed in try/catch; every Text pins textFormat: Text.PlainText; plainLabel neutralizes shared button controls; no RichText, no eval, no dynamic QML |
User keyboard → ynab-cli |
The PAT | Passed on stdin, never argv; charset- and length-validated, then sealed with XChaCha20-Poly1305 before it reaches the keyring |
Disk → ynab-cli |
Encryption key file | Opened `O_NOFOLLOW |
| Local process → IPC socket | tab(index) and friends |
Index clamped to the valid tab range |
| API data → desktop notification | Formatted currency text | Sent as an argument list; never through a shell |
Disk → ynab-cli |
Cache file | Opened `O_NOFOLLOW |
- The token never appears in
argv./proc/<pid>/cmdlineis world-readable by default on Linux, so a token passed as an argument is legible to every process on the machine for as long as the helper runs — and lands in shell history for CLI users.ynab-cli auth setreads stdin and refuses an argument, so the unsafe form cannot be reintroduced by accident. - The keyring never holds the token itself. It holds an envelope:
XChaCha20-Poly1305, a 32-byte key generated per install, a fresh random
192-bit nonce per write, with the version bytes authenticated as associated
data. The key lives in
~/.local/share/io.github.elevate08.ynab-glance/token.key(0600, in a 0700 directory - under the plugin id rather thanomarchy/, which Omarchy symlinks to its root-owned system install), so a process that dumps the Secret Service gets ciphertext and nothing else. See What the encryption layer is and is not below - this is a real but bounded control. - The envelope is stored in the Secret Service (
org.freedesktop.secrets- GNOME Keyring, KWallet, or any compliant implementation), never in a dotfile. The Secret Service is what makes the credential outlive a reboot, encrypted at rest under your login password. The kernel keyutils keyring is not used: it does not survive a reboot, and its persistent keyring expires on a timeout of its own. - Revoking destroys the key along with the credential.
auth clearremoves the keyring entry, deletestoken.key, and purges the cache - so a keyring entry recovered later from a backup is not openable. In process memory it is held inZeroizing<String>, and the API client keeps no copy — it lives only in a header marked sensitive. - Redirects are refused outright (
Policy::none()plushttps_only). The YNAB API does not redirect, and refusing them removes every path by which the bearer token could be replayed to another host. - Budget ids are validated as UUIDs at three points: the CLI flag, each API
call that interpolates one into a request path, and the QML that builds a URL
for
xdg-open. An id carrying?,#, or../would otherwise silently retarget a token-bearing request or steer the browser. - Remote names cannot trip Qt into rendering markup. Qt's
Textdefaults toText.AutoText, which parses incoming strings as HTML the moment they contain markup like<img src="...">. If a collaborator on a shared budget names a category or budget with an image tag pointing to a loopback or remote host, Qt would trigger network requests to fetch the resource. EveryTextelement across the plugin's QML explicitly pinstextFormat: Text.PlainText, and labels passed to shared UI kit controls (likeButton) route throughModel.plainLabel(), which HTML-escapes markup and safely wraps entities to prevent rich-text execution. - No API data ever reaches a shell. Every subprocess is spawned with an
argument list. This matters most for the two right-click notifications (the
panel's and the bar widget's): the Omarchy bar's
run()helper executes throughbash -lc, and the amounts they display are assembled from the currency symbol and separators supplied by the API response. Built as a shell string, a symbol of$(...)would have run as a command on right-click; built as argv, it is inert text. The symbols are additionally sanitized where they enter the backend. - Upstream cannot exhaust the machine. Response bodies are refused above
16 MiB (checked against
content-lengthand again on a short read, so a chunked body that lies about its size is still caught), and the number of budgets, category groups, categories, and pie slices turned into panel content is capped — an unbounded response would otherwise make the shell build delegates until the bar hangs. The size limit alone is not the backstop it looks like: the panel nests a categoryRepeaterinside a groupRepeater, both build every delegate up front and keep it alive, so the objects a payload costs are the product of the two lists. Tens of thousands of them fit comfortably inside 16 MiB of JSON. Because a collaborator on a shared budget decides how many groups and categories it contains, that product is capped directly rather than only per list — at most 500 categories across all groups, however they are distributed. The caps are applied twice: in the helper, which decides what the panel is sent, and again inModel.boundOverview(), the single point every payload passes through before the panel binds it, so a cache written by an older build or a helper whose caps were loosened still cannot hand aRepeateran unbounded list. - The cache cannot be swapped out from under the read. The file is opened
with
O_NOFOLLOWandO_NONBLOCKand then inspected through the resulting descriptor, so a symlink planted in its place is refused by the kernel, a FIFO fails instead of hanging the widget on an open that waits for a writer, and there is no window between the check and the read in which another local process could substitute a different file. - Both state directories are named for the plugin id. Not
omarchy/: Omarchy symlinks~/.local/share/omarchyto its root-owned system install, which made the key file impossible to create, and a cache under the same name would break the same way if~/.cache/omarchyever became a symlink - silently, because cache write failures are deliberately non-fatal. A cache left over from before the rename is deleted on the next successful write and byauth clear, so no readable copy of the budget survives the upgrade. - The cache is sealed too, with the same key. The panel's data is as
personal as the credential that fetches it - category names ("Legal fees",
"Therapy") often more so than the numbers - and a scraper after financial
data has no reason to go near the keyring when a readable copy is sitting in
~/.cache. Sealing it closes that asymmetry. The AEAD tag also means a cache another local process rewrote is discarded rather than displayed: a forged "Ready to Assign" on your bar is a plausible nudge, not a harmless prank. - The payload carries no account identifier.
user_idused to ride along in the cached payload and nothing read it; the settings screen takes it fromauth statusinstead. Dropping it also removed a/userAPI call per fetch. - Revoking the token erases the cache.
auth cleardeletes the stored credential and the cached financial data — disconnecting should not leave a readable copy of your budget on disk.
It is protection against untargeted credential theft. The Secret Service is unlocked for as long as you are logged in, and any process running as you can enumerate it over D-Bus. A stealer that walks the keyring harvesting anything token-shaped - the common case, and the one that does not know or care which application an item belongs to - comes away with an AEAD envelope.
It is not protection against an attacker who is looking for this plugin. The key sits in a file readable by anything running as you, which is the same reach that let it read the keyring in the first place. Two files instead of one is a higher bar, not a wall.
Real separation would need key material that same-UID code cannot reach at will: a passphrase typed at unlock, a TPM-sealed key, or a FIDO2 token. Each of those requires you to be present when the token is read, which a bar widget that refreshes on a timer cannot ask for. That trade - unattended refresh over a stronger key boundary - is the deliberate choice this plugin makes.
The plugin ships bin/ynab-cli as a compiled binary inside the repository,
which deserves an honest accounting of what you are being asked to trust.
The repository is already the trust anchor. Installing this plugin runs
arbitrary, unsandboxed QML from this repo inside your long-lived
omarchy-shell process - omarchy plugin add prints exactly that warning
before cloning. Code in Panel.qml handles your token end to end: it reads
it from the settings UI, pipes it to the helper, and renders whatever the
helper reports. Anyone in a position to swap a malicious binary into this
repo can equally commit a malicious Panel.qml, so the prebuilt helper does
not create a new trust requirement - the repo itself was always the thing
you were trusting.
The binary is only ever born in CI. bin/ynab-cli stays listed in
.gitignore, and the only commits that touch it are made by this repo's
GitHub Actions workflow after clippy, tests, and a smoke test pass on the
exact source commit named in the commit message (ci: bundle ynab-cli <sha>). Hand-uploaded binaries are excluded by construction rather than by
policy. Tagged releases go through the same pipeline plus packaging and
checksums.
You can verify what actually landed on your disk:
PLUGIN=~/.config/omarchy/plugins/io.github.elevate08.ynab-glance
# Sigstore build provenance, anchored to this repo's OIDC identity:
gh attestation verify "$PLUGIN/bin/ynab-cli" --repo Elevate08/qs-ynab-api
# Byte-for-byte comparison against a tagged release's artifacts:
sha256sum "$PLUGIN/bin/ynab-cli" # vs SHA256SUMS on the release pageThe source-build escape hatch remains. If you would rather compile the
code you audited than audit the bytes you downloaded, ./build.sh in the
plugin directory rebuilds the helper from the checkout in front of you;
git restore bin/ynab-cli returns to the CI-built version. The committed
Cargo.lock pins dependencies for both paths, and CI runs cargo audit
against it.
- Financial data is cached locally and never leaves the machine except in
requests back to
api.ynab.com. There is no telemetry, no analytics, and no third-party endpoint. - The cache is refreshed on every successful fetch and removed by
auth clear. To erase everything manually:Deleting the key alone is enough to make the cache unreadable, but delete both: an envelope nobody can open is still a copy of your budget.ynab-cli auth clear rm -rf ~/.cache/io.github.elevate08.ynab-glance \ ~/.local/share/io.github.elevate08.ynab-glance
- The helper path is not configurable.
ynab-cliis resolved relative to the installed plugin directory, with no fallback path and no environment override, so the binary that receives your token cannot be redirected by anything that can set your environment. If that resolution ever fails, the helper does not run at all. - The keyring is only as strong as your session. Once the Secret Service is
unlocked, any process running as you can read the token from it - through
D-Bus, or out of the keyutils cache with
keyctl. This plugin cannot fix that; it only avoids widening the exposure. omarchy-notification-sendandxdg-openare resolved viaPATH. Standard for desktop tooling, but a poisonedPATHis a poisoned desktop.
Please report security issues by opening a GitHub issue marked security, or
privately to the maintainer if the issue is exploitable as described above.