Skip to main content

saddlebag

Carry the right credentials for every ride.

saddlebag holds the secrets a QA run needs and puts each one where it is consumed, so that an agent driving a workhorse workflow never holds a secret in its context. A secret leaves the store in exactly two ways. fill types it into a field of a browser over the Chrome DevTools Protocol. exec runs a command with it set in that command's process environment. Neither hands the value back to whoever ran them. Around the store, env reads a dotenv file with its secret lines masked, and guard is the Claude Code hook that refuses the tool call which would have printed a secret.

One daemon holds the HashiCorp Vault token, and every other command is a thin client over a unix socket. That is what lets the same tool serve a run on the host and a run in a container: the container sees the socket and nothing else.


Position in the ecosystem

Tool Job
ostler Tends the knowledge graph: epics, stories, seeds, specs
farrier Fits the shared prompt library onto each repo
workhorse Runs the workflow graph unattended
saddlebag Carries secrets and fills them where they are consumed

Ostler owns the spec of what a test needs and which environment it runs against. Saddlebag owns the secret material that satisfies it. Ostler's stack bring-up asks saddlebag for the variables a stack needs, as described under The ostler stack.


Install

Saddlebag publishes to PyPI as stablemate-saddlebag, because the name saddlebag is unavailable. The command and the import package stay saddlebag.

uv tool install stablemate-saddlebag
pipx install stablemate-saddlebag

The stablemate package installs it along with the other tools (uv tool install stablemate). From a checkout of the stablemate workspace:

uv run --package stablemate-saddlebag saddlebag --help

Requires Python ≥ 3.12 and a reachable HashiCorp Vault with a KV v2 mount named secret. For a developer machine, vault server -dev is enough.


Sixty seconds

# 1. Start the daemon. The token arrives on stdin and stays in the daemon's memory.
export VAULT_ADDR=http://127.0.0.1:8200
printf '%s' "$VAULT_TOKEN_FROM_YOUR_LOGIN" | saddlebag serve

# 2. In another shell: describe a secret, then give it its value on stdin.
saddlebag secret create normal_login \
  --username gab \
  --description "Log in as a normal user" \
  --var LOGIN_PASSWORD \
  --tag environment=local --tag role=user
printf '%s' "$PASSWORD" | saddlebag secret set normal_login

# 3. An agent finds it by tag and reads the description. No value is printed.
saddlebag list --tag environment=local

# 4. Deliver it. Into a browser field, or into a command's environment.
saddlebag fill normal_login --field username --selector "#login_field" --url-contains example.com
saddlebag fill normal_login --field password --selector "#password"    --url-contains example.com
saddlebag exec --tag environment=local -- docker compose up

The browser the fourth step talks to is one you started with a debugger port:

chromium --remote-debugging-port=9222 --user-data-dir=/tmp/qa-profile

Saddlebag connects only to a DevTools endpoint on loopback and refuses any other host. That port is unauthenticated and grants total control of the browser.


The daemon

saddlebag serve is the one process that talks to Vault. It reads the token from stdin when piped, else from the OS keyring where saddlebag serve --keep-token stored it, else from saddlebag/vault-token under the stablemate state dir when that file is mode 0600. It refuses to start if VAULT_TOKEN is set in its environment, and listens on a unix socket created with owner-only permissions. Every other verb connects to that socket, sends one request and reads one reply.

Backend $SADDLEBAG_BACKEND (keyring or daemon), else backend under [saddlebag] in the shared stablemate config.toml, else a daemon that answers on the socket, else the OS keyring
Socket path $SADDLEBAG_SOCKET, else $XDG_RUNTIME_DIR/saddlebag.sock, else the OS runtime dir
Vault address $VAULT_ADDR or --vault-addr
Browser --cdp, default http://127.0.0.1:9222

saddlebag never puts the token in an environment variable, because a process of the same user can read /proc/<pid>/environ. The kept token lives in the OS keyring. The filed token is the operator's choice, and doctor warns about it because any file the user owns is readable by their other processes. For the same reason the operator's own Vault login must not leave ~/.vault-token behind, and doctor warns when that file exists.

A run in a container

Mount the socket and name it. The client inside the container talks to the daemon on the host, which is also where the browser and its debug port live.

docker run \
  -v "$SADDLEBAG_SOCKET:/run/saddlebag.sock" \
  -e SADDLEBAG_SOCKET=/run/saddlebag.sock \
  ...

Secrets and tags

A secret has a name, an optional username, a description an agent reads to choose between candidates, an optional var naming the environment variable exec fills it into, a set of key=value tags, and a map of printable metadata such as a phone number or a role, given with --meta key=value and changed later with secret meta and secret unmeta. It holds up to two values: a password and a totp enrolment seed. The username and the metadata may print. The two values never do.

{
  "name":        "normal_login",
  "username":    "gab",
  "description": "Log in as a normal user",
  "var":         "LOGIN_PASSWORD",
  "tags":        [["environment", "local"], ["role", "user"]],
  "metadata":    {"phone": "+15555550100"},
  "fields":      ["password"]
}

Tags are the whole query vocabulary. list --tag environment=local --tag role=admin returns the secrets carrying both pairs. tags lists the distinct pairs in use. The convention the ostler integration relies on is that a secret for a QA environment carries environment=<slug of the ostler environment page>.


Commands

serve run the daemon; token from stdin, the OS keyring or a 0600 file; --keep-token stores one
list tags query by tag; neither can emit a value
secret create set unset remove tag untag meta unmeta manage a secret; set reads its value from stdin; meta K=V adds or overwrites printable metadata, unmeta KEY drops it
fill type a secret's username, password or current TOTP code into a browser field
exec run a command with matching secrets in its environment
env show keys classify read a dotenv file with its secret values masked, list its keys with the rule that marks each secret one, or print the verdicts as JSON
env import store a dotenv's chosen keys as secrets, one record per key, without printing a value
guard --hook guard install the Claude Code PreToolUse hook that denies a secret-printing tool call, and its installer
doctor backend and route in use, store reachable, secrets with no value, orphan keyring items, stray token file, and on the daemon route where its token came from
saddlebag fill NAME --field username|password|totp --selector S [--url-contains X]
saddlebag exec [--tag k=v]... [--env VAR=NAME[.field|.meta.KEY]]... -- cmd args...
saddlebag env show|keys|classify FILE [--json]
saddlebag env import FILE --keys KEY[,KEY...] [--tag k=v]... [--prefix TEXT] [--json]
saddlebag guard install --user|--project

exec fills each tag-matched secret's var with its password. An explicit --env VAR=NAME.field binding may name any field, including the username, the current TOTP code and a metadata key as NAME.meta.KEY. A secret matched by tag that has no var is an error naming it, not a silent skip.

What each verb guarantees. fill reports a character count, a selector and a URL. The value is typed through Input.insertText and never interpolated into an evaluated expression. A TOTP seed leaves the store only as a computed six-digit code, and only by being typed. exec masks what the command prints when its output goes to a pipe, which is how an agent's tool call runs it. Saddlebag stays as the command's parent and copies its stdout and stderr through, with each secret value replaced by [REDACTED]. It masks the value as written and in the forms tools escape it into: compose's $$, URL encoding and JSON string escaping. It looks for no other encoding, such as base64. A value shorter than 8 characters is not masked, and exec names its variable in a warning on stderr. A username bound with --env is metadata and is not masked. When stdout and stderr are both a terminal, exec becomes the command and masks nothing, so colors, -it and progress bars keep working for a person at the keyboard.

Masking catches the leak an agent makes by mistake, such as running docker compose config under exec. It does not stop a command written to leak, and it does not reach what the command writes to a file. Saddlebag itself never writes a value to disk. The agent's permission layer decides which commands it may run under exec.


A dotenv file

A .env mixes literals with secrets, so reading it is the commonest way a value lands in an agent's context. env show FILE prints the file with each secret value replaced by [REDACTED], and ends with masked N of M values. env keys FILE prints one key per line and marks each secret one with the rule that caught it. env classify FILE prints KEY<TAB>literal or KEY<TAB>secret:<rule> per line, or the same as JSON with --json. FILE may be - for stdin.

The store decides which lines are secret. A line is secret when the store already holds its value (held-value), when its value is a URL carrying user:password@ (url-userinfo), when its value has the shape of a vendor token or a PEM block (token-shape), or when its name says so (key-name: a part such as TOKEN, SECRET, PASSWORD or API_KEY, with PUBLIC names exempt). The rules are deliberately few and name-first: a literal the rules miss prints in the clear.

env import FILE --keys K[,K...] moves the chosen keys into the store, one record per key: the key becomes the record's var, the record is named <prefix> plus the key lowercased with _ as -, and every record carries the --tag pairs. It prints stored KEY as NAME per key and never a value. A key the file lacks or an empty value stops it before any write; a record name already in use stops it at that key, and the keys before it stay stored.


The guard

saddlebag guard --hook reads a Claude Code PreToolUse payload on stdin and prints a deny decision when the tool call would put a secret in front of the agent. The denial's reason is the saddlebag line that gets the same thing done, so the agent is redirected rather than stopped. It covers the Read, Grep, Write, Edit and Bash tools and refuses:

  • a read of a file whose contents are secret: .env and its variants other than .env.example, .env.sample, .env.template and .env.dist; .pem, .key, .p12 and .pfx files and id_rsa and its siblings; .netrc, .npmrc, .pypirc, .git-credentials, .vault-token, ~/.aws/credentials, ~/.docker/config.json; /proc/<pid>/environ. In a shell command the readers it knows are cat, grep, sed, head, source, python, jq and the rest of that family, behind wrappers such as sudo, env, timeout and xargs;
  • a dump of the whole environment: env with no command, printenv with no name, export -p, set, declare -x;
  • a secret manager's read whose output is the value: vault kv get, vault read, aws secretsmanager get-secret-value, aws ssm get-parameter --with-decryption, gcloud secrets versions access, az keyvault secret show, kubectl get secret, op read, op item get, gh auth token, security find-generic-password -w, secret-tool lookup, pass show, docker inspect, docker compose config. The read passes when the same stage pipes into saddlebag secret set, which is how a fetched secret enters the store;
  • a command whose text carries a value the store holds, found by asking the daemon holds with the command text. The daemon answers with the record names and never a value. The check runs only when a daemon answers; on the keyring route the guard writes one note on stderr and lets the command through.

A command that starts with saddlebag exec passes whole, and any saddlebag stage is skipped. Anything the guard cannot parse is allowed: it is a guard against the accidental read, not an access control. saddlebag guard install --user registers it in ~/.claude/settings.json, --project in ./.claude/settings.json, replacing an entry it installed before.


The ostler stack

A repository's docs/services/ops/stack.yml names one secret source for its compose stack. With saddlebag as that source, ostler asks saddlebag list for the variable names the matching secrets fill, checks them against the ${VAR} references in the rendered compose file, and runs every docker compose command under saddlebag exec. No stack.env file is written.

secrets:
  saddlebag: environment=local

The value is one or more space-separated tag pairs. The ostler README documents the other two sources and the required-name check.

The environment's non-secret variables live on its ostler environment page under variables:, with NAME: secret marking each value saddlebag holds in a record tagged environment=<slug> with --var NAME. ostler env exec <slug> -- CMD puts the page's literals in the command's environment and wraps it in saddlebag exec --env for the held names. ostler env dotenv <slug> renders the page as a .env with the held names blank, for a tool that wants a file. ostler env import-dotenv FILE --environment <slug> is the migration: the literals go onto the page and the secret lines go through saddlebag env import with --tag environment=<slug> --prefix <slug>-.


Where secrets live

A command picks its route in this order: SADDLEBAG_BACKEND, then the backend key of the [saddlebag] table in the shared stablemate config.toml, then a daemon that answers on the socket, else the OS keyring in this process. A set SADDLEBAG_SOCKET always means the daemon, and nothing listening there is an error, never a silent fall-back to the keyring.

Without a daemon, values live in the OS keyring, one item per secret and field under the service stablemate-saddlebag, and their metadata in saddlebag/catalogue.json under the stablemate state dir, owner-only. Behind the daemon, values and metadata both live in Vault under secret/saddlebag/<name>: the values as the secret's data, the metadata as its custom metadata. Saddlebag implements no encryption and keeps no plaintext fallback. A locked or absent keyring stops the command with a line that says what to do, and if Vault is unreachable, the daemon does not start.

Over SSH the keyring is usually locked. Feed your login password to gnome-keyring-daemon --unlock to open it, or set SADDLEBAG_BACKEND=daemon. On macOS the Keychain trusts the interpreter that stored an item, so a reinstalled interpreter prompts once per item; doctor prints the interpreter path, which security add-generic-password -T <path> takes to pre-authorise it. The full account, including what the socket and the keyring do and do not protect against, is in docs/SECURITY.md. Driving saddlebag from a workflow node is in docs/INTEGRATION.md.


Upgrading from 1.x

Version 2.0 removed the 1.x OS keyring backend, the SQLite pool, credential leases, AI selection, the 1.x env command family and the YAML manifest. The env of 2.x reads dotenv files and shares only the name. Nothing is migrated. Credentials in a 1.x pool and 1.x keyring items under the service saddlebag are left in place and orphaned; the keyring store of 2.x reads only items under stablemate-saddlebag, which it wrote itself. Create each secret you still need with secret create and secret set.


Package layout

saddlebag/
├── __init__.py
├── cli.py        # argparse front end: build a request, route it, print the reply; `exec`
├── guard.py      # the PreToolUse hook: the verdict on a tool call, its redirect, and the installer
├── dotenv.py     # a dotenv file's lines, and the masked form of a secret one
├── shapes.py     # the secret rules a classified line can match: key-name, token-shape, url-userinfo
├── route.py      # how a command reaches the counter: the daemon's socket, or in-process over the keyring
├── verbs.py      # the verb handlers over one store, used in-process or behind the daemon
├── store.py      # the SecretStore contract every verb talks to, and StoreUnavailable
├── custody.py    # the Vault token's three sources and the refusal
├── daemon.py     # `serve`: the socket accept loop over the verbs
├── protocol.py   # the socket path, newline-JSON framing and the client's send()
├── vault.py      # Vault KV v2: values in the secret's data, metadata in its custom metadata
├── keyring_store.py  # the OS keyring door and the shelf: values in the keyring, records in the catalogue
├── catalogue.py  # the keyring shelf's records, in one owner-only JSON file
├── models.py     # Secret and tag parsing; no field of it carries a value
├── redact.py     # `exec` off a terminal: the child's output relayed with every secret form masked
├── browser.py    # CDP target discovery and Input.insertText; only a length comes back
└── totp.py       # RFC 6238, computed in-process so the seed never leaves

Name

A saddlebag is the kit a horse carries on a ride: the right tools, ready when needed, returned to the stable when the ride is done. It fits the stablemate vocabulary. Ostler tends the stable, farrier fits the gear, workhorse does the riding, and saddlebag carries what the journey needs.

Metadata

Release files for stablemate-saddlebag 2.1.0

For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.

Source distribution (sdist)

Source distribution for stablemate-saddlebag 2.1.0
File Size Uploaded Provenance Actions
stablemate_saddlebag-2.1.0.tar.gz 41.0 kB With provenance Details for stablemate_saddlebag-2.1.0.tar.gz

Built distribution (wheel)

Table of built distributions (wheels) for stablemate-saddlebag 2.1.0
File Interpreter ABI Platform Actions
stablemate_saddlebag-2.1.0-py3-none-any.whl Python 3 none any Details for stablemate_saddlebag-2.1.0-py3-none-any.whl

Total release size: 89.0 kB

Release files / stablemate_saddlebag-2.1.0.tar.gz

Download URL stablemate_saddlebag-2.1.0.tar.gz
Size 41.0 kB
Tags Source
SHA-256 checksum
How to use checksums
6318f03218d3f298500a7567561e1435f58284d73cc4799fd68ecf43c30cadc2
BLAKE2b-256 checksum
How to use checksums
ffbb8131a1ff164954547aaa6488756cbf3aca9701769c325b271b31afe3c484
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/7.0.0 CPython/3.13.16

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Oct 29, 2026.

Transparency log

Release files / stablemate_saddlebag-2.1.0-py3-none-any.whl

Download URL stablemate_saddlebag-2.1.0-py3-none-any.whl
Size 48.0 kB
Tags Python 3
SHA-256 checksum
How to use checksums
0b8dc4a8cbf987eaa54a48a5277fbfa476f07b2fbb019cd4b97e9d041552b523
BLAKE2b-256 checksum
How to use checksums
50457f52a6b2dd34a5b9e66ce18357375721b95e51c4a3eabd01bcd9cb2116b1
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/7.0.0 CPython/3.13.16

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Oct 29, 2026.

Transparency log

Security for stablemate-saddlebag 2.1.0

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

Full provenance

All 2 files in this release have been uploaded with provenance metadata.

Select from 2 files

Attestation for stablemate_saddlebag-2.1.0.tar.gz

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Oct 29, 2026.

Transparency log

A note on package security

PyPI is a repository for distributing packages, not a service for auditing them. While third-party security researchers and our staff do their best to detect and remove malicious packages, PyPI cannot guarantee the quality, security or suitability of any given package.

To learn more about protecting yourself from malicious actors, visit our package security documentation.

Release history Release notifications | RSS feed

This release

2.1.0 This release

2 release files Full provenance

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page