stablemate-saddlebag 2.0.0__py3-none-any.whl

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.
saddlebag/README.md ADDED
@@ -0,0 +1,13 @@
1
+ # saddlebag
2
+
3
+ The package behind the `saddlebag` command. A daemon holds the Vault token and answers verbs on a unix socket. The command line is a thin client of that socket. Each secret reaches its consumer without being returned to the caller.
4
+
5
+ ## Map
6
+
7
+ - `browser.py`: the `fill` delivery path. It finds a page over the Chrome DevTools Protocol on loopback and types a value into it.
8
+ - `cli.py`: the argparse entry point. Every verb builds a request and prints the reply, except `serve`, which runs the daemon, and `exec`, which replaces itself with the command.
9
+ - `daemon.py`: the socket listener and the verb handlers. It answers one connection at a time and never logs a value.
10
+ - `models.py`: the secret record, tag parsing and name validation. A secret names which values exist and never holds one.
11
+ - `protocol.py`: the socket path, the newline-terminated JSON framing and the client `send`.
12
+ - `totp.py`: RFC 6238 codes computed in-process, so a seed never leaves.
13
+ - `vault.py`: the `SecretStore` protocol and the Vault KV v2 adapter. It is the only place a stored value is kept.
saddlebag/__init__.py ADDED
@@ -0,0 +1,7 @@
1
+ """saddlebag: a Vault-backed daemon that fills secrets without printing them."""
2
+
3
+ from __future__ import annotations
4
+
5
+ __all__ = ["Secret"]
6
+
7
+ from saddlebag.models import Secret
saddlebag/browser.py ADDED
@@ -0,0 +1,190 @@
1
+ """Typing a stored secret into a live browser, over the Chrome DevTools Protocol."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import logging
7
+ import urllib.error
8
+ import urllib.parse
9
+ import urllib.request
10
+ from dataclasses import dataclass
11
+ from typing import Any, Protocol
12
+
13
+ from websockets.sync.client import connect
14
+
15
+ logger = logging.getLogger(__name__)
16
+
17
+ DEFAULT_ENDPOINT = "http://127.0.0.1:9222"
18
+
19
+ TIMEOUT_SECONDS = 15.0
20
+
21
+ LOOPBACK_HOSTS = frozenset({"localhost", "127.0.0.1", "::1", "[::1]"})
22
+
23
+
24
+ class BrowserError(RuntimeError):
25
+ """The browser could not be reached, or could not be made to accept the value."""
26
+
27
+
28
+ @dataclass(frozen=True)
29
+ class Target:
30
+ """One inspectable page in the browser."""
31
+
32
+ id: str
33
+ url: str
34
+ title: str
35
+ websocket_url: str
36
+
37
+
38
+ class CdpSession(Protocol):
39
+ """One command channel to a page."""
40
+
41
+ def send(self, method: str, params: dict[str, Any]) -> dict[str, Any]: ...
42
+
43
+
44
+ class WebSocketSession:
45
+ """A :class:`CdpSession` over the page's debugger WebSocket."""
46
+
47
+ def __init__(self, websocket_url: str) -> None:
48
+ self._socket = connect(websocket_url, open_timeout=TIMEOUT_SECONDS, max_size=None)
49
+ self._next_id = 0
50
+
51
+ def send(self, method: str, params: dict[str, Any]) -> dict[str, Any]:
52
+ self._next_id += 1
53
+ message_id = self._next_id
54
+ self._socket.send(json.dumps({"id": message_id, "method": method, "params": params}))
55
+ while True:
56
+ frame = json.loads(self._socket.recv(timeout=TIMEOUT_SECONDS))
57
+ if frame.get("id") != message_id:
58
+ continue
59
+ if "error" in frame:
60
+ raise BrowserError(f"{method} failed: {frame['error'].get('message', frame['error'])}")
61
+ return frame.get("result", {})
62
+
63
+ def close(self) -> None:
64
+ self._socket.close()
65
+
66
+
67
+ def require_loopback(endpoint: str) -> None:
68
+ """Refuse an endpoint that is not on this machine."""
69
+ host = urllib.parse.urlparse(endpoint).hostname
70
+ if host is None or host.lower() not in LOOPBACK_HOSTS:
71
+ raise BrowserError(
72
+ f"refusing to send a secret to non-loopback CDP endpoint {endpoint!r}. "
73
+ "A DevTools port grants full control of the browser and is never "
74
+ "authenticated; saddlebag will only talk to one on this machine."
75
+ )
76
+
77
+
78
+ def list_targets(endpoint: str) -> tuple[Target, ...]:
79
+ """Every inspectable page the browser is showing."""
80
+ require_loopback(endpoint)
81
+ url = endpoint.rstrip("/") + "/json/list"
82
+ try:
83
+ with urllib.request.urlopen(url, timeout=TIMEOUT_SECONDS) as response: # noqa: S310 - loopback-checked above
84
+ payload = json.loads(response.read())
85
+ except (urllib.error.URLError, TimeoutError, OSError) as exc:
86
+ raise BrowserError(
87
+ f"no browser answering at {endpoint}: {exc}. Start one with "
88
+ "--remote-debugging-port, e.g. "
89
+ "`chromium --remote-debugging-port=9222 --user-data-dir=/tmp/qa-profile`"
90
+ ) from exc
91
+ targets = tuple(
92
+ Target(
93
+ id=entry.get("id", ""),
94
+ url=entry.get("url", ""),
95
+ title=entry.get("title", ""),
96
+ websocket_url=entry.get("webSocketDebuggerUrl", ""),
97
+ )
98
+ for entry in payload
99
+ if entry.get("type") == "page" and entry.get("webSocketDebuggerUrl")
100
+ )
101
+ for target in targets:
102
+ require_loopback(target.websocket_url)
103
+ return targets
104
+
105
+
106
+ def select_target(targets: tuple[Target, ...], url_contains: str | None) -> Target:
107
+ """The one page to type into."""
108
+ if not targets:
109
+ raise BrowserError("the browser has no inspectable pages open")
110
+ matches = (
111
+ targets
112
+ if url_contains is None
113
+ else tuple(t for t in targets if url_contains in t.url)
114
+ )
115
+ if not matches:
116
+ listing = "\n".join(f" - {t.url}" for t in targets)
117
+ raise BrowserError(f"no open page's URL contains {url_contains!r}. Open pages:\n{listing}")
118
+ if len(matches) > 1:
119
+ listing = "\n".join(f" - {t.url}" for t in matches)
120
+ raise BrowserError(
121
+ f"{len(matches)} pages match {url_contains!r}; narrow it so the target is "
122
+ f"unambiguous:\n{listing}"
123
+ )
124
+ return next(iter(matches))
125
+
126
+
127
+ _FOCUS = """
128
+ (() => {
129
+ const el = document.querySelector(%s);
130
+ if (!el) return { found: false };
131
+ el.focus();
132
+ if (typeof el.select === 'function') el.select();
133
+ return {
134
+ found: true,
135
+ editable: el.isContentEditable || ['INPUT', 'TEXTAREA'].includes(el.tagName),
136
+ tag: el.tagName,
137
+ type: el.getAttribute('type'),
138
+ };
139
+ })()
140
+ """
141
+
142
+ _LENGTH = """
143
+ (() => {
144
+ const el = document.querySelector(%s);
145
+ if (!el) return -1;
146
+ return (el.isContentEditable ? el.textContent : el.value || '').length;
147
+ })()
148
+ """
149
+
150
+
151
+ def _evaluate(session: CdpSession, expression: str) -> Any:
152
+ result = session.send(
153
+ "Runtime.evaluate",
154
+ {"expression": expression, "returnByValue": True, "awaitPromise": True},
155
+ )
156
+ if "exceptionDetails" in result:
157
+ details = result["exceptionDetails"]
158
+ raise BrowserError(f"page script failed: {details.get('text', details)}")
159
+ return result.get("result", {}).get("value")
160
+
161
+
162
+ def fill(session: CdpSession, selector: str, value: str) -> int:
163
+ """Type ``value`` into ``selector`` on an already-open page."""
164
+ selector_literal = json.dumps(selector)
165
+ found = _evaluate(session, _FOCUS % selector_literal)
166
+ if not isinstance(found, dict) or not found.get("found"):
167
+ raise BrowserError(f"no element matches {selector!r} on that page")
168
+ if not found.get("editable"):
169
+ raise BrowserError(
170
+ f"{selector!r} matches a <{str(found.get('tag', '?')).lower()}>, which cannot "
171
+ "hold typed text; name the input itself"
172
+ )
173
+
174
+ session.send("Input.insertText", {"text": value})
175
+
176
+ length = _evaluate(session, _LENGTH % selector_literal)
177
+ if not isinstance(length, int) or length <= 0:
178
+ raise BrowserError(
179
+ f"{selector!r} is still empty after the insert — the field is probably "
180
+ "read-only, or the page replaced it while it was being filled"
181
+ )
182
+ if length != len(value):
183
+ logger.warning(
184
+ "%s holds %d characters after inserting %d — the field may be masked, "
185
+ "truncating or reformatting",
186
+ selector,
187
+ length,
188
+ len(value),
189
+ )
190
+ return length
saddlebag/catalogue.py ADDED
@@ -0,0 +1,95 @@
1
+ """The keyring shelf's records, in one owner-only JSON file."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import os
7
+ from pathlib import Path
8
+ from typing import Any
9
+
10
+ import platformdirs
11
+
12
+ from saddlebag.models import Secret
13
+ from saddlebag.store import StoreUnavailable
14
+
15
+ VERSION = 1
16
+
17
+
18
+ class Catalogue:
19
+ """The keyring shelf's records, in one owner-only JSON file. It serialises Secret and nothing else."""
20
+
21
+ def __init__(self, path: Path) -> None:
22
+ self.path = path
23
+
24
+ @classmethod
25
+ def default(cls) -> Catalogue:
26
+ """The catalogue under the stablemate state directory."""
27
+ return cls(Path(platformdirs.user_state_dir("stablemate")) / "saddlebag" / "catalogue.json")
28
+
29
+ def _load(self) -> dict[str, Secret]:
30
+ try:
31
+ raw = self.path.read_text(encoding="utf-8")
32
+ except FileNotFoundError:
33
+ return {}
34
+ except OSError as exc:
35
+ raise StoreUnavailable(f"cannot read the catalogue {self.path}: {exc.strerror or exc}") from exc
36
+ try:
37
+ payload = json.loads(raw)
38
+ if not isinstance(payload, dict) or payload.get("version") != VERSION:
39
+ raise ValueError("unsupported version")
40
+ return {secret.name: secret for secret in map(_secret, payload["secrets"])}
41
+ except (ValueError, KeyError, TypeError) as exc:
42
+ raise StoreUnavailable(f"the catalogue {self.path} is not a version {VERSION} catalogue: fix or remove it") from exc
43
+
44
+ def _save(self, secrets: dict[str, Secret]) -> None:
45
+ parent = self.path.parent
46
+ created = not parent.exists()
47
+ parent.mkdir(parents=True, exist_ok=True)
48
+ if created:
49
+ parent.chmod(0o700)
50
+ payload = json.dumps({"version": VERSION, "secrets": [secrets[name].to_dict() for name in sorted(secrets)]}, indent=2)
51
+ temp = parent / f".{self.path.name}.{os.getpid()}.tmp"
52
+ descriptor = os.open(temp, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600)
53
+ try:
54
+ with os.fdopen(descriptor, "w", encoding="utf-8") as handle:
55
+ handle.write(payload + "\n")
56
+ os.replace(temp, self.path)
57
+ except BaseException:
58
+ temp.unlink(missing_ok=True)
59
+ raise
60
+
61
+ def names(self) -> list[str]:
62
+ """The record names, sorted."""
63
+ return sorted(self._load())
64
+
65
+ def get(self, name: str) -> Secret | None:
66
+ """The record called ``name``, or None."""
67
+ return self._load().get(name)
68
+
69
+ def put(self, secret: Secret) -> None:
70
+ """Insert or replace a record, then save."""
71
+ secrets = self._load()
72
+ secrets[secret.name] = secret
73
+ self._save(secrets)
74
+
75
+ def drop(self, name: str) -> None:
76
+ """Forget a record, then save."""
77
+ secrets = self._load()
78
+ secrets.pop(name, None)
79
+ self._save(secrets)
80
+
81
+
82
+ _KEYS = frozenset({"name", "username", "description", "var", "tags", "fields"})
83
+
84
+
85
+ def _secret(entry: Any) -> Secret:
86
+ if not isinstance(entry, dict) or set(entry) != _KEYS:
87
+ raise ValueError("unexpected record keys")
88
+ return Secret(
89
+ name=entry["name"],
90
+ username=entry["username"],
91
+ description=entry["description"],
92
+ var=entry["var"],
93
+ tags=frozenset((key, value) for key, value in entry["tags"]),
94
+ fields=frozenset(entry["fields"]),
95
+ )
saddlebag/cli.py ADDED
@@ -0,0 +1,361 @@
1
+ """saddlebag command-line entry point: argparse front end that builds a request, routes it and prints the reply; `exec`."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import json
7
+ import logging
8
+ import os
9
+ import signal
10
+ import sys
11
+ import threading
12
+ from importlib.metadata import version as _pkg_version
13
+ from pathlib import Path
14
+
15
+ from saddlebag import browser, custody, daemon, preference, protocol, route
16
+ from saddlebag.custody import CustodyError
17
+ from saddlebag.keyring_store import open_door
18
+ from saddlebag.models import FIELDS, VALUE_FIELDS, parse_tag
19
+ from saddlebag.protocol import DaemonNotRunning, Reply, Request
20
+ from saddlebag.route import RouteError
21
+ from saddlebag.store import StoreUnavailable
22
+ from saddlebag.vault import VaultError, VaultStore
23
+
24
+ logger = logging.getLogger(__name__)
25
+
26
+
27
+ def _emit(data: object) -> None:
28
+ json.dump(data, sys.stdout, indent=2)
29
+ sys.stdout.write("\n")
30
+
31
+
32
+ def _error(message: str, code: int) -> int:
33
+ print(f"saddlebag: {message}", file=sys.stderr)
34
+ return code
35
+
36
+
37
+ def _ask(verb: str, args: dict[str, object] | None = None) -> Reply | int:
38
+ try:
39
+ reply = route.choose().ask(Request(verb, args or {}))
40
+ except (DaemonNotRunning, StoreUnavailable, RouteError) as exc:
41
+ return _error(str(exc), 1)
42
+ except protocol.ProtocolError as exc:
43
+ return _error(f"the daemon sent an unreadable reply: {exc}", 1)
44
+ if not reply.ok:
45
+ return _error(reply.error or f"{verb} failed", 1)
46
+ return reply
47
+
48
+
49
+ class UsageError(Exception):
50
+ """The command line is malformed. The message is the one line printed."""
51
+
52
+
53
+ def _check_tags(tags: list[str]) -> list[str]:
54
+ for tag in tags:
55
+ try:
56
+ parse_tag(tag)
57
+ except ValueError as exc:
58
+ raise UsageError(str(exc)) from exc
59
+ return tags
60
+
61
+
62
+ def _table(rows: list[list[str]], header: list[str]) -> None:
63
+ widths = [max(len(row[i]) for row in [header, *rows]) for i in range(len(header))]
64
+ for row in [header, *rows]:
65
+ print(" ".join(cell.ljust(width) for cell, width in zip(row, widths, strict=True)).rstrip())
66
+
67
+
68
+ def _pairs(raw: list[list[str]]) -> str:
69
+ return ",".join(f"{k}={v}" for k, v in raw)
70
+
71
+
72
+ def cmd_serve(args: argparse.Namespace) -> int:
73
+ try:
74
+ custody.refuse_environment()
75
+ if args.keep_token:
76
+ custody.keep(sys.stdin, open_door)
77
+ print("kept the Vault token in the OS keyring", file=sys.stderr)
78
+ return 0
79
+ addr = args.vault_addr or os.environ.get("VAULT_ADDR")
80
+ if not addr:
81
+ return _error("no Vault address: set VAULT_ADDR or pass --vault-addr", 2)
82
+ token = custody.find(sys.stdin, open_door, custody.filed_path())
83
+ except (CustodyError, StoreUnavailable) as exc:
84
+ return _error(str(exc), 2)
85
+ try:
86
+ store = VaultStore(addr, token.value)
87
+ except VaultError as exc:
88
+ return _error(str(exc), 1)
89
+ path = protocol.fit_socket_path(args.socket) if args.socket else protocol.socket_path()
90
+ stop = threading.Event()
91
+ signal.signal(signal.SIGTERM, lambda *_: stop.set())
92
+ signal.signal(signal.SIGINT, lambda *_: stop.set())
93
+ try:
94
+ daemon.serve(store, socket_path=path, browser_endpoint=args.cdp, stop=stop, token_source=token.source)
95
+ except OSError as exc:
96
+ return _error(str(exc), 1)
97
+ return 0
98
+
99
+
100
+ def cmd_list(args: argparse.Namespace) -> int:
101
+ reply = _ask("list", {"tags": _check_tags(args.tag)})
102
+ if isinstance(reply, int):
103
+ return reply
104
+ if args.json:
105
+ _emit(reply.result)
106
+ return 0
107
+ rows = [
108
+ [s["name"], s["username"] or "", s["var"] or "", ",".join(s["fields"]), _pairs(s["tags"]), s["description"] or ""]
109
+ for s in reply.result["secrets"]
110
+ ]
111
+ _table(rows, ["NAME", "USERNAME", "VAR", "VALUES", "TAGS", "DESCRIPTION"])
112
+ return 0
113
+
114
+
115
+ def cmd_tags(args: argparse.Namespace) -> int:
116
+ reply = _ask("tags", {"tags": _check_tags(args.tag)})
117
+ if isinstance(reply, int):
118
+ return reply
119
+ for pair in reply.result["tags"]:
120
+ print(f"{pair[0]}={pair[1]}")
121
+ return 0
122
+
123
+
124
+ def cmd_create(args: argparse.Namespace) -> int:
125
+ reply = _ask(
126
+ "create",
127
+ {"name": args.name, "username": args.username, "description": args.description, "var": args.var, "tags": _check_tags(args.tag)},
128
+ )
129
+ if isinstance(reply, int):
130
+ return reply
131
+ if args.json:
132
+ _emit(reply.result)
133
+ else:
134
+ print(f"created {args.name}")
135
+ return 0
136
+
137
+
138
+ def cmd_set(args: argparse.Namespace) -> int:
139
+ value = sys.stdin.read().rstrip("\r\n")
140
+ if not value.strip():
141
+ return _error("stdin gave an empty value", 2)
142
+ reply = _ask("set", {"name": args.name, "field": args.field, "value": value})
143
+ if isinstance(reply, int):
144
+ return reply
145
+ print(f"stored {reply.result['characters']} characters as {args.name} {args.field}")
146
+ return 0
147
+
148
+
149
+ def cmd_unset(args: argparse.Namespace) -> int:
150
+ reply = _ask("unset", {"name": args.name, "field": args.field})
151
+ if isinstance(reply, int):
152
+ return reply
153
+ print(f"cleared {args.field} of {args.name}")
154
+ return 0
155
+
156
+
157
+ def cmd_remove(args: argparse.Namespace) -> int:
158
+ reply = _ask("remove", {"name": args.name})
159
+ if isinstance(reply, int):
160
+ return reply
161
+ print(f"removed {args.name}")
162
+ return 0
163
+
164
+
165
+ def cmd_tag(args: argparse.Namespace) -> int:
166
+ reply = _ask(args.command_name, {"name": args.name, "tags": _check_tags(args.tags)})
167
+ if isinstance(reply, int):
168
+ return reply
169
+ print(f"{'tagged' if args.command_name == 'tag' else 'untagged'} {args.name}")
170
+ return 0
171
+
172
+
173
+ def cmd_fill(args: argparse.Namespace) -> int:
174
+ reply = _ask(
175
+ "fill",
176
+ {
177
+ "name": args.name,
178
+ "field": args.field,
179
+ "selector": args.selector,
180
+ "url_contains": args.url_contains,
181
+ "cdp": args.cdp,
182
+ "min_seconds": args.min_seconds,
183
+ },
184
+ )
185
+ if isinstance(reply, int):
186
+ return reply
187
+ if args.json:
188
+ _emit(reply.result)
189
+ return 0
190
+ extra = f", valid for {reply.result['valid_for_seconds']}s" if "valid_for_seconds" in reply.result else ""
191
+ print(f"filled {reply.result['characters']} characters into {args.selector} on {reply.result['url']}{extra}")
192
+ return 0
193
+
194
+
195
+ def _binding(text: str) -> dict[str, object]:
196
+ var, sep, source = text.partition("=")
197
+ name, _, field = source.partition(".")
198
+ if not sep or not var or not name:
199
+ raise ValueError(f"invalid --env {text!r}: expected VAR=NAME or VAR=NAME.field")
200
+ return {"var": var, "name": name, "field": field or "password"}
201
+
202
+
203
+ def cmd_exec(args: argparse.Namespace) -> int:
204
+ command = args.command[1:] if args.command[:1] == ["--"] else args.command
205
+ if not command:
206
+ return _error("exec needs a command after --", 2)
207
+ if not args.tag and not args.env:
208
+ return _error("exec needs at least one --tag or --env", 2)
209
+ try:
210
+ bindings = [_binding(text) for text in args.env]
211
+ except ValueError as exc:
212
+ return _error(str(exc), 2)
213
+ tags = _check_tags(args.tag)
214
+ reply = _ask("env", {"tags": tags, "bindings": bindings})
215
+ if isinstance(reply, int):
216
+ return reply
217
+ values = reply.result.get("values")
218
+ if not isinstance(values, dict):
219
+ return _error("the reply carried no environment", 1)
220
+ try:
221
+ os.execvpe(command[0], command, {**os.environ, **values})
222
+ except OSError as exc:
223
+ return _error(f"cannot run {command[0]}: {exc.strerror or exc}", 127)
224
+ return 0
225
+
226
+
227
+ def cmd_doctor(args: argparse.Namespace) -> int:
228
+ reply = _ask("doctor")
229
+ if isinstance(reply, int):
230
+ return reply
231
+ result = reply.result
232
+ chosen = preference.read()
233
+ result["preference"] = None if chosen is None else {"backend": chosen.backend, "source": chosen.source}
234
+ if args.json:
235
+ _emit(result)
236
+ else:
237
+ route = result["route"]
238
+ if route.startswith("socket "):
239
+ route = f"{route} (daemon {result['version']})"
240
+ print(f"backend: {result['backend']}")
241
+ print(f"route: {route}")
242
+ print(f"preference: {'none (auto)' if chosen is None else f'{chosen.backend} ({chosen.source})'}")
243
+ print(f"store reachable: {'yes' if result['store_reachable'] else 'no'}")
244
+ print(f"secrets without a stored value: {', '.join(result['secrets_without_value']) or 'none'}")
245
+ if result["backend"] != "vault":
246
+ orphans = result["orphan_items"]
247
+ print(f"orphan keyring items: {'not checked on this keyring' if orphans is None else ', '.join(orphans) or 'none'}")
248
+ if result.get("interpreter"):
249
+ print(f"interpreter: {result['interpreter']}")
250
+ for item in result["missing_items"]:
251
+ print(f"warning: the catalogue lists a value for {item} that the keyring no longer holds")
252
+ token_source = result.get("token_source")
253
+ if result["backend"] == "vault" and token_source is not None:
254
+ print(f"token source: {token_source}")
255
+ if token_source.startswith("file "):
256
+ print("warning: the Vault token is read from a file; any process of this user can read it. Prefer saddlebag serve --keep-token.")
257
+ if result["vault_token_file"]:
258
+ if result["backend"] == "vault":
259
+ print("warning: ~/.vault-token exists on the daemon's host; any process of this user can read it")
260
+ else:
261
+ print("warning: ~/.vault-token exists; any process of this user can read it")
262
+ healthy = result["store_reachable"] and not result["secrets_without_value"] and not result["missing_items"]
263
+ return 0 if healthy else 1
264
+
265
+
266
+ def _tag_option(parser: argparse.ArgumentParser) -> None:
267
+ parser.add_argument("--tag", action="append", default=[], metavar="K=V", help="match secrets carrying this tag; repeatable")
268
+
269
+
270
+ def build_parser() -> argparse.ArgumentParser:
271
+ p = argparse.ArgumentParser(prog="saddlebag", description="Fill secrets into browsers and commands without printing them.")
272
+ p.add_argument("--version", action="version", version=f"saddlebag {_pkg_version('stablemate-saddlebag')}")
273
+ p.add_argument("-v", "--verbose", action="store_true", help="debug logging on stderr")
274
+ sub = p.add_subparsers(dest="command", required=True)
275
+
276
+ s = sub.add_parser("serve", help="run the daemon; the Vault token comes from stdin, the OS keyring or a 0600 file")
277
+ s.add_argument("--socket", type=Path, metavar="PATH", help="socket to listen on")
278
+ s.add_argument("--vault-addr", metavar="URL", help="Vault address (default $VAULT_ADDR)")
279
+ s.add_argument("--cdp", default=browser.DEFAULT_ENDPOINT, metavar="URL", help="DevTools endpoint, loopback only")
280
+ s.add_argument(
281
+ "--keep-token",
282
+ action="store_true",
283
+ help="read a Vault token from stdin, store it in the OS keyring for later `serve` runs, and exit",
284
+ )
285
+ s.set_defaults(func=cmd_serve)
286
+
287
+ ls = sub.add_parser("list", help="list secrets and what they are for")
288
+ _tag_option(ls)
289
+ ls.add_argument("--json", action="store_true")
290
+ ls.set_defaults(func=cmd_list)
291
+
292
+ tg = sub.add_parser("tags", help="list the distinct tags of the matching secrets")
293
+ _tag_option(tg)
294
+ tg.set_defaults(func=cmd_tags)
295
+
296
+ sec = sub.add_parser("secret", help="create and change secrets")
297
+ ssub = sec.add_subparsers(dest="secret_command", required=True)
298
+ c = ssub.add_parser("create", help="create a secret with no value yet")
299
+ c.add_argument("name")
300
+ c.add_argument("--username")
301
+ c.add_argument("--description")
302
+ c.add_argument("--var", help="environment variable `exec` fills with the password")
303
+ c.add_argument("--tag", action="append", default=[], metavar="K=V")
304
+ c.add_argument("--json", action="store_true")
305
+ c.set_defaults(func=cmd_create)
306
+ st = ssub.add_parser("set", help="store a value read from stdin")
307
+ st.add_argument("name")
308
+ st.add_argument("--field", choices=VALUE_FIELDS, default="password")
309
+ st.set_defaults(func=cmd_set)
310
+ us = ssub.add_parser("unset", help="forget a stored value")
311
+ us.add_argument("name")
312
+ us.add_argument("--field", choices=VALUE_FIELDS, required=True)
313
+ us.set_defaults(func=cmd_unset)
314
+ rm = ssub.add_parser("remove", help="delete a secret and every version")
315
+ rm.add_argument("name")
316
+ rm.set_defaults(func=cmd_remove)
317
+ for verb in ("tag", "untag"):
318
+ t = ssub.add_parser(verb, help=f"{verb} a secret")
319
+ t.add_argument("name")
320
+ t.add_argument("tags", nargs="+", metavar="K=V")
321
+ t.set_defaults(func=cmd_tag, command_name=verb)
322
+
323
+ f = sub.add_parser("fill", help="type a value into a browser field over CDP")
324
+ f.add_argument("name")
325
+ f.add_argument("--field", choices=FIELDS, default="password")
326
+ f.add_argument("--selector", required=True, metavar="CSS")
327
+ f.add_argument("--url-contains", metavar="TEXT")
328
+ f.add_argument("--cdp", metavar="URL", help="DevTools endpoint, loopback only (default: the daemon's)")
329
+ f.add_argument("--min-seconds", type=int, default=5, metavar="N")
330
+ f.add_argument("--json", action="store_true")
331
+ f.set_defaults(func=cmd_fill)
332
+
333
+ e = sub.add_parser("exec", help="run a command with secrets in its environment")
334
+ _tag_option(e)
335
+ e.add_argument("--env", action="append", default=[], metavar="VAR=NAME[.field]")
336
+ e.add_argument("command", nargs=argparse.REMAINDER, metavar="-- CMD")
337
+ e.set_defaults(func=cmd_exec)
338
+
339
+ d = sub.add_parser("doctor", help="check the daemon, Vault and the stored values")
340
+ d.add_argument("--json", action="store_true")
341
+ d.set_defaults(func=cmd_doctor)
342
+ return p
343
+
344
+
345
+ def main(argv: list[str] | None = None) -> None:
346
+ args = build_parser().parse_args(argv)
347
+ logging.basicConfig(
348
+ stream=sys.stderr,
349
+ level=logging.DEBUG if args.verbose else logging.INFO,
350
+ format="%(name)s %(levelname)s: %(message)s",
351
+ )
352
+ try:
353
+ raise SystemExit(args.func(args))
354
+ except UsageError as exc:
355
+ raise SystemExit(_error(str(exc), 2)) from exc
356
+ except BrokenPipeError:
357
+ raise SystemExit(0) from None
358
+
359
+
360
+ if __name__ == "__main__":
361
+ main()