stablemate-saddlebag 2.0.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,264 @@
1
+ # Byte-compiled / optimized / DLL files
2
+ __pycache__/
3
+ *.py[codz]
4
+ *$py.class
5
+
6
+ # C extensions
7
+ *.so
8
+
9
+ # Distribution / packaging
10
+ .Python
11
+ build/
12
+ develop-eggs/
13
+ dist/
14
+ downloads/
15
+ eggs/
16
+ .eggs/
17
+ lib/
18
+ lib64/
19
+ parts/
20
+ sdist/
21
+ var/
22
+ wheels/
23
+ share/python-wheels/
24
+ *.egg-info/
25
+ .installed.cfg
26
+ *.egg
27
+ MANIFEST
28
+
29
+ # PyInstaller
30
+ # Usually these files are written by a python script from a template
31
+ # before PyInstaller builds the exe, so as to inject date/other infos into it.
32
+ *.manifest
33
+ *.spec
34
+
35
+ # Installer logs
36
+ pip-log.txt
37
+ pip-delete-this-directory.txt
38
+
39
+ # Unit test / coverage reports
40
+ htmlcov/
41
+ .tox/
42
+ .nox/
43
+ .coverage
44
+ .coverage.*
45
+ .cache
46
+ nosetests.xml
47
+ coverage.xml
48
+ *.cover
49
+ *.py.cover
50
+ .hypothesis/
51
+ .pytest_cache/
52
+ cover/
53
+
54
+ # Translations
55
+ *.mo
56
+ *.pot
57
+
58
+ # Django stuff:
59
+ *.log
60
+ local_settings.py
61
+ db.sqlite3
62
+ db.sqlite3-journal
63
+
64
+ # Flask stuff:
65
+ instance/
66
+ .webassets-cache
67
+
68
+ # Scrapy stuff:
69
+ .scrapy
70
+
71
+ # Sphinx documentation
72
+ docs/_build/
73
+
74
+ # PyBuilder
75
+ .pybuilder/
76
+ target/
77
+
78
+ # Jupyter Notebook
79
+ .ipynb_checkpoints
80
+
81
+ # IPython
82
+ profile_default/
83
+ ipython_config.py
84
+
85
+ # pyenv
86
+ # For a library or package, you might want to ignore these files since the code is
87
+ # intended to run in multiple environments; otherwise, check them in:
88
+ # .python-version
89
+
90
+ # pipenv
91
+ # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
92
+ # However, in case of collaboration, if having platform-specific dependencies or dependencies
93
+ # having no cross-platform support, pipenv may install dependencies that don't work, or not
94
+ # install all needed dependencies.
95
+ # Pipfile.lock
96
+
97
+ # UV
98
+ # Similar to Pipfile.lock, it is generally recommended to include uv.lock in version control.
99
+ # This is especially recommended for binary packages to ensure reproducibility, and is more
100
+ # commonly ignored for libraries.
101
+ # uv.lock
102
+
103
+ # poetry
104
+ # Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control.
105
+ # This is especially recommended for binary packages to ensure reproducibility, and is more
106
+ # commonly ignored for libraries.
107
+ # https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control
108
+ # poetry.lock
109
+ # poetry.toml
110
+
111
+ # pdm
112
+ # Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control.
113
+ # pdm recommends including project-wide configuration in pdm.toml, but excluding .pdm-python.
114
+ # https://pdm-project.org/en/latest/usage/project/#working-with-version-control
115
+ # pdm.lock
116
+ # pdm.toml
117
+ .pdm-python
118
+ .pdm-build/
119
+
120
+ # pixi
121
+ # Similar to Pipfile.lock, it is generally recommended to include pixi.lock in version control.
122
+ # pixi.lock
123
+ # Pixi creates a virtual environment in the .pixi directory, just like venv module creates one
124
+ # in the .venv directory. It is recommended not to include this directory in version control.
125
+ .pixi
126
+
127
+ # PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm
128
+ __pypackages__/
129
+
130
+ # Celery stuff
131
+ celerybeat-schedule
132
+ celerybeat.pid
133
+
134
+ # Redis
135
+ *.rdb
136
+ *.aof
137
+ *.pid
138
+
139
+ # RabbitMQ
140
+ mnesia/
141
+ rabbitmq/
142
+ rabbitmq-data/
143
+
144
+ # ActiveMQ
145
+ activemq-data/
146
+
147
+ # SageMath parsed files
148
+ *.sage.py
149
+
150
+ # Environments
151
+ .env
152
+ .envrc
153
+ .venv
154
+ env/
155
+ venv/
156
+ ENV/
157
+ env.bak/
158
+ venv.bak/
159
+
160
+ # Spyder project settings
161
+ .spyderproject
162
+ .spyproject
163
+
164
+ # Rope project settings
165
+ .ropeproject
166
+
167
+ # mkdocs documentation
168
+ /site
169
+
170
+ # mypy
171
+ .mypy_cache/
172
+ .dmypy.json
173
+ dmypy.json
174
+
175
+ # Pyre type checker
176
+ .pyre/
177
+
178
+ # pytype static type analyzer
179
+ .pytype/
180
+
181
+ # Cython debug symbols
182
+ cython_debug/
183
+
184
+ # PyCharm
185
+ # JetBrains specific template is maintained in a separate JetBrains.gitignore that can
186
+ # be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
187
+ # and can be added to the global gitignore or merged into this file. For a more nuclear
188
+ # option (not recommended) you can uncomment the following to ignore the entire idea folder.
189
+ # .idea/
190
+
191
+ # Abstra
192
+ # Abstra is an AI-powered process automation framework.
193
+ # Ignore directories containing user credentials, local state, and settings.
194
+ # Learn more at https://abstra.io/docs
195
+ .abstra/
196
+
197
+ # Visual Studio Code
198
+ # Visual Studio Code specific template is maintained in a separate VisualStudioCode.gitignore
199
+ # that can be found at https://github.com/github/gitignore/blob/main/Global/VisualStudioCode.gitignore
200
+ # and can be added to the global gitignore or merged into this file. However, if you prefer,
201
+ # you could uncomment the following to ignore the entire vscode folder
202
+ # .vscode/
203
+ # Temporary file for partial code execution
204
+ tempCodeRunnerFile.py
205
+
206
+ # Ruff stuff:
207
+ .ruff_cache/
208
+
209
+ # PyPI configuration file
210
+ .pypirc
211
+
212
+ # Marimo
213
+ marimo/_static/
214
+ marimo/_lsp/
215
+ __marimo__/
216
+
217
+ # Streamlit
218
+ .streamlit/secrets.toml
219
+
220
+ .playwright-mcp
221
+
222
+ .agents/*
223
+
224
+
225
+ # A benchmark round's built repo, its run artifacts and its scorecard. Untracked for
226
+ # three separate reasons, any one of which is sufficient — it is large (a whole app per
227
+ # round), it is derived (re-runnable from the task module), and the built repo is its OWN
228
+ # git repo, which is the whole point: nesting one working tree inside another is only safe
229
+ # while the outer one cannot see it. Paddock's work and result roots live outside the tree
230
+ # entirely (`~/.local/share/stablemate/paddock`) for the same reason; this entry stays as
231
+ # the backstop for a round pointed at the repo by hand.
232
+ /data/
233
+
234
+
235
+ # Internal planning notes: maintainer paths, private-project references and
236
+ # retired-engine prose live here by nature. The directory stays (kept by .gitkeep)
237
+ # but its contents never ship.
238
+ docs/plans/*
239
+ !docs/plans/.gitkeep
240
+ progress.md
241
+ docs/specs
242
+ .opencode/opencode-loop
243
+
244
+ docs/backload.md
245
+
246
+ # >>> farrier: QA evidence (generated) >>>
247
+ **/qa/**/steps/
248
+ **/qa/**/asserts/
249
+ **/qa/**/traces/
250
+ **/qa/**/videos/
251
+ **/qa/**/screenshots/
252
+ **/qa/qa-run.ndjson
253
+ **/qa/run-manifest.json
254
+ **/qa/qa-session.json
255
+ # <<< farrier: QA evidence <<<
256
+
257
+ .agents/runs/
258
+ .agents/worktrees/
259
+ .agents/workflows/
260
+ .agents/operator/
261
+ .agents/local.compose.yaml
262
+ .agents/agents-context.json
263
+ .agents/agents-context.*.json
264
+ .agents/agents.mk
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Gabriel Côté
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,299 @@
1
+ Metadata-Version: 2.5
2
+ Name: stablemate-saddlebag
3
+ Version: 2.0.0
4
+ Summary: A Vault-backed daemon that fills secrets into browsers and commands without printing them.
5
+ Project-URL: Homepage, https://github.com/GabrielCpp/stablemate/tree/main/saddlebag
6
+ Project-URL: Repository, https://github.com/GabrielCpp/stablemate
7
+ Project-URL: Issues, https://github.com/GabrielCpp/stablemate/issues
8
+ Author: Gabriel Côté
9
+ License-Expression: MIT
10
+ License-File: LICENSE
11
+ Keywords: agents,credentials,secrets,testing,vault,workflow
12
+ Classifier: Development Status :: 3 - Alpha
13
+ Classifier: Intended Audience :: Developers
14
+ Classifier: Programming Language :: Python :: 3
15
+ Classifier: Programming Language :: Python :: 3.12
16
+ Classifier: Programming Language :: Python :: 3.13
17
+ Classifier: Topic :: Security
18
+ Classifier: Topic :: Software Development :: Testing
19
+ Classifier: Topic :: Utilities
20
+ Requires-Python: >=3.12
21
+ Requires-Dist: hvac>=2.0
22
+ Requires-Dist: keyring>=25
23
+ Requires-Dist: platformdirs>=4.0
24
+ Requires-Dist: websockets>=13.0
25
+ Provides-Extra: test
26
+ Requires-Dist: pytest>=8.0; extra == 'test'
27
+ Description-Content-Type: text/markdown
28
+
29
+ # saddlebag
30
+
31
+ > Carry the right credentials for every ride.
32
+
33
+ `saddlebag` holds the secrets a QA run needs and puts each one where it is consumed, so
34
+ that an agent driving a `workhorse` workflow never holds a secret in its context. A
35
+ secret leaves the store in exactly two ways. `fill` types it into a field of a browser
36
+ over the Chrome DevTools Protocol. `exec` runs a command with it set in that command's
37
+ process environment. Neither hands the value back to whoever ran them.
38
+
39
+ One daemon holds the HashiCorp Vault token, and every other command is a thin client
40
+ over a unix socket. That is what lets the same tool serve a run on the host and a run
41
+ in a container: the container sees the socket and nothing else.
42
+
43
+ ---
44
+
45
+ ## Position in the ecosystem
46
+
47
+ | Tool | Job |
48
+ |---|---|
49
+ | **ostler** | Tends the knowledge graph: epics, stories, seeds, specs |
50
+ | **farrier** | Fits the shared prompt library onto each repo |
51
+ | **workhorse** | Runs the workflow graph unattended |
52
+ | **saddlebag** | Carries secrets and fills them where they are consumed |
53
+
54
+ Ostler owns the spec of what a test needs and which environment it runs against.
55
+ Saddlebag owns the secret material that satisfies it. Ostler's stack bring-up asks
56
+ saddlebag for the variables a stack needs, as described under
57
+ [The ostler stack](#the-ostler-stack).
58
+
59
+ ---
60
+
61
+ ## Install
62
+
63
+ Saddlebag publishes to PyPI as `stablemate-saddlebag`, because the name `saddlebag` is
64
+ unavailable. The command and the import package stay `saddlebag`.
65
+
66
+ ```bash
67
+ uv tool install stablemate-saddlebag
68
+ pipx install stablemate-saddlebag
69
+ ```
70
+
71
+ The `stablemate` package installs it along with the other tools (`uv tool install stablemate`).
72
+ From a checkout of the [stablemate](https://github.com/GabrielCpp/stablemate) workspace:
73
+
74
+ ```bash
75
+ uv run --package stablemate-saddlebag saddlebag --help
76
+ ```
77
+
78
+ Requires Python ≥ 3.12 and a reachable HashiCorp Vault with a KV v2 mount named
79
+ `secret`. For a developer machine, `vault server -dev` is enough.
80
+
81
+ ---
82
+
83
+ ## Sixty seconds
84
+
85
+ ```bash
86
+ # 1. Start the daemon. The token arrives on stdin and stays in the daemon's memory.
87
+ export VAULT_ADDR=http://127.0.0.1:8200
88
+ printf '%s' "$VAULT_TOKEN_FROM_YOUR_LOGIN" | saddlebag serve
89
+
90
+ # 2. In another shell: describe a secret, then give it its value on stdin.
91
+ saddlebag secret create normal_login \
92
+ --username gab \
93
+ --description "Log in as a normal user" \
94
+ --var LOGIN_PASSWORD \
95
+ --tag environment=local --tag role=user
96
+ printf '%s' "$PASSWORD" | saddlebag secret set normal_login
97
+
98
+ # 3. An agent finds it by tag and reads the description. No value is printed.
99
+ saddlebag list --tag environment=local
100
+
101
+ # 4. Deliver it. Into a browser field, or into a command's environment.
102
+ saddlebag fill normal_login --field username --selector "#login_field" --url-contains example.com
103
+ saddlebag fill normal_login --field password --selector "#password" --url-contains example.com
104
+ saddlebag exec --tag environment=local -- docker compose up
105
+ ```
106
+
107
+ The browser the fourth step talks to is one you started with a debugger port:
108
+
109
+ ```bash
110
+ chromium --remote-debugging-port=9222 --user-data-dir=/tmp/qa-profile
111
+ ```
112
+
113
+ Saddlebag connects only to a DevTools endpoint on loopback and refuses any other
114
+ host. That port is unauthenticated and grants total control of the browser.
115
+
116
+ ---
117
+
118
+ ## The daemon
119
+
120
+ `saddlebag serve` is the one process that talks to Vault. It reads the token from
121
+ stdin when piped, else from the OS keyring where `saddlebag serve --keep-token` stored
122
+ it, else from `saddlebag/vault-token` under the stablemate state dir when that file is
123
+ mode 0600. It refuses to start if `VAULT_TOKEN` is set in its environment, and listens
124
+ on a unix socket created with owner-only permissions. Every other verb connects to
125
+ that socket, sends one request and reads one reply.
126
+
127
+ | | |
128
+ |---|---|
129
+ | 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 |
130
+ | Socket path | `$SADDLEBAG_SOCKET`, else `$XDG_RUNTIME_DIR/saddlebag.sock`, else the OS runtime dir |
131
+ | Vault address | `$VAULT_ADDR` or `--vault-addr` |
132
+ | Browser | `--cdp`, default `http://127.0.0.1:9222` |
133
+
134
+ saddlebag never puts the token in an environment variable, because a process of the
135
+ same user can read `/proc/<pid>/environ`. The kept token lives in the OS keyring. The
136
+ filed token is the operator's choice, and `doctor` warns about it because any file the
137
+ user owns is readable by their other processes. For the same reason the operator's own
138
+ Vault login must not leave `~/.vault-token` behind, and `doctor` warns when that file
139
+ exists.
140
+
141
+ ### A run in a container
142
+
143
+ Mount the socket and name it. The client inside the container talks to the daemon
144
+ on the host, which is also where the browser and its debug port live.
145
+
146
+ ```bash
147
+ docker run \
148
+ -v "$SADDLEBAG_SOCKET:/run/saddlebag.sock" \
149
+ -e SADDLEBAG_SOCKET=/run/saddlebag.sock \
150
+ ...
151
+ ```
152
+
153
+ ---
154
+
155
+ ## Secrets and tags
156
+
157
+ A **secret** has a name, an optional username, a description an agent reads to choose
158
+ between candidates, an optional `var` naming the environment variable `exec` fills it
159
+ into, and a set of `key=value` tags. It holds up to two values: a `password` and a
160
+ `totp` enrolment seed. The username is metadata and may print. The two values never do.
161
+
162
+ ```jsonc
163
+ {
164
+ "name": "normal_login",
165
+ "username": "gab",
166
+ "description": "Log in as a normal user",
167
+ "var": "LOGIN_PASSWORD",
168
+ "tags": [["environment", "local"], ["role", "user"]],
169
+ "fields": ["password"]
170
+ }
171
+ ```
172
+
173
+ Tags are the whole query vocabulary. `list --tag environment=local --tag role=admin`
174
+ returns the secrets carrying both pairs. `tags` lists the distinct pairs in use. The
175
+ convention the ostler integration relies on is that a secret for a QA environment
176
+ carries `environment=<ostler environment node id>`.
177
+
178
+ ---
179
+
180
+ ## Commands
181
+
182
+ | | |
183
+ |---|---|
184
+ | `serve` | run the daemon; token from stdin, the OS keyring or a 0600 file; `--keep-token` stores one |
185
+ | `list` `tags` | query by tag; neither can emit a value |
186
+ | `secret create` `set` `unset` `remove` `tag` `untag` | manage a secret; `set` reads its value from stdin |
187
+ | `fill` | type a secret's username, password or current TOTP code into a browser field |
188
+ | `exec` | run a command with matching secrets in its environment |
189
+ | `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 |
190
+
191
+ ```bash
192
+ saddlebag fill NAME --field username|password|totp --selector S [--url-contains X]
193
+ saddlebag exec [--tag k=v]... [--env VAR=NAME[.field]]... -- cmd args...
194
+ ```
195
+
196
+ `exec` fills each tag-matched secret's `var` with its password. An explicit
197
+ `--env VAR=NAME.field` binding may name any field, including the username and the
198
+ current TOTP code. A secret matched by tag that has no `var` is an error naming it,
199
+ not a silent skip.
200
+
201
+ **What each verb guarantees.** `fill` reports a character count, a selector and a URL.
202
+ The value is typed through `Input.insertText` and never interpolated into an evaluated
203
+ expression. A TOTP seed leaves the store only as a computed six-digit code, and only
204
+ by being typed. `exec` is as private as the command it runs: a command that prints its
205
+ own environment prints the secret. What `exec` guarantees is that the value is never
206
+ in the agent's transcript by default and never on disk. The agent's permission layer
207
+ decides which commands it may run under `exec`.
208
+
209
+ ---
210
+
211
+ ## The ostler stack
212
+
213
+ A repository's `docs/services/ops/stack.yml` names one secret source for its compose
214
+ stack. With saddlebag as that source, ostler asks `saddlebag list` for the variable
215
+ names the matching secrets fill, checks them against the `${VAR}` references in the
216
+ rendered compose file, and runs every `docker compose` command under `saddlebag exec`.
217
+ No `stack.env` file is written.
218
+
219
+ ```yaml
220
+ secrets:
221
+ saddlebag: environment=local
222
+ ```
223
+
224
+ The value is one or more space-separated tag pairs. The ostler README documents the
225
+ other two sources and the required-name check.
226
+
227
+ ---
228
+
229
+ ## Where secrets live
230
+
231
+ A command picks its route in this order: `SADDLEBAG_BACKEND`, then the `backend` key
232
+ of the `[saddlebag]` table in the shared stablemate `config.toml`, then a daemon that
233
+ answers on the socket, else the OS keyring in this process. A set `SADDLEBAG_SOCKET`
234
+ always means the daemon, and nothing listening there is an error, never a silent
235
+ fall-back to the keyring.
236
+
237
+ Without a daemon, values live in the OS keyring, one item per secret and field under
238
+ the service `stablemate-saddlebag`, and their metadata in `saddlebag/catalogue.json`
239
+ under the stablemate state dir, owner-only. Behind the daemon, values and metadata both
240
+ live in Vault under `secret/saddlebag/<name>`: the values as the secret's data, the
241
+ metadata as its custom metadata. Saddlebag implements no encryption and keeps no
242
+ plaintext fallback. A locked or absent keyring stops the command with a line that says
243
+ what to do, and if Vault is unreachable, the daemon does not start.
244
+
245
+ Over SSH the keyring is usually locked. Feed your login password to
246
+ `gnome-keyring-daemon --unlock` to open it, or set `SADDLEBAG_BACKEND=daemon`. On
247
+ macOS the Keychain trusts the interpreter that stored an item, so a reinstalled
248
+ interpreter prompts once per item; `doctor` prints the interpreter path, which
249
+ `security add-generic-password -T <path>` takes to pre-authorise it. The full account,
250
+ including what the socket and the keyring do and do not protect against, is in
251
+ [docs/SECURITY.md](https://github.com/GabrielCpp/stablemate/blob/main/saddlebag/docs/SECURITY.md).
252
+ Driving saddlebag from a workflow node is in
253
+ [docs/INTEGRATION.md](https://github.com/GabrielCpp/stablemate/blob/main/saddlebag/docs/INTEGRATION.md).
254
+
255
+ ---
256
+
257
+ ## Upgrading from 1.x
258
+
259
+ Version 2.0 removed the 1.x OS keyring backend, the SQLite pool, credential leases,
260
+ AI selection, the `env` command family and the YAML manifest. Nothing is migrated.
261
+ Credentials in a 1.x pool and 1.x keyring items under the service `saddlebag` are left
262
+ in place and orphaned; the keyring store of 2.x reads only items under
263
+ `stablemate-saddlebag`, which it wrote itself. Create each secret you still need with
264
+ `secret create` and `secret set`.
265
+
266
+ ---
267
+
268
+ ## Package layout
269
+
270
+ ```
271
+ saddlebag/
272
+ ├── __init__.py
273
+ ├── cli.py # argparse front end: build a request, route it, print the reply; `exec`
274
+ ├── route.py # how a command reaches the counter: the daemon's socket, or in-process over the keyring
275
+ ├── verbs.py # the verb handlers over one store, used in-process or behind the daemon
276
+ ├── store.py # the SecretStore contract every verb talks to, and StoreUnavailable
277
+ ├── custody.py # the Vault token's three sources and the refusal
278
+ ├── daemon.py # `serve`: the socket accept loop over the verbs
279
+ ├── protocol.py # the socket path, newline-JSON framing and the client's send()
280
+ ├── vault.py # Vault KV v2: values in the secret's data, metadata in its custom metadata
281
+ ├── keyring_store.py # the OS keyring door and the shelf: values in the keyring, records in the catalogue
282
+ ├── catalogue.py # the keyring shelf's records, in one owner-only JSON file
283
+ ├── models.py # Secret and tag parsing; no field of it carries a value
284
+ ├── browser.py # CDP target discovery and Input.insertText; only a length comes back
285
+ └── totp.py # RFC 6238, computed in-process so the seed never leaves
286
+ ```
287
+
288
+ ## Name
289
+
290
+ A **saddlebag** is the kit a horse carries on a ride: the right tools, ready when
291
+ needed, returned to the stable when the ride is done. It fits the stablemate
292
+ vocabulary. Ostler tends the stable, farrier fits the gear, workhorse does the
293
+ riding, and saddlebag carries what the journey needs.
294
+
295
+ ## Map
296
+
297
+ - `saddlebag/`: the package behind the `saddlebag` command. [Package layout](#package-layout) says what each module owns.
298
+ - `docs/`: the security model and the workflow integration.
299
+ - `tests/`: the suite, run from the repo root with `uv run --package stablemate-saddlebag pytest saddlebag/tests`.