PassBook

Stop pasting API keys into .env files.


The idea

Ship three apps and you get three credential stores. The same OpenAI key sits in three .env files, so rotating it means finding all three, and the one you miss is the one that breaks in production. Delete it from one project and two copies are still sitting on your disk.

PassBook fixes that by agreeing on a path instead of building a sync protocol. Every app resolves the same file by the same rule, so provisioning and linking are the same operation. The first app that needs credentials creates the store, and every app installed after it finds that file and adopts it. Nothing forks, so nothing has to be merged.

There is a desktop app and it holds no logic of its own. Every question it answers goes through the same command line an agent or a script would use. What you see in the window, you can do from a terminal.

The Keys screen, grouped by vendor, with every value hidden until you reveal it

The store is a default, never an override. A value exported into the process, or set in the project's own .env, always wins. PassBook fills in what is missing. It does not take anything over.

Install

Grab the app from releases. On Windows that is the whole thing. The installer brings its own Python and its own copy of every passbook command, puts them on your PATH, and takes them back off when you uninstall. Nothing needs to be on the machine first.

For the command line on its own:

# macOS and Linux
./install.sh

# Windows
.\install.ps1

It finds a Python, installs the commands, provisions the store, and prints what it decided. Encryption and linking need cryptography, which is not in the standard library, so setup provisions its own interpreter under ~/.hivemindos/passbook-runtime rather than touching a Python the machine already relies on. No root, nothing broken.

If you already have uv:

uv tool install git+https://github.com/LiamVisionary/passbook

The name passbook on PyPI belongs to an unrelated project. Install from the repository, not from PyPI.

Use it

import passbook

passbook.ensure(app="my-app", name="My App")   # idempotent: creates or adopts
passbook.apply()                               # fill in what the process lacks
import { ensure, apply } from './passbook.mjs';
ensure({ app: 'my-app', name: 'My App' });
apply();

Then read credentials from the environment as you already do.

For an app that should ask for what it needs rather than inherit everything:

key = passbook.request(["OPENAI_API_KEY"], app="my-app", reason="image render")

Both read the same file today, so request grants no less than apply does. The difference is that an app written against request can be moved behind a broker that says no to a key it was never granted. An app written against apply cannot.

On the command line

passbook-check OPENAI_API_KEY          # set or missing, never the value
passbook-add OPENAI_API_KEY            # prompts without echo
passbook-run -- npm run dev            # run with the store loaded as a base

Use the bare passbook-add KEY prompt. A value typed as KEY=value lands in shell history and is briefly visible to ps.

Add to PassBook

Every API page ends the same way. Here is your key, now go and put it somewhere. So you paste it into a .env, and that is how keys end up in repositories.

A platform can hand it over instead. One button on the page that just minted the key, and it lands in your vault, sealed, without touching your clipboard.

PassBook asking whether to add two keys, showing who is asking, a preview of each value, and which workspace they would go to

You see who is asking, which keys, and which workspace. Nothing is stored until you say so and open the vault.

Putting the button on your page

<button id="add-to-passbook">Add to PassBook</button>

<script>
document.getElementById("add-to-passbook").addEventListener("click", async () => {
  const request = {
    app: "OpenAI",
    note: "Read from the environment by the SDK",
    keys: [
      { name: "OPENAI_API_KEY", value: theKeyYouJustMinted },
      { name: "OPENAI_ORG_ID",  value: theOrgId },
    ],
  };

  async function hand(over) {
    for (let port = 17817; port < 17826; port++) {
      try {
        const answer = await fetch(`http://127.0.0.1:${port}/ask`, {
          method: "POST",
          headers: { "Content-Type": "text/plain" },
          body: JSON.stringify(over),
        });
        if (answer.ok) return true;
      } catch { /* nothing listening on that port */ }
    }
    return false;
  }

  if (await hand(request)) return;

  location.href = "passbook://add?app=OpenAI&key=OPENAI_API_KEY&key=OPENAI_ORG_ID";
  setTimeout(() => hand(request), 2500);
});
</script>

That is the whole integration. No SDK, no account, no key of your own.

Why the value does not travel in the link

The link exists to start the app, and it carries key names only. It is not a smaller version of the same thing.

A URL handed to an operating system's URL handler is not private. Windows passes it to the app as a command line, which any process on the machine can read, and the others log it. So values go over loopback in a request body instead. They never appear in a URL, an argument list, or a log.

The browser sets Origin, which means PassBook learns who is asking from the browser rather than from the page's own claim. That is the one part of a request a page cannot forge.

The value does not reach the window either. It stays in the app, and the window is sent a preview like sk-pr…4f21, because a window is a thing people screen-share.

FieldWhat it is
appYour product name. Shown to the person as a claim, next to the origin the browser reported.
noteOne short line about what the keys are for. Optional.
keys[].nameA plain environment variable: a letter or underscore, then letters, digits and underscores. At most 25 per request.
keys[].valueThe credential. One line, up to 8 KB. Leave it out and the person fills it in themselves.
workspaceA suggestion. The window still defaults to the active workspace and the person can change it.

Workspaces

A machine can hold several stores, and each one has its own key.

That second half is the part that matters. A workspace is not a folder. It is a set of credentials plus the thing that opens them, so picking a workspace and picking whose key you are holding is one decision, not two. Sign in to Personal and the Acme keys stay encrypted, on the same disk, in the same second.

The workspace picker showing Personal, Acme and Lab, each with its own key
passbook workspace                       # what is here, and which is active
passbook workspace use acme              # switch
passbook signin --workspace acme         # open that one, leave the rest shut
passbook signout --workspace acme        # shut it, leave the rest open

Reads layer the machine store, then the workspace store, so a workspace inherits the machine's keys and a more specific value wins. Writes go to the workspace. That is the half people get bitten by. A key added while you are scoped to a client's workspace must not appear machine wide.

Set "inherit": false to cut the machine store out entirely. Use it for anything holding someone else's credentials. Siblings never see each other either way.

Encryption

Sealing turns every value into ciphertext at rest. The store is still one file in one place, and it is unreadable until something signs in.

passbook secure          # seal it, and set up a way back in
passbook seal            # seal under the active profile
passbook unseal          # undo it

You can leave specific keys readable on purpose with --skip, which is for the feature flags an app reads before anyone has signed in.

The security screen showing what is sealed, which profiles exist, and how to get back in

Signing in

A password, a passkey, or the device itself. Touch ID and Windows Hello count as the device factor.

The sign-in screen offering a password, a passkey, or Touch ID
passbook signin                    # password
passbook signin --passkey ID       # passkey, PRF secret on stdin
passbook signin --device           # this machine's device factor
passbook signin --for 8h           # how long to stay open
passbook signout

A sign-in is held by the broker, not by your shell. It survives the terminal that started it and expires when you said it should.

Who can read what

Every key has a mode, an audience, and a scope. An app gets what it was granted and nothing else.

The apps screen showing which app may read which keys
passbook access                   # the grid
passbook scope KEY --projects a,b # narrow a key to named projects
passbook policy --learn           # build a policy from what apps actually asked for

Write the policy from what happened rather than from imagination. Run in audit mode, let it record, then turn what it saw into a starting policy. Policies written from a blank page are how things end up permanently in audit mode.

The broker

The broker is one door for credential reads, and a complete record of them. It is optional. Everything works without it, and it closes the holes in the audit trail.

Without a broker, each app opts into stamping its own reads. A vendored passbook.py in somebody else's project does not, so the ledger has holes shaped exactly like the apps least likely to be careful. The broker stamps every request it serves either way.

passbook broker start
passbook broker status
passbook broker stop

It speaks over a Unix socket on macOS and Linux, and a named pipe on Windows, restricted in both cases to the account that created it.

Read this before you trust it. A process running as you can connect and claim to be any app it likes. Nothing in the request proves otherwise. So do not read "denied" in the ledger as "an attacker was stopped". What the broker buys you is that the audit stops being voluntary, and that honest code gets least privilege.

Activity

Every read lands in a hash chained ledger. You can see what asked for what, when, and whether it was granted.

The activity screen listing reads, writes and sign-ins with what asked for them
passbook history            # what happened
passbook state              # the whole picture as JSON
passbook check KEY          # set or missing, never the value

Agents

Agents are the reason this exists. They run commands, they need credentials, and they should not be handed the whole environment.

claude mcp add passbook -- passbook mcp

Over MCP an agent asks for a named key and gets that key, recorded, or a refusal. It never implements token refresh, and it never sees the rest of the store.

Backup

passbook export --out backup.json   # sealed, portable
passbook import backup.json
passbook recovery                   # a code that gets you back in

Make the recovery code before you need it. That is the entire advice.

Command reference

Every verb is also available hyphenated, so passbook add and passbook-add are the same command. That matters for shell scripts and for agents that prefer one binary per action.

CommandWhat it does
statusWhere the store is, how many keys, whether it is sealed
checkWhether a key is set. Never prints the value
add / remove / deleteChange what is stored
get / revealRead one value, recorded
listKey names, not values
runRun a command with the store loaded as a base
secure / seal / unsealEncryption at rest
signin / signout / lock / unlockOpen and shut the vault
profile / passkey / recoveryWays back in
workspace / projects / scopeWhich store, and how narrow
access / policy / apps / agents / approve / confirmWho may read what
brokerRun the door that records reads
history / state / matrixWhat happened, and what is true now
push / rotate / services / used-inWhere each key lives, and replacing it everywhere at once
githubConnect GitHub and send keys as Actions secrets
oauthGrants that know how to refresh themselves
mcpServe the store to an agent
export / import / sync / linkMove a store between machines
install / forget / groupSet up, and tidy up

Run passbook <command> --help for the flags. The help is generated from the parser, so it cannot drift from what the command accepts.

What it does not claim

PassBook does not stop malware. A process running as you can read the store file directly, and stopping the broker restores full access. What it does is make reads recorded and narrow. It does not make unauthorised reads impossible.

It is not a secrets manager for a fleet of servers. It is for the machine in front of you and the apps and agents running on it.

Sealing protects the file at rest. It does not protect a value once an app has been granted it and loaded it into memory.

Being clear about this is the point. A tool that oversells what it stops is a tool people trust in situations it was never built for.