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

OSBackend
macOSthe system Keychain (Security.framework)
LinuxSecret Service via secret-tool, falling back to pass — chosen at runtime
WindowsCredential 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-tool reads 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 the pass backend instead. Force pass for 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.