guhio 0.1.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.
guhio-0.1.0/PKG-INFO ADDED
@@ -0,0 +1,90 @@
1
+ Metadata-Version: 2.4
2
+ Name: guhio
3
+ Version: 0.1.0
4
+ Summary: A local password vault for agent workflows (guhio: Sanskrit for secret)
5
+ License: MIT
6
+ Requires-Python: >=3.10
7
+ Description-Content-Type: text/markdown
8
+ Requires-Dist: cryptography>=42.0.0
9
+ Requires-Dist: flask>=3.0.0
10
+ Provides-Extra: dev
11
+ Requires-Dist: pytest>=8.0.0; extra == "dev"
12
+
13
+ # Guhio
14
+
15
+ Guhio (Sanskrit: गुह्य, "secret") is a local password vault built for agent
16
+ workflows. It lets humans provide credentials without typing them into an agent
17
+ context, and it lets agents *use* credentials by name without ever seeing the
18
+ plaintext values.
19
+
20
+ ## Features
21
+
22
+ - **Encrypted local vault** using PBKDF2 key derivation and Fernet symmetric
23
+ encryption.
24
+ - **Human-friendly CLI** with secure password prompts.
25
+ - **Agent-safe usage** via `guhio exec`, which injects credentials into a
26
+ subprocess as environment variables.
27
+ - **Agent skill** following the [Agent Skills](https://agentskills.io/specification)
28
+ format so AI assistants can discover and use the vault safely.
29
+ - **Local web dashboard** for adding, viewing, and managing credentials in a
30
+ browser.
31
+
32
+ ## Installation
33
+
34
+ ```bash
35
+ python3 -m venv .venv
36
+ .venv/bin/pip install -e '.[dev]'
37
+ ```
38
+
39
+ ## CLI Usage
40
+
41
+ ```bash
42
+ # Create a vault
43
+ .venv/bin/python -m guhio.cli init
44
+
45
+ # Add a credential (prompts securely)
46
+ .venv/bin/python -m guhio.cli add github
47
+
48
+ # List credentials
49
+ .venv/bin/python -m guhio.cli list
50
+
51
+ # Use a credential without revealing it
52
+ .venv/bin/python -m guhio.cli exec --with github:GITHUB_TOKEN -- curl \
53
+ -H "Authorization: token $GITHUB_TOKEN" https://api.github.com/user
54
+
55
+ # Remove a credential
56
+ .venv/bin/python -m guhio.cli remove github
57
+ ```
58
+
59
+ The default vault file is `~/.guhio/vault.json`. Use `--vault <path>` to
60
+ override.
61
+
62
+ ## Dashboard
63
+
64
+ Start the local web dashboard:
65
+
66
+ ```bash
67
+ .venv/bin/python -m guhio.cli dashboard
68
+ ```
69
+
70
+ Then open <http://127.0.0.1:5000> in your browser. The dashboard only binds to
71
+ localhost and requires the master password to unlock the vault.
72
+
73
+ ## Agent Skill
74
+
75
+ A skill conforming to the [Agent Skills specification](https://agentskills.io/specification)
76
+ is included under `.claude/skills/guhio/`. When loaded, it instructs agents to
77
+ use `guhio exec` for credential-based operations and to direct humans to
78
+ `guhio add` when a credential is missing.
79
+
80
+ ## Development
81
+
82
+ ```bash
83
+ .venv/bin/python -m pytest
84
+ ```
85
+
86
+ ## Security Notes
87
+
88
+ This is an MVP. The vault is only as strong as the master password and the
89
+ security of the machine it runs on. While unlocked, plaintext values reside in
90
+ process memory. Do not commit vault files to version control.
guhio-0.1.0/README.md ADDED
@@ -0,0 +1,78 @@
1
+ # Guhio
2
+
3
+ Guhio (Sanskrit: गुह्य, "secret") is a local password vault built for agent
4
+ workflows. It lets humans provide credentials without typing them into an agent
5
+ context, and it lets agents *use* credentials by name without ever seeing the
6
+ plaintext values.
7
+
8
+ ## Features
9
+
10
+ - **Encrypted local vault** using PBKDF2 key derivation and Fernet symmetric
11
+ encryption.
12
+ - **Human-friendly CLI** with secure password prompts.
13
+ - **Agent-safe usage** via `guhio exec`, which injects credentials into a
14
+ subprocess as environment variables.
15
+ - **Agent skill** following the [Agent Skills](https://agentskills.io/specification)
16
+ format so AI assistants can discover and use the vault safely.
17
+ - **Local web dashboard** for adding, viewing, and managing credentials in a
18
+ browser.
19
+
20
+ ## Installation
21
+
22
+ ```bash
23
+ python3 -m venv .venv
24
+ .venv/bin/pip install -e '.[dev]'
25
+ ```
26
+
27
+ ## CLI Usage
28
+
29
+ ```bash
30
+ # Create a vault
31
+ .venv/bin/python -m guhio.cli init
32
+
33
+ # Add a credential (prompts securely)
34
+ .venv/bin/python -m guhio.cli add github
35
+
36
+ # List credentials
37
+ .venv/bin/python -m guhio.cli list
38
+
39
+ # Use a credential without revealing it
40
+ .venv/bin/python -m guhio.cli exec --with github:GITHUB_TOKEN -- curl \
41
+ -H "Authorization: token $GITHUB_TOKEN" https://api.github.com/user
42
+
43
+ # Remove a credential
44
+ .venv/bin/python -m guhio.cli remove github
45
+ ```
46
+
47
+ The default vault file is `~/.guhio/vault.json`. Use `--vault <path>` to
48
+ override.
49
+
50
+ ## Dashboard
51
+
52
+ Start the local web dashboard:
53
+
54
+ ```bash
55
+ .venv/bin/python -m guhio.cli dashboard
56
+ ```
57
+
58
+ Then open <http://127.0.0.1:5000> in your browser. The dashboard only binds to
59
+ localhost and requires the master password to unlock the vault.
60
+
61
+ ## Agent Skill
62
+
63
+ A skill conforming to the [Agent Skills specification](https://agentskills.io/specification)
64
+ is included under `.claude/skills/guhio/`. When loaded, it instructs agents to
65
+ use `guhio exec` for credential-based operations and to direct humans to
66
+ `guhio add` when a credential is missing.
67
+
68
+ ## Development
69
+
70
+ ```bash
71
+ .venv/bin/python -m pytest
72
+ ```
73
+
74
+ ## Security Notes
75
+
76
+ This is an MVP. The vault is only as strong as the master password and the
77
+ security of the machine it runs on. While unlocked, plaintext values reside in
78
+ process memory. Do not commit vault files to version control.
@@ -0,0 +1,32 @@
1
+ [build-system]
2
+ requires = ["setuptools>=61.0"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "guhio"
7
+ version = "0.1.0"
8
+ description = "A local password vault for agent workflows (guhio: Sanskrit for secret)"
9
+ readme = "README.md"
10
+ license = {text = "MIT"}
11
+ requires-python = ">=3.10"
12
+ dependencies = [
13
+ "cryptography>=42.0.0",
14
+ "flask>=3.0.0",
15
+ ]
16
+
17
+ [project.optional-dependencies]
18
+ dev = [
19
+ "pytest>=8.0.0",
20
+ ]
21
+
22
+ [project.scripts]
23
+ guhio = "guhio.cli:main"
24
+
25
+ [tool.setuptools.packages.find]
26
+ where = ["src"]
27
+
28
+ [tool.setuptools.package-data]
29
+ guhio = ["templates/*.html"]
30
+
31
+ [tool.pytest.ini_options]
32
+ testpaths = ["tests"]
guhio-0.1.0/setup.cfg ADDED
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,6 @@
1
+ """Guhio: a local password vault for agent workflows.
2
+
3
+ guhio (Sanskrit: गुह्य) means "secret" or "hidden".
4
+ """
5
+
6
+ __version__ = "0.1.0"
@@ -0,0 +1,309 @@
1
+ """Command-line interface for the password vault."""
2
+
3
+ import argparse
4
+ import getpass
5
+ import os
6
+ import subprocess
7
+ import sys
8
+ from pathlib import Path
9
+
10
+ from guhio.store import (
11
+ EntryNotFoundError,
12
+ InvalidPasswordError,
13
+ Vault,
14
+ VaultError,
15
+ VaultNotFoundError,
16
+ )
17
+
18
+
19
+ def _prompt_password(prompt: str = "Master password: ") -> str:
20
+ """Prompt for a password without echoing to the terminal."""
21
+ return getpass.getpass(prompt)
22
+
23
+
24
+ def _prompt_new_password() -> str:
25
+ """Prompt for a new master password twice to avoid typos."""
26
+ first = getpass.getpass("Set master password: ")
27
+ second = getpass.getpass("Confirm master password: ")
28
+ if first != second:
29
+ print("Passwords do not match.", file=sys.stderr)
30
+ sys.exit(1)
31
+ if not first:
32
+ print("Master password cannot be empty.", file=sys.stderr)
33
+ sys.exit(1)
34
+ return first
35
+
36
+
37
+ def _read_env_password() -> str | None:
38
+ """Read the master password from the environment if available."""
39
+ return os.environ.get("GUHIO_MASTER_PASSWORD")
40
+
41
+
42
+ def _get_password_for_unlock(args) -> str:
43
+ """Determine the master password for unlocking an existing vault."""
44
+ env_password = _read_env_password()
45
+ if env_password is not None:
46
+ return env_password
47
+ if args.password:
48
+ return args.password
49
+ return _prompt_password()
50
+
51
+
52
+ def cmd_init(args) -> None:
53
+ """Create a new vault."""
54
+ vault = Vault(args.vault)
55
+ if vault.exists():
56
+ print(f"Vault already exists at {vault.path}", file=sys.stderr)
57
+ sys.exit(1)
58
+ master_password = _prompt_new_password()
59
+ vault.create(master_password)
60
+ print(f"Vault created at {vault.path}")
61
+
62
+
63
+ def cmd_unlock(args) -> None:
64
+ """Unlock the vault and keep it unlocked in memory (no-op for CLI)."""
65
+ vault = Vault(args.vault)
66
+ password = _get_password_for_unlock(args)
67
+ try:
68
+ vault.unlock(password)
69
+ except InvalidPasswordError as exc:
70
+ print(f"Error: {exc}", file=sys.stderr)
71
+ sys.exit(1)
72
+ print("Vault unlocked.")
73
+
74
+
75
+ def cmd_add(args) -> None:
76
+ """Add a new credential to the vault."""
77
+ vault = Vault(args.vault)
78
+ password = _get_password_for_unlock(args)
79
+ try:
80
+ vault.unlock(password)
81
+ except InvalidPasswordError as exc:
82
+ print(f"Error: {exc}", file=sys.stderr)
83
+ sys.exit(1)
84
+
85
+ if args.value:
86
+ value = args.value
87
+ else:
88
+ value = getpass.getpass(f"Enter value for '{args.name}': ")
89
+ if not value:
90
+ print("Credential value cannot be empty.", file=sys.stderr)
91
+ sys.exit(1)
92
+
93
+ try:
94
+ vault.add(args.name, value)
95
+ except VaultError as exc:
96
+ print(f"Error: {exc}", file=sys.stderr)
97
+ sys.exit(1)
98
+ print(f"Credential '{args.name}' added.")
99
+
100
+
101
+ def cmd_list(args) -> None:
102
+ """List stored credential names."""
103
+ vault = Vault(args.vault)
104
+ password = _get_password_for_unlock(args)
105
+ try:
106
+ vault.unlock(password)
107
+ except InvalidPasswordError as exc:
108
+ print(f"Error: {exc}", file=sys.stderr)
109
+ sys.exit(1)
110
+
111
+ entries = vault.list_entries()
112
+ if not entries:
113
+ print("Vault is empty.")
114
+ return
115
+ for entry in entries:
116
+ print(f"{entry.name}\t{entry.created_at.isoformat()}")
117
+
118
+
119
+ def cmd_remove(args) -> None:
120
+ """Remove a credential from the vault."""
121
+ vault = Vault(args.vault)
122
+ password = _get_password_for_unlock(args)
123
+ try:
124
+ vault.unlock(password)
125
+ except InvalidPasswordError as exc:
126
+ print(f"Error: {exc}", file=sys.stderr)
127
+ sys.exit(1)
128
+
129
+ try:
130
+ vault.remove(args.name)
131
+ except EntryNotFoundError as exc:
132
+ print(f"Error: {exc}", file=sys.stderr)
133
+ sys.exit(1)
134
+ print(f"Credential '{args.name}' removed.")
135
+
136
+
137
+ def cmd_get(args) -> None:
138
+ """Print a credential value to stdout.
139
+
140
+ This subcommand exists for human use and scripts; agents should prefer
141
+ ``exec`` to avoid exposing values in their context.
142
+ """
143
+ vault = Vault(args.vault)
144
+ password = _get_password_for_unlock(args)
145
+ try:
146
+ vault.unlock(password)
147
+ except InvalidPasswordError as exc:
148
+ print(f"Error: {exc}", file=sys.stderr)
149
+ sys.exit(1)
150
+
151
+ try:
152
+ print(vault.get(args.name))
153
+ except EntryNotFoundError as exc:
154
+ print(f"Error: {exc}", file=sys.stderr)
155
+ sys.exit(1)
156
+
157
+
158
+ def _parse_mapping(mapping: str) -> tuple[str, str]:
159
+ """Parse 'name:ENV_VAR' into credential name and environment variable name."""
160
+ if ":" not in mapping:
161
+ return mapping, mapping.upper()
162
+ name, env_var = mapping.split(":", 1)
163
+ return name, env_var
164
+
165
+
166
+ def cmd_dashboard(args) -> None:
167
+ """Start the local web dashboard."""
168
+ from guhio.dashboard import run_dashboard
169
+
170
+ print(f"Starting Guhio dashboard at http://{args.host}:{args.port}")
171
+ print("Press Ctrl+C to stop.")
172
+ run_dashboard(host=args.host, port=args.port, debug=args.debug)
173
+
174
+
175
+ def cmd_exec(args) -> None:
176
+ """Run a command with credentials injected as environment variables."""
177
+ vault = Vault(args.vault)
178
+ password = _get_password_for_unlock(args)
179
+ try:
180
+ vault.unlock(password)
181
+ except InvalidPasswordError as exc:
182
+ print(f"Error: {exc}", file=sys.stderr)
183
+ sys.exit(1)
184
+
185
+ env = os.environ.copy()
186
+ for mapping in args.with_:
187
+ name, env_var = _parse_mapping(mapping)
188
+ try:
189
+ env[env_var] = vault.get(name)
190
+ except EntryNotFoundError as exc:
191
+ print(f"Error: {exc}", file=sys.stderr)
192
+ sys.exit(1)
193
+
194
+ command = args.command
195
+ if command and command[0] == "--":
196
+ command = command[1:]
197
+ if not command:
198
+ print("No command provided.", file=sys.stderr)
199
+ sys.exit(1)
200
+
201
+ # Run the command directly; do not invoke a shell so the value stays out of
202
+ # the command string visible to the agent.
203
+ try:
204
+ result = subprocess.run(command, env=env, check=False)
205
+ except FileNotFoundError as exc:
206
+ print(f"Error: command not found: {exc}", file=sys.stderr)
207
+ sys.exit(127)
208
+ sys.exit(result.returncode)
209
+
210
+
211
+ def build_parser() -> argparse.ArgumentParser:
212
+ """Build the argument parser."""
213
+ parser = argparse.ArgumentParser(
214
+ prog="guhio",
215
+ description="Local password vault for agent workflows (guhio: Sanskrit for secret).",
216
+ )
217
+ parser.add_argument(
218
+ "--vault",
219
+ type=Path,
220
+ default=os.environ.get("GUHIO_VAULT"),
221
+ help="Path to the vault file (default: ~/.guhio/vault.json; can also be set via GUHIO_VAULT env var)",
222
+ )
223
+ parser.add_argument(
224
+ "--password",
225
+ default=None,
226
+ help=argparse.SUPPRESS,
227
+ )
228
+
229
+ subparsers = parser.add_subparsers(dest="command", required=True)
230
+
231
+ init_parser = subparsers.add_parser("init", help="Create a new vault")
232
+ init_parser.set_defaults(func=cmd_init)
233
+
234
+ unlock_parser = subparsers.add_parser("unlock", help="Unlock the vault")
235
+ unlock_parser.set_defaults(func=cmd_unlock)
236
+
237
+ add_parser = subparsers.add_parser("add", help="Add a credential")
238
+ add_parser.add_argument("name", help="Name of the credential")
239
+ add_parser.add_argument(
240
+ "--value",
241
+ default=None,
242
+ help="Value for the credential (prompted securely if omitted)",
243
+ )
244
+ add_parser.set_defaults(func=cmd_add)
245
+
246
+ list_parser = subparsers.add_parser("list", help="List stored credentials")
247
+ list_parser.set_defaults(func=cmd_list)
248
+
249
+ remove_parser = subparsers.add_parser("remove", help="Remove a credential")
250
+ remove_parser.add_argument("name", help="Name of the credential")
251
+ remove_parser.set_defaults(func=cmd_remove)
252
+
253
+ get_parser = subparsers.add_parser("get", help="Print a credential value")
254
+ get_parser.add_argument("name", help="Name of the credential")
255
+ get_parser.set_defaults(func=cmd_get)
256
+
257
+ exec_parser = subparsers.add_parser(
258
+ "exec",
259
+ help="Run a command with credentials injected as environment variables",
260
+ )
261
+ exec_parser.add_argument(
262
+ "--with",
263
+ dest="with_",
264
+ action="append",
265
+ default=[],
266
+ metavar="NAME:ENV_VAR",
267
+ help="Inject credential NAME as environment variable ENV_VAR",
268
+ )
269
+ exec_parser.add_argument(
270
+ "command",
271
+ nargs=argparse.REMAINDER,
272
+ help="Command and arguments to run",
273
+ )
274
+ exec_parser.set_defaults(func=cmd_exec)
275
+
276
+ dashboard_parser = subparsers.add_parser(
277
+ "dashboard",
278
+ help="Start the local web dashboard for managing credentials",
279
+ )
280
+ dashboard_parser.add_argument(
281
+ "--host",
282
+ default=os.environ.get("GUHIO_HOST", "127.0.0.1"),
283
+ help="Host to bind the dashboard to (default: 127.0.0.1; can also be set via GUHIO_HOST env var)",
284
+ )
285
+ dashboard_parser.add_argument(
286
+ "--port",
287
+ type=int,
288
+ default=int(os.environ.get("GUHIO_PORT", "5000")),
289
+ help="Port to bind the dashboard to (default: 5000; can also be set via GUHIO_PORT env var)",
290
+ )
291
+ dashboard_parser.add_argument(
292
+ "--debug",
293
+ action="store_true",
294
+ help="Run Flask in debug mode (not recommended with vault sessions)",
295
+ )
296
+ dashboard_parser.set_defaults(func=cmd_dashboard)
297
+
298
+ return parser
299
+
300
+
301
+ def main() -> None:
302
+ """Entry point for the CLI."""
303
+ parser = build_parser()
304
+ args = parser.parse_args()
305
+ args.func(args)
306
+
307
+
308
+ if __name__ == "__main__":
309
+ main()
@@ -0,0 +1,48 @@
1
+ """Cryptographic helpers for the password vault."""
2
+
3
+ import base64
4
+ import secrets
5
+
6
+ from cryptography.fernet import Fernet, InvalidToken
7
+ from cryptography.hazmat.primitives import hashes
8
+ from cryptography.hazmat.primitives.kdf.pbkdf2 import PBKDF2HMAC
9
+
10
+
11
+ def derive_key(password: str, salt: bytes) -> bytes:
12
+ """Derive a Fernet key from a password and salt."""
13
+ kdf = PBKDF2HMAC(
14
+ algorithm=hashes.SHA256(),
15
+ length=32,
16
+ salt=salt,
17
+ iterations=600_000,
18
+ )
19
+ return base64.urlsafe_b64encode(kdf.derive(password.encode("utf-8")))
20
+
21
+
22
+ def generate_salt(size: int = 16) -> bytes:
23
+ """Generate a random salt."""
24
+ return secrets.token_bytes(size)
25
+
26
+
27
+ def encrypt_value(password: str, plaintext: str, salt: bytes | None = None) -> bytes:
28
+ """Encrypt a plaintext value with a password.
29
+
30
+ If ``salt`` is provided it is used for key derivation; otherwise a new salt
31
+ is generated and the caller is responsible for storing it.
32
+ """
33
+ if salt is None:
34
+ salt = generate_salt()
35
+ key = derive_key(password, salt)
36
+ f = Fernet(key)
37
+ return f.encrypt(plaintext.encode("utf-8"))
38
+
39
+
40
+ def decrypt_value(password: str, salt: bytes, ciphertext: bytes) -> str:
41
+ """Decrypt a ciphertext value with a password and salt."""
42
+ key = derive_key(password, salt)
43
+ f = Fernet(key)
44
+ try:
45
+ plaintext = f.decrypt(ciphertext)
46
+ except InvalidToken as exc:
47
+ raise ValueError("Invalid master password or corrupted vault") from exc
48
+ return plaintext.decode("utf-8")
@@ -0,0 +1,116 @@
1
+ """Local web dashboard for managing the Guhio vault."""
2
+
3
+ import secrets
4
+
5
+ from flask import Flask, abort, jsonify, render_template, request, session
6
+
7
+ from guhio.store import EntryNotFoundError, Vault, VaultError
8
+
9
+ app = Flask(__name__)
10
+ app.secret_key = secrets.token_hex(32)
11
+
12
+ # Server-side session storage: maps a random token to an unlocked Vault instance.
13
+ # This keeps decrypted values out of cookies and is acceptable for the local,
14
+ # single-user dashboard. Do not use this for a multi-user service.
15
+ _vault_sessions: dict[str, Vault] = {}
16
+
17
+
18
+ def _require_vault() -> Vault:
19
+ """Return the unlocked vault for the current session or abort with 401."""
20
+ token = session.get("vault_token")
21
+ vault = _vault_sessions.get(token) if token else None
22
+ if vault is None or not vault.is_unlocked():
23
+ abort(401)
24
+ return vault
25
+
26
+
27
+ @app.route("/")
28
+ def index() -> str:
29
+ """Render the dashboard page."""
30
+ return render_template("dashboard.html")
31
+
32
+
33
+ @app.route("/api/status")
34
+ def status() -> dict:
35
+ """Return whether the vault is unlocked for this session."""
36
+ token = session.get("vault_token")
37
+ vault = _vault_sessions.get(token) if token else None
38
+ return jsonify({"unlocked": vault is not None and vault.is_unlocked()})
39
+
40
+
41
+ @app.route("/api/unlock", methods=["POST"])
42
+ def unlock() -> tuple[dict, int]:
43
+ """Unlock the vault and create a server-side session."""
44
+ data = request.get_json(silent=True) or {}
45
+ password = data.get("password", "")
46
+
47
+ vault = Vault()
48
+ try:
49
+ vault.unlock(password)
50
+ except VaultError as exc:
51
+ return jsonify({"error": str(exc)}), 401
52
+
53
+ token = secrets.token_urlsafe(32)
54
+ _vault_sessions[token] = vault
55
+ session["vault_token"] = token
56
+ return jsonify({"unlocked": True})
57
+
58
+
59
+ @app.route("/api/lock", methods=["POST"])
60
+ def lock() -> dict:
61
+ """Lock the vault and clear the server-side session."""
62
+ token = session.pop("vault_token", None)
63
+ if token:
64
+ _vault_sessions.pop(token, None)
65
+ return jsonify({"unlocked": False})
66
+
67
+
68
+ @app.route("/api/credentials", methods=["GET"])
69
+ def list_credentials() -> list[dict]:
70
+ """List credential names and metadata without values."""
71
+ vault = _require_vault()
72
+ entries = vault.list_entries()
73
+ return jsonify(
74
+ [
75
+ {"name": entry.name, "created_at": entry.created_at.isoformat()}
76
+ for entry in entries
77
+ ]
78
+ )
79
+
80
+
81
+ @app.route("/api/credentials", methods=["POST"])
82
+ def add_credential() -> tuple[dict, int]:
83
+ """Add a new credential to the vault."""
84
+ vault = _require_vault()
85
+ data = request.get_json(silent=True) or {}
86
+ name = data.get("name", "").strip()
87
+ value = data.get("value", "")
88
+
89
+ if not name or not value:
90
+ return jsonify({"error": "Name and value are required"}), 400
91
+
92
+ try:
93
+ vault.add(name, value)
94
+ except VaultError as exc:
95
+ return jsonify({"error": str(exc)}), 409
96
+
97
+ return jsonify({"ok": True})
98
+
99
+
100
+ @app.route("/api/credentials/<path:name>", methods=["DELETE"])
101
+ def remove_credential(name: str) -> tuple[dict, int]:
102
+ """Remove a credential from the vault."""
103
+ vault = _require_vault()
104
+ try:
105
+ vault.remove(name)
106
+ except EntryNotFoundError as exc:
107
+ return jsonify({"error": str(exc)}), 404
108
+ return jsonify({"ok": True})
109
+
110
+
111
+ def run_dashboard(host: str = "127.0.0.1", port: int = 5000, debug: bool = False) -> None:
112
+ """Start the Flask development server."""
113
+ # Do not use the reloader: it forks a child process and clears the
114
+ # in-memory _vault_sessions dictionary, which would lock users out after
115
+ # every auto-reload.
116
+ app.run(host=host, port=port, debug=debug, use_reloader=False)