Secrets & credentials
CLIs collect tokens — and too many of them drop those tokens into dotfiles. The zcli_secrets plugin stores credentials in the operating system’s keychain, namespaced to your app, with a three-method API and no plaintext fallback: if a target OS has no secure backend, the build fails rather than quietly writing a file.
Enable it in build.zig:
.plugins = &.{
zcli.builtin(.help, .{}),
zcli.builtin(.secrets, .{}),
},
The API
Three methods on context.plugins.zcli_secrets, each taking just a secret name — the plugin captures the allocator, io, environment, and app name (which namespaces your entries) off the context once at the start of the run:
// store (or overwrite) — the value is copied
try context.plugins.zcli_secrets.set("token", token);
// retrieve — null if never stored
var token = (try context.plugins.zcli_secrets.get("token")) orelse
return context.fail("not logged in — run `myapp login` first", .{});
defer token.deinit(); // wipes the plaintext, not just frees it
// ... use token.bytes ...
// remove — a no-op if absent
try context.plugins.zcli_secrets.delete("token");
get returns ?Secret — a missing secret is a null, not an error, so “not logged in yet” stays a normal branch.
Retrieved plaintext is wiped, not just freed
get hands back a Secret, not a bare slice. The backends already scrub every intermediate copy of a decrypted value they make — the subprocess stdin and stdout buffers, the base64 form, the Windows credential blob on the OS heap — and Secret extends that discipline to the last hop, the one your command actually holds: deinit zeroes bytes before releasing them.
That matters because the bytes are owned by the per-command arena, and an arena is released, not scrubbed. A bare slice would leave a decrypted token legible in reclaimable memory for the rest of the process, and into whatever reuses those pages.
The wipe covers that one buffer and nothing else — every copy you make escapes it. An Authorization header, a JSON body, the buffer of a Writer you printed through: each is a second plaintext copy deinit knows nothing about. Scrub those yourself if they outlive their use:
const auth = try std.fmt.allocPrint(arena, "Bearer {s}", .{token.bytes});
defer std.crypto.secureZero(u8, auth);
Backends
| OS | Backend |
|---|---|
| macOS | the system Keychain (Security.framework) |
| Linux | Secret Service via secret-tool, falling back to pass — chosen at runtime |
| Windows | Credential Manager |
The Linux backend deliberately shells out instead of linking libsecret — so a static musl build of your CLI stays fully static and still gets real keychain storage on desktops that have it. Autodetection prefers a live Secret Service and falls through to pass; set ZCLI_SECRETS_BACKEND=secret-service|pass to force one. Any other target is a compile error — never a plaintext file.
The helper it shells out to (secret-tool or pass) is looked up in a fixed list of standard locations — /usr/bin, /bin, /usr/local/bin, the NixOS system profile, Linux Homebrew, then ~/.nix-profile/bin and ~/.local/bin — and invoked by absolute path, never resolved through PATH. Spawning a bare name would let any PATH entry ahead of the real install be the process handed your decrypted credential on stdin. If your install lives somewhere else, symlink it into /usr/local/bin; there is deliberately no environment variable to point at it, since that would reopen the same hole.
Secret names must be valid UTF-8 with no NUL byte, no /, no .., and no leading - (the last three guard the pass filesystem namespace and subprocess argv) — validated once, up front, so the contract is uniform across every backend. Your app name is the other half of the key (entries live at zcli/<app>/<name>), so it is held to the identical rule and fails with InvalidAppName rather than composing a namespace nobody intended.
Value size limits
Two backends bound a secret’s size, and both reject oversized values cleanly with a hint — never a silent truncation:
- Windows Credential Manager caps a value at 2560 bytes.
- Linux Secret Service caps a value at roughly 6 KiB raw —
secret-toolreads the secret through a fixed 8 KiB stdin buffer, so a larger value would be truncated; zcli verifies the stored value after writing and fails with a “too large” error and a pointer to thepassbackend instead. Forcepassfor large secrets:ZCLI_SECRETS_BACKEND=pass.
The macOS Keychain and the Linux pass backend impose no practical cap.
A complete flow
The examples/ghauth example is a small GitHub auth CLI built on this plugin:
// login.zig — the auth flow is ordinary command code
const token = context.environ.get("GITHUB_TOKEN") orelse {
return context.fail("set GITHUB_TOKEN, then run `ghauth login`.", .{});
};
try context.plugins.zcli_secrets.set("token", token);
try context.stdout().print("Saved your GitHub token to the OS keychain.\n", .{});
whoami reads it back with get, logout calls delete. Nothing touches disk.
Testing commands that use secrets
addCommandTests wires an in-memory secrets stub into per-command unit tests, so a command that calls get/set tests without touching the real keychain — seed the stub, run the command, assert.