keyward 0.0.1__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.
keyward/__init__.py ADDED
@@ -0,0 +1,7 @@
1
+ """Local secret broker that keeps API keys out of files AI agents can read."""
2
+
3
+ __version__ = "0.0.1"
4
+
5
+ from keyward.inject import ActivateResult, DaemonNotRunning, activate
6
+
7
+ __all__ = ["ActivateResult", "DaemonNotRunning", "__version__", "activate"]
keyward/__main__.py ADDED
@@ -0,0 +1,4 @@
1
+ from keyward.cli import app
2
+
3
+ if __name__ == "__main__":
4
+ app()
keyward/agent.py ADDED
@@ -0,0 +1,93 @@
1
+ from __future__ import annotations
2
+
3
+ import os
4
+ import plistlib
5
+ import subprocess
6
+ from pathlib import Path
7
+
8
+ LABEL = "com.keyward.daemon"
9
+
10
+
11
+ def plist_path() -> Path:
12
+ return Path.home() / "Library" / "LaunchAgents" / f"{LABEL}.plist"
13
+
14
+
15
+ def log_dir() -> Path:
16
+ return Path.home() / "Library" / "Logs" / "keyward"
17
+
18
+
19
+ def _uid() -> int:
20
+ if not hasattr(os, "getuid"):
21
+ raise RuntimeError("LaunchAgent install is macOS-only; not supported on this OS.")
22
+ return os.getuid()
23
+
24
+
25
+ def _bootout() -> None:
26
+ # Idempotent: non-zero exit when not loaded is fine.
27
+ subprocess.run(
28
+ ["launchctl", "bootout", f"gui/{_uid()}/{LABEL}"],
29
+ check=False,
30
+ capture_output=True,
31
+ )
32
+
33
+
34
+ def _bootstrap(plist: Path) -> None:
35
+ result = subprocess.run(
36
+ ["launchctl", "bootstrap", f"gui/{_uid()}", str(plist)],
37
+ check=False,
38
+ capture_output=True,
39
+ )
40
+ if result.returncode != 0:
41
+ raise RuntimeError(
42
+ f"launchctl bootstrap failed ({result.returncode}): "
43
+ f"{result.stderr.decode().strip() or result.stdout.decode().strip()}"
44
+ )
45
+
46
+
47
+ def _write_plist(plist: Path, python_executable: str) -> None:
48
+ plist.parent.mkdir(parents=True, exist_ok=True)
49
+ log_dir().mkdir(parents=True, exist_ok=True)
50
+ contents = {
51
+ "Label": LABEL,
52
+ "ProgramArguments": [python_executable, "-m", "keyward.daemon"],
53
+ "RunAtLoad": True,
54
+ "KeepAlive": True,
55
+ "StandardOutPath": str(log_dir() / "daemon.out"),
56
+ "StandardErrorPath": str(log_dir() / "daemon.err"),
57
+ # Don't run faster than once every 5s if the daemon crash-loops.
58
+ "ThrottleInterval": 5,
59
+ }
60
+ with plist.open("wb") as f:
61
+ plistlib.dump(contents, f)
62
+
63
+
64
+ def install(python_executable: str) -> Path:
65
+ """Write the LaunchAgent plist and load it. Idempotent."""
66
+ path = plist_path()
67
+ _write_plist(path, python_executable)
68
+ _bootout()
69
+ _bootstrap(path)
70
+ return path
71
+
72
+
73
+ def uninstall() -> bool:
74
+ """Unload and remove the LaunchAgent. Returns True if a plist was removed."""
75
+ path = plist_path()
76
+ _bootout()
77
+ if path.exists():
78
+ path.unlink()
79
+ return True
80
+ return False
81
+
82
+
83
+ def restart() -> None:
84
+ """Kickstart the LaunchAgent, forcing it to reload config and secret cache."""
85
+ subprocess.run(
86
+ ["launchctl", "kickstart", "-k", f"gui/{_uid()}/{LABEL}"],
87
+ check=False,
88
+ capture_output=True,
89
+ )
90
+
91
+
92
+ def is_installed() -> bool:
93
+ return plist_path().exists()
keyward/cli.py ADDED
@@ -0,0 +1,269 @@
1
+ from __future__ import annotations
2
+
3
+ import contextlib
4
+ import json
5
+ import os
6
+ import platform
7
+ import signal
8
+ import subprocess
9
+ import sys
10
+ import time
11
+
12
+ import typer
13
+
14
+ from keyward import __version__, agent, store
15
+ from keyward.config import daemon_file, ensure_dirs
16
+ from keyward.discovery import live_daemon_info
17
+
18
+ app = typer.Typer(
19
+ name="keyward",
20
+ help="Local secret broker. Keeps API keys out of files AI agents can read.",
21
+ no_args_is_help=True,
22
+ add_completion=False,
23
+ )
24
+
25
+
26
+ def _version_cb(value: bool) -> None:
27
+ if value:
28
+ typer.echo(f"keyward {__version__}")
29
+ raise typer.Exit()
30
+
31
+
32
+ @app.callback()
33
+ def _root(
34
+ version: bool = typer.Option(
35
+ False,
36
+ "--version",
37
+ callback=_version_cb,
38
+ is_eager=True,
39
+ help="Show version and exit.",
40
+ ),
41
+ ) -> None:
42
+ pass
43
+
44
+
45
+ def _hint_restart_if_running() -> None:
46
+ if live_daemon_info() is not None:
47
+ typer.echo("hint: a daemon is running. Run 'keyward restart' to reload the change.")
48
+
49
+
50
+ @app.command()
51
+ def init(
52
+ uninstall: bool = typer.Option(False, "--uninstall", help="Remove the login agent."),
53
+ ) -> None:
54
+ """Create config/state dirs and install the daemon as a login agent."""
55
+ ensure_dirs()
56
+
57
+ if uninstall:
58
+ if platform.system() != "Darwin":
59
+ typer.echo("uninstalling a login agent is only wired up for macOS in v0.2", err=True)
60
+ raise typer.Exit(1)
61
+ removed = agent.uninstall()
62
+ typer.echo("removed LaunchAgent" if removed else "no LaunchAgent was installed")
63
+ return
64
+
65
+ typer.echo(f"config dir ready: {daemon_file().parent}")
66
+
67
+ system = platform.system()
68
+ if system == "Darwin":
69
+ try:
70
+ path = agent.install(sys.executable)
71
+ except RuntimeError as e:
72
+ typer.echo(f"error: {e}", err=True)
73
+ raise typer.Exit(1) from None
74
+ typer.echo(f"installed LaunchAgent at {path}")
75
+ typer.echo("daemon will start at login (and is running now).")
76
+ elif system == "Linux":
77
+ typer.echo("TODO: systemd user-unit install (Linux) lands in v0.2.1")
78
+ elif system == "Windows":
79
+ typer.echo("TODO: scheduled-task install (Windows) lands in v0.2.1")
80
+ else:
81
+ typer.echo(f"unsupported platform: {system}", err=True)
82
+ raise typer.Exit(1)
83
+
84
+
85
+ @app.command()
86
+ def restart() -> None:
87
+ """Restart the daemon to reload config and refresh the secret cache."""
88
+ if platform.system() == "Darwin" and agent.is_installed():
89
+ agent.restart()
90
+ typer.echo("kickstarted LaunchAgent daemon")
91
+ return
92
+ info = live_daemon_info()
93
+ if info is None:
94
+ typer.echo("no daemon is running")
95
+ return
96
+ try:
97
+ os.kill(info["pid"], signal.SIGTERM)
98
+ typer.echo("sent SIGTERM to ephemeral daemon; next 'keyward run' will start a fresh one")
99
+ except ProcessLookupError:
100
+ typer.echo("daemon not running")
101
+
102
+
103
+ @app.command()
104
+ def add(
105
+ name: str = typer.Argument(..., help="Short name for this key, e.g. openai."),
106
+ endpoint: str = typer.Option(..., "--endpoint", help="Allowlisted host, e.g. api.openai.com."),
107
+ env: list[str] = typer.Option(
108
+ None,
109
+ "--env",
110
+ help="Env var to set to the token (repeatable). Defaults to <NAME>_API_KEY.",
111
+ ),
112
+ base_url_env: str | None = typer.Option(
113
+ None,
114
+ "--base-url-env",
115
+ help="Env var for the base URL pointing at the daemon. Defaults to <NAME>_BASE_URL.",
116
+ ),
117
+ auth_style: str = typer.Option(
118
+ "bearer",
119
+ "--auth-style",
120
+ help="Upstream auth style: 'bearer' (OpenAI-style) or 'x-api-key' (Anthropic-style).",
121
+ ),
122
+ ) -> None:
123
+ """Store a secret in the OS keychain and mint a token for it."""
124
+ if auth_style not in store.AUTH_STYLES:
125
+ typer.echo(
126
+ f"error: --auth-style must be one of {store.AUTH_STYLES}, got {auth_style!r}",
127
+ err=True,
128
+ )
129
+ raise typer.Exit(2)
130
+ secret = typer.prompt("secret", hide_input=True)
131
+ env_vars = list(env) if env else [f"{name.upper()}_API_KEY"]
132
+ if base_url_env is None:
133
+ base_url_env = f"{name.upper()}_BASE_URL"
134
+ try:
135
+ entry = store.add_key(name, secret, endpoint, env_vars, base_url_env, auth_style)
136
+ except KeyError as e:
137
+ typer.echo(f"error: {e}", err=True)
138
+ raise typer.Exit(1) from None
139
+ typer.echo(f"added '{entry.name}' -> {entry.endpoint} ({entry.auth_style})")
140
+ typer.echo(f" token: {entry.token}")
141
+ typer.echo(f" env vars: {', '.join(entry.env_vars)}")
142
+ if entry.base_url_env:
143
+ typer.echo(f" base url: {entry.base_url_env}")
144
+ _hint_restart_if_running()
145
+
146
+
147
+ @app.command()
148
+ def rotate(name: str) -> None:
149
+ """Replace the secret for an existing token. Token stays the same."""
150
+ secret = typer.prompt("new secret", hide_input=True)
151
+ entry = store.rotate_secret(name, secret)
152
+ if entry is None:
153
+ typer.echo(f"error: no key named '{name}'", err=True)
154
+ raise typer.Exit(1)
155
+ typer.echo(f"rotated secret for '{name}' (token unchanged: {entry.token})")
156
+ _hint_restart_if_running()
157
+
158
+
159
+ @app.command("rm")
160
+ def remove(
161
+ name: str,
162
+ yes: bool = typer.Option(False, "--yes", "-y", help="Skip confirmation prompt."),
163
+ ) -> None:
164
+ """Delete a secret and revoke its token."""
165
+ if store.get_key(name) is None:
166
+ typer.echo(f"error: no key named '{name}'", err=True)
167
+ raise typer.Exit(1)
168
+ if not yes and not typer.confirm(f"remove '{name}' and delete its secret?"):
169
+ raise typer.Exit(0)
170
+ store.remove_key(name)
171
+ typer.echo(f"removed '{name}'")
172
+ _hint_restart_if_running()
173
+
174
+
175
+ @app.command("list")
176
+ def list_() -> None:
177
+ """Print registered keys, tokens, and endpoints."""
178
+ entries = store.list_keys()
179
+ if not entries:
180
+ typer.echo("(no keys registered; run 'keyward add <name> --endpoint <host>')")
181
+ return
182
+ width = max(len(e.name) for e in entries)
183
+ for e in entries:
184
+ style = f"[{e.auth_style}]" if e.auth_style != "bearer" else ""
185
+ typer.echo(f"{e.name.ljust(width)} {e.token} -> {e.endpoint} {style}".rstrip())
186
+
187
+
188
+ @app.command()
189
+ def approve(
190
+ name: str = typer.Argument(..., help="Key name."),
191
+ host: str = typer.Argument(..., help="Host to add to this key's allowlist."),
192
+ ) -> None:
193
+ """Add a new host to a key's allowlist. (Multi-endpoint support lands in v0.3.)"""
194
+ typer.echo(f"TODO: approve {host} for '{name}' (single-endpoint only in v0.2)")
195
+
196
+
197
+ @app.command()
198
+ def log(
199
+ since: str = typer.Option("1h", "--since", help="Time window, e.g. 10m, 2h, 1d."),
200
+ key: str | None = typer.Option(None, "--key", help="Filter to one key name."),
201
+ ) -> None:
202
+ """Tail the audit log."""
203
+ typer.echo(f"TODO: show audit log since={since} key={key}")
204
+
205
+
206
+ def _wait_for_daemon(timeout: float = 2.0) -> dict | None:
207
+ deadline = time.time() + timeout
208
+ while time.time() < deadline:
209
+ if daemon_file().exists():
210
+ try:
211
+ return json.loads(daemon_file().read_text())
212
+ except json.JSONDecodeError:
213
+ pass
214
+ time.sleep(0.05)
215
+ return None
216
+
217
+
218
+ @app.command(
219
+ context_settings={"allow_extra_args": True, "ignore_unknown_options": True},
220
+ )
221
+ def run(ctx: typer.Context) -> None:
222
+ """Run a command with daemon env vars injected.
223
+
224
+ Example: keyward run -- python app.py
225
+ """
226
+ argv = list(ctx.args)
227
+ if not argv:
228
+ typer.echo("error: provide a command after --, e.g. keyward run -- echo hi", err=True)
229
+ raise typer.Exit(2)
230
+
231
+ ensure_dirs()
232
+
233
+ info = live_daemon_info()
234
+ daemon_proc: subprocess.Popen | None = None
235
+ if info is None:
236
+ daemon_proc = subprocess.Popen([sys.executable, "-m", "keyward.daemon"])
237
+ info = _wait_for_daemon()
238
+ if info is None:
239
+ daemon_proc.terminate()
240
+ typer.echo("error: daemon did not start in time", err=True)
241
+ raise typer.Exit(1)
242
+
243
+ daemon_url = f"http://{info['host']}:{info['port']}"
244
+ env = {**os.environ, "KEYWARD_DAEMON": daemon_url}
245
+ for entry in store.list_keys():
246
+ for var in entry.env_vars:
247
+ env[var] = entry.token
248
+ if entry.base_url_env:
249
+ env[entry.base_url_env] = daemon_url
250
+
251
+ try:
252
+ exit_code = subprocess.run(argv, env=env).returncode
253
+ finally:
254
+ if daemon_proc is not None:
255
+ # This process started the daemon; clean it up. An existing long-running
256
+ # daemon (e.g. the LaunchAgent) is left alone.
257
+ with contextlib.suppress(ProcessLookupError):
258
+ os.kill(info["pid"], signal.SIGTERM)
259
+ try:
260
+ daemon_proc.wait(timeout=3.0)
261
+ except subprocess.TimeoutExpired:
262
+ daemon_proc.kill()
263
+ daemon_proc.wait(timeout=1.0)
264
+
265
+ raise typer.Exit(exit_code)
266
+
267
+
268
+ if __name__ == "__main__":
269
+ app()
keyward/config.py ADDED
@@ -0,0 +1,27 @@
1
+ from __future__ import annotations
2
+
3
+ import os
4
+ from pathlib import Path
5
+
6
+
7
+ def config_dir() -> Path:
8
+ base = os.environ.get("XDG_CONFIG_HOME") or str(Path.home() / ".config")
9
+ return Path(base) / "keyward"
10
+
11
+
12
+ def state_dir() -> Path:
13
+ base = os.environ.get("XDG_STATE_HOME") or str(Path.home() / ".local" / "state")
14
+ return Path(base) / "keyward"
15
+
16
+
17
+ def daemon_file() -> Path:
18
+ return config_dir() / "daemon.json"
19
+
20
+
21
+ def audit_log() -> Path:
22
+ return state_dir() / "audit.log"
23
+
24
+
25
+ def ensure_dirs() -> None:
26
+ config_dir().mkdir(parents=True, exist_ok=True)
27
+ state_dir().mkdir(parents=True, exist_ok=True)
keyward/daemon.py ADDED
@@ -0,0 +1,190 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ import json
5
+ import logging
6
+ import os
7
+ import signal
8
+ import socket
9
+ import sys
10
+ from typing import NamedTuple
11
+
12
+ from aiohttp import ClientError, ClientSession, web
13
+
14
+ from keyward import store
15
+ from keyward.config import daemon_file, ensure_dirs
16
+
17
+ logger = logging.getLogger("keyward.daemon")
18
+
19
+
20
+ class CacheEntry(NamedTuple):
21
+ name: str
22
+ endpoint: str
23
+ secret: str
24
+ auth_style: str
25
+
26
+
27
+ CACHE_KEY: web.AppKey[dict[str, CacheEntry]] = web.AppKey("cache", dict[str, CacheEntry])
28
+ CLIENT_KEY: web.AppKey[ClientSession] = web.AppKey("client", ClientSession)
29
+
30
+ # Hop-by-hop headers (RFC 2616) plus auth headers we rewrite ourselves.
31
+ # x-api-key is stripped so a client's fake token never reaches upstream.
32
+ HOP_BY_HOP = {
33
+ "connection",
34
+ "keep-alive",
35
+ "proxy-authenticate",
36
+ "proxy-authorization",
37
+ "te",
38
+ "trailers",
39
+ "transfer-encoding",
40
+ "upgrade",
41
+ "content-length",
42
+ "host",
43
+ "authorization",
44
+ "x-api-key",
45
+ }
46
+
47
+
48
+ def _extract_token(request: web.Request) -> str | None:
49
+ auth = request.headers.get("Authorization", "")
50
+ if auth.startswith("Bearer "):
51
+ candidate = auth[len("Bearer ") :].strip()
52
+ if candidate.startswith("kw_"):
53
+ return candidate
54
+ candidate = request.headers.get("x-api-key", "").strip()
55
+ if candidate.startswith("kw_"):
56
+ return candidate
57
+ return None
58
+
59
+
60
+ async def handle(request: web.Request) -> web.StreamResponse:
61
+ token = _extract_token(request)
62
+ if token is None:
63
+ return web.Response(
64
+ status=401,
65
+ text="missing keyward token (expected Authorization: Bearer kw_... or x-api-key: kw_...)\n",
66
+ )
67
+
68
+ cache = request.app[CACHE_KEY]
69
+ entry = cache.get(token)
70
+ if entry is None:
71
+ return web.Response(status=403, text="unknown token\n")
72
+
73
+ base = entry.endpoint if "://" in entry.endpoint else f"https://{entry.endpoint}"
74
+ if not (base.startswith("https://") or base.startswith("http://")):
75
+ # Defense in depth: reject unexpected schemes even if config.toml was tampered with.
76
+ logger.error("rejecting non-http(s) endpoint for '%s'", entry.name)
77
+ return web.Response(status=502, text="invalid upstream scheme\n")
78
+ target_url = f"{base}{request.path_qs}"
79
+
80
+ out_headers = {k: v for k, v in request.headers.items() if k.lower() not in HOP_BY_HOP}
81
+ if entry.auth_style == "x-api-key":
82
+ out_headers["x-api-key"] = entry.secret
83
+ else:
84
+ out_headers["Authorization"] = f"Bearer {entry.secret}"
85
+
86
+ body = await request.read()
87
+
88
+ logger.info("%s %s -> %s [%s]", request.method, request.path, entry.endpoint, entry.name)
89
+
90
+ client = request.app[CLIENT_KEY]
91
+ try:
92
+ upstream_cm = client.request(
93
+ request.method,
94
+ target_url,
95
+ headers=out_headers,
96
+ data=body if body else None,
97
+ allow_redirects=False,
98
+ )
99
+ except ClientError as e:
100
+ logger.warning("upstream connect error: %s", e)
101
+ return web.Response(status=502, text=f"upstream error: {e}\n")
102
+
103
+ try:
104
+ async with upstream_cm as upstream:
105
+ resp_headers = {
106
+ k: v for k, v in upstream.headers.items() if k.lower() not in HOP_BY_HOP
107
+ }
108
+ resp = web.StreamResponse(status=upstream.status, headers=resp_headers)
109
+ await resp.prepare(request)
110
+ # iter_any yields as soon as bytes arrive; important for SSE streams.
111
+ async for chunk in upstream.content.iter_any():
112
+ await resp.write(chunk)
113
+ await resp.write_eof()
114
+ return resp
115
+ except ClientError as e:
116
+ logger.warning("upstream stream error: %s", e)
117
+ return web.Response(status=502, text=f"upstream error: {e}\n")
118
+
119
+
120
+ def build_cache() -> dict[str, CacheEntry]:
121
+ cache: dict[str, CacheEntry] = {}
122
+ for entry in store.list_keys():
123
+ s = store.read_secret(entry.name)
124
+ if s is None:
125
+ logger.warning("no keychain entry for '%s'; skipping", entry.name)
126
+ continue
127
+ cache[entry.token] = CacheEntry(entry.name, entry.endpoint, s, entry.auth_style)
128
+ return cache
129
+
130
+
131
+ def create_app(cache: dict[str, CacheEntry]) -> web.Application:
132
+ app = web.Application()
133
+ app[CACHE_KEY] = cache
134
+
135
+ async def _on_startup(app: web.Application) -> None:
136
+ app[CLIENT_KEY] = ClientSession()
137
+
138
+ async def _on_cleanup(app: web.Application) -> None:
139
+ await app[CLIENT_KEY].close()
140
+
141
+ app.on_startup.append(_on_startup)
142
+ app.on_cleanup.append(_on_cleanup)
143
+ app.router.add_route("*", "/{path:.*}", handle)
144
+ return app
145
+
146
+
147
+ async def _run() -> None:
148
+ ensure_dirs()
149
+
150
+ sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
151
+ sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
152
+ sock.bind(("127.0.0.1", 0))
153
+ host, port = sock.getsockname()[:2]
154
+
155
+ cache = build_cache()
156
+ app = create_app(cache)
157
+
158
+ runner = web.AppRunner(app)
159
+ await runner.setup()
160
+ site = web.SockSite(runner, sock)
161
+ await site.start()
162
+
163
+ daemon_file().write_text(json.dumps({"host": host, "port": port, "pid": os.getpid()}))
164
+ logger.info("listening on %s:%d (%d keys cached)", host, port, len(cache))
165
+
166
+ stop = asyncio.Event()
167
+ loop = asyncio.get_running_loop()
168
+ for sig in (signal.SIGINT, signal.SIGTERM):
169
+ loop.add_signal_handler(sig, stop.set)
170
+
171
+ try:
172
+ await stop.wait()
173
+ finally:
174
+ daemon_file().unlink(missing_ok=True)
175
+ await runner.cleanup()
176
+
177
+
178
+ def main() -> None:
179
+ logging.basicConfig(
180
+ level=logging.INFO,
181
+ format="%(asctime)s %(name)s %(levelname)s %(message)s",
182
+ )
183
+ try:
184
+ asyncio.run(_run())
185
+ except KeyboardInterrupt:
186
+ sys.exit(0)
187
+
188
+
189
+ if __name__ == "__main__":
190
+ main()
keyward/discovery.py ADDED
@@ -0,0 +1,40 @@
1
+ """Daemon discovery: read daemon.json and check whether the registered process is alive."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import os
7
+ from typing import Any
8
+
9
+ from keyward.config import daemon_file
10
+
11
+
12
+ def live_daemon_info() -> dict[str, Any] | None:
13
+ """Return {host, port, pid} if a daemon is registered and its PID is alive, else None."""
14
+ path = daemon_file()
15
+ if not path.exists():
16
+ return None
17
+ try:
18
+ info = json.loads(path.read_text())
19
+ except (json.JSONDecodeError, OSError):
20
+ return None
21
+ pid = info.get("pid")
22
+ if not isinstance(pid, int):
23
+ return None
24
+ try:
25
+ os.kill(pid, 0)
26
+ except (ProcessLookupError, PermissionError):
27
+ return None
28
+ return info
29
+
30
+
31
+ def live_daemon_url() -> str | None:
32
+ """Return http://host:port for the live daemon, else None."""
33
+ info = live_daemon_info()
34
+ if info is None:
35
+ return None
36
+ host = info.get("host")
37
+ port = info.get("port")
38
+ if not host or not port:
39
+ return None
40
+ return f"http://{host}:{port}"
keyward/inject.py ADDED
@@ -0,0 +1,71 @@
1
+ """Runtime activation: rewrite env vars holding keyward tokens so SDKs talk to the daemon."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ from dataclasses import dataclass, field
7
+
8
+ from keyward.discovery import live_daemon_url
9
+ from keyward.store import list_keys
10
+
11
+
12
+ class DaemonNotRunning(RuntimeError):
13
+ """Raised by activate(strict=True) when no live keyward daemon is registered."""
14
+
15
+
16
+ @dataclass
17
+ class ActivateResult:
18
+ """Outcome of a keyward.activate() call.
19
+
20
+ Truthy when at least one key was activated, so existing ``if keyward.activate():``
21
+ checks keep working without modification.
22
+ """
23
+
24
+ activated: list[str] = field(default_factory=list)
25
+ """Keys whose base_url_env was rewritten to point at the daemon."""
26
+
27
+ skipped_no_env: list[str] = field(default_factory=list)
28
+ """Keys whose token was not found in any of their configured env_vars."""
29
+
30
+ skipped_no_base_url: list[str] = field(default_factory=list)
31
+ """Keys that have no base_url_env configured — nothing to rewrite."""
32
+
33
+ def __bool__(self) -> bool:
34
+ return bool(self.activated)
35
+
36
+
37
+ def activate(*, strict: bool = True) -> ActivateResult:
38
+ """Point SDKs at the local keyward daemon for any env var holding a keyward token.
39
+
40
+ For each registered key, if any of its env_vars is set in os.environ to the
41
+ entry's token, set the entry's base_url_env to the daemon URL. Real keys
42
+ (values that don't match a registered token) and unset vars are left alone.
43
+
44
+ Returns an ActivateResult with three lists: activated, skipped_no_env,
45
+ skipped_no_base_url. The result is truthy when at least one key was activated.
46
+
47
+ With strict=True (default), raises DaemonNotRunning if no live daemon is
48
+ registered. With strict=False, returns an empty result and leaves env untouched.
49
+ """
50
+ url = live_daemon_url()
51
+ if url is None:
52
+ if strict:
53
+ raise DaemonNotRunning(
54
+ "keyward: no live daemon. Run 'keyward init' to install the login "
55
+ "agent, or wrap the process with 'keyward run'."
56
+ )
57
+ return ActivateResult()
58
+
59
+ result = ActivateResult()
60
+ for entry in list_keys():
61
+ if not entry.base_url_env:
62
+ result.skipped_no_base_url.append(entry.name)
63
+ continue
64
+ if not any(os.environ.get(var) == entry.token for var in entry.env_vars):
65
+ result.skipped_no_env.append(entry.name)
66
+ continue
67
+ os.environ[entry.base_url_env] = url
68
+ result.activated.append(entry.name)
69
+
70
+ os.environ.setdefault("KEYWARD_DAEMON", url)
71
+ return result
keyward/py.typed ADDED
File without changes
keyward/store.py ADDED
@@ -0,0 +1,157 @@
1
+ from __future__ import annotations
2
+
3
+ import contextlib
4
+ import datetime as dt
5
+ import secrets
6
+ import tomllib
7
+ from dataclasses import dataclass, field
8
+ from pathlib import Path
9
+ from typing import Any
10
+
11
+ import keyring
12
+ import keyring.errors
13
+ import tomli_w
14
+
15
+ from keyward.config import config_dir, ensure_dirs
16
+
17
+ KEYCHAIN_SERVICE = "keyward"
18
+
19
+
20
+ def config_file() -> Path:
21
+ return config_dir() / "config.toml"
22
+
23
+
24
+ AUTH_STYLES = ("bearer", "x-api-key")
25
+ ALLOWED_SCHEMES = ("http", "https")
26
+
27
+
28
+ def _validate_endpoint(endpoint: str) -> None:
29
+ if "://" in endpoint:
30
+ scheme = endpoint.split("://", 1)[0].lower()
31
+ if scheme not in ALLOWED_SCHEMES:
32
+ raise ValueError(f"endpoint scheme must be one of {ALLOWED_SCHEMES}, got {scheme!r}")
33
+
34
+
35
+ @dataclass
36
+ class KeyEntry:
37
+ name: str
38
+ token: str
39
+ endpoint: str
40
+ env_vars: list[str] = field(default_factory=list)
41
+ base_url_env: str | None = None
42
+ auth_style: str = "bearer"
43
+ created: str = ""
44
+
45
+ def to_dict(self) -> dict[str, Any]:
46
+ d: dict[str, Any] = {
47
+ "token": self.token,
48
+ "endpoint": self.endpoint,
49
+ "env_vars": self.env_vars,
50
+ "auth_style": self.auth_style,
51
+ "created": self.created,
52
+ }
53
+ if self.base_url_env:
54
+ d["base_url_env"] = self.base_url_env
55
+ return d
56
+
57
+ @classmethod
58
+ def from_dict(cls, name: str, d: dict[str, Any]) -> KeyEntry:
59
+ return cls(
60
+ name=name,
61
+ token=d["token"],
62
+ endpoint=d["endpoint"],
63
+ env_vars=list(d.get("env_vars", [])),
64
+ base_url_env=d.get("base_url_env"),
65
+ # default to bearer for configs written before auth_style existed
66
+ auth_style=d.get("auth_style", "bearer"),
67
+ created=d.get("created", ""),
68
+ )
69
+
70
+
71
+ def mint_token() -> str:
72
+ return "kw_" + secrets.token_hex(8)
73
+
74
+
75
+ def _load_raw() -> dict[str, Any]:
76
+ path = config_file()
77
+ if not path.exists():
78
+ return {}
79
+ with path.open("rb") as f:
80
+ return tomllib.load(f)
81
+
82
+
83
+ def _save_raw(data: dict[str, Any]) -> None:
84
+ ensure_dirs()
85
+ with config_file().open("wb") as f:
86
+ tomli_w.dump(data, f)
87
+
88
+
89
+ def list_keys() -> list[KeyEntry]:
90
+ data = _load_raw()
91
+ return [KeyEntry.from_dict(n, d) for n, d in data.get("keys", {}).items()]
92
+
93
+
94
+ def get_key(name: str) -> KeyEntry | None:
95
+ data = _load_raw()
96
+ d = data.get("keys", {}).get(name)
97
+ return KeyEntry.from_dict(name, d) if d else None
98
+
99
+
100
+ def get_key_by_token(token: str) -> KeyEntry | None:
101
+ for k in list_keys():
102
+ if k.token == token:
103
+ return k
104
+ return None
105
+
106
+
107
+ def add_key(
108
+ name: str,
109
+ secret: str,
110
+ endpoint: str,
111
+ env_vars: list[str],
112
+ base_url_env: str | None,
113
+ auth_style: str = "bearer",
114
+ ) -> KeyEntry:
115
+ if auth_style not in AUTH_STYLES:
116
+ raise ValueError(f"auth_style must be one of {AUTH_STYLES}, got {auth_style!r}")
117
+ _validate_endpoint(endpoint)
118
+ if get_key(name) is not None:
119
+ raise KeyError(f"key '{name}' already exists; use 'keyward rotate' to change its secret")
120
+ keyring.set_password(KEYCHAIN_SERVICE, name, secret)
121
+ entry = KeyEntry(
122
+ name=name,
123
+ token=mint_token(),
124
+ endpoint=endpoint,
125
+ env_vars=env_vars,
126
+ base_url_env=base_url_env,
127
+ auth_style=auth_style,
128
+ created=dt.datetime.now(dt.UTC).isoformat(timespec="seconds"),
129
+ )
130
+ data = _load_raw()
131
+ data.setdefault("keys", {})[name] = entry.to_dict()
132
+ _save_raw(data)
133
+ return entry
134
+
135
+
136
+ def remove_key(name: str) -> bool:
137
+ data = _load_raw()
138
+ keys = data.get("keys", {})
139
+ if name not in keys:
140
+ return False
141
+ del keys[name]
142
+ _save_raw(data)
143
+ with contextlib.suppress(keyring.errors.PasswordDeleteError):
144
+ keyring.delete_password(KEYCHAIN_SERVICE, name)
145
+ return True
146
+
147
+
148
+ def rotate_secret(name: str, new_secret: str) -> KeyEntry | None:
149
+ entry = get_key(name)
150
+ if entry is None:
151
+ return None
152
+ keyring.set_password(KEYCHAIN_SERVICE, name, new_secret)
153
+ return entry
154
+
155
+
156
+ def read_secret(name: str) -> str | None:
157
+ return keyring.get_password(KEYCHAIN_SERVICE, name)
@@ -0,0 +1,222 @@
1
+ Metadata-Version: 2.4
2
+ Name: keyward
3
+ Version: 0.0.1
4
+ Summary: Local secret broker that keeps API keys out of files AI agents can read.
5
+ Project-URL: Homepage, https://github.com/sumedhrasal/keyward
6
+ Project-URL: Repository, https://github.com/sumedhrasal/keyward
7
+ Author: sumedh
8
+ License: MIT License
9
+
10
+ Copyright (c) 2026 sumedh
11
+
12
+ Permission is hereby granted, free of charge, to any person obtaining a copy
13
+ of this software and associated documentation files (the "Software"), to deal
14
+ in the Software without restriction, including without limitation the rights
15
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
16
+ copies of the Software, and to permit persons to whom the Software is
17
+ furnished to do so, subject to the following conditions:
18
+
19
+ The above copyright notice and this permission notice shall be included in all
20
+ copies or substantial portions of the Software.
21
+
22
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
23
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
24
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
25
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
26
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
27
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
28
+ SOFTWARE.
29
+ License-File: LICENSE
30
+ Keywords: ai-agents,api-keys,proxy,secrets,security
31
+ Classifier: Development Status :: 2 - Pre-Alpha
32
+ Classifier: Environment :: Console
33
+ Classifier: Intended Audience :: Developers
34
+ Classifier: License :: OSI Approved :: MIT License
35
+ Classifier: Operating System :: MacOS
36
+ Classifier: Operating System :: POSIX :: Linux
37
+ Classifier: Programming Language :: Python :: 3
38
+ Classifier: Programming Language :: Python :: 3.11
39
+ Classifier: Programming Language :: Python :: 3.12
40
+ Classifier: Programming Language :: Python :: 3.13
41
+ Classifier: Topic :: Security
42
+ Requires-Python: >=3.11
43
+ Requires-Dist: aiohttp>=3.9
44
+ Requires-Dist: keyring>=24.0
45
+ Requires-Dist: tomli-w>=1.0
46
+ Requires-Dist: typer>=0.12
47
+ Description-Content-Type: text/markdown
48
+
49
+ # keyward
50
+
51
+ A local secret broker for developers who run AI coding agents on their own machines.
52
+
53
+ ## The goal
54
+
55
+ Keep API keys out of any file an AI agent, co-pilot, or third-party tool can read,
56
+ without adding friction to normal development.
57
+
58
+ Your code never contains real keys. It contains opaque tokens like `kw_ab12cd34`.
59
+ A local daemon swaps the token for the real key only when the outbound request
60
+ goes to an allowlisted endpoint, and records every use.
61
+
62
+ If an agent reads your code, config, or environment, it sees tokens. Tokens are
63
+ useless off-host: they only resolve inside the daemon, which will not forward
64
+ them to destinations you have not explicitly approved.
65
+
66
+ ## Why this is not just encryption
67
+
68
+ "One-way encryption you can decrypt" does not exist. What this package actually
69
+ provides is **tokenization plus a scoped, audited forward proxy**. The security
70
+ properties that matter are:
71
+
72
+ - real secrets live in the OS keychain, never on disk in plaintext
73
+ - code and config contain only tokens
74
+ - the daemon forwards to an allowlist, so a leaked token cannot exfiltrate data to a new host
75
+ - every resolution is logged
76
+ - new destinations require explicit user approval
77
+
78
+ ## Intended user experience
79
+
80
+ Onboarding is the product. If any step feels heavier than `export KEY=...`,
81
+ it has failed its design goal.
82
+
83
+ ```
84
+ # one-time setup
85
+ pip install keyward
86
+ keyward init
87
+
88
+ # add a key (prompts for the secret; never passed on the command line)
89
+ keyward add openai --endpoint api.openai.com
90
+
91
+ # run any program with tokens injected as env vars
92
+ keyward run -- python app.py
93
+ keyward run -- pytest
94
+ keyward run -- npm start
95
+
96
+ # rotate a key in place; tokens stay the same so no code changes
97
+ keyward rotate openai
98
+
99
+ # list, remove, inspect
100
+ keyward list
101
+ keyward rm openai
102
+ keyward log --since 1h
103
+ ```
104
+
105
+ Your code stays boring:
106
+
107
+ ```python
108
+ import os, openai
109
+ client = openai.OpenAI() # reads OPENAI_API_KEY and OPENAI_BASE_URL from env
110
+ ```
111
+
112
+ Under `keyward run`, those variables point at the local daemon with a token.
113
+ Outside `keyward run`, they are not set at all.
114
+
115
+ ## Activating from inside your app
116
+
117
+ If you don't want to wrap every command with `keyward run`, call
118
+ `keyward.activate()` once near the top of your app. With the daemon installed
119
+ as a login agent (`keyward init`), this is all you need:
120
+
121
+ ```python
122
+ # .env (or your normal env-loading mechanism)
123
+ # OPENAI_API_KEY=kw_ab12cd34
124
+ import os
125
+ from dotenv import load_dotenv
126
+ load_dotenv()
127
+
128
+ import keyward
129
+ keyward.activate() # rewrites OPENAI_BASE_URL to point at the daemon
130
+
131
+ from openai import OpenAI
132
+ client = OpenAI() # transparently goes through keyward
133
+ ```
134
+
135
+ `activate()` looks at every registered key, and for each one whose `env_vars`
136
+ already hold its token in `os.environ`, sets the matching `base_url_env` to the
137
+ daemon URL. Real keys are left alone. It also exports `KEYWARD_DAEMON` as a
138
+ stable signal you can check from your code (`if "KEYWARD_DAEMON" in os.environ:
139
+ ...`) to confirm activation.
140
+
141
+ It returns a `keyward.ActivateResult` with three lists so you can see exactly
142
+ what happened:
143
+
144
+ ```python
145
+ result = keyward.activate(strict=False)
146
+ if result.skipped_no_env:
147
+ print(f"token not found in env for: {result.skipped_no_env}")
148
+ print("Did you load your .env file before calling activate()?")
149
+ # result.activated — keys that are now routing through the daemon
150
+ # result.skipped_no_env — keys whose token was not found in any env var
151
+ # result.skipped_no_base_url — keys with no base_url_env configured
152
+ ```
153
+
154
+ If no daemon is running, `activate()` raises `keyward.DaemonNotRunning`. Pass
155
+ `strict=False` to return an empty result instead — useful for code that should
156
+ work both with and without keyward installed.
157
+
158
+ ## What works today (v0.2)
159
+
160
+ | Area | Status |
161
+ |-----------------------|------------------------------------------------------------------------|
162
+ | CLI commands | `init`, `add`, `list`, `rm`, `rotate`, `restart`, `run` all functional |
163
+ | Keychain storage | macOS Keychain, Windows Credential Manager, Linux libsecret via `keyring` |
164
+ | Proxy forwarding | Authorization: Bearer and x-api-key, on both ingress and egress |
165
+ | Streaming | Server-Sent Events forwarded without buffering |
166
+ | Login agent | macOS LaunchAgent install/uninstall/kickstart via `keyward init` |
167
+ | Daemon reuse | `keyward run` reuses a live daemon; else spawns ephemeral |
168
+ | Audit log | Stub only (prints TODO; no log is written yet) |
169
+ | Endpoint enforcement | Each token is bound to one host at `keyward add` time; the daemon ignores the request host and always forwards to the stored endpoint — so a token cannot be used against a different host |
170
+ | Multi-endpoint allowlist + approval flow | Not yet — v0.3 scope; see ARCHITECTURE.md |
171
+ | Linux systemd / Windows scheduled task | Not wired up yet |
172
+ | Websocket proxying | Returns 501; HTTP only for now |
173
+ | Request body streaming| Buffered; fine for LLM chat, not for large uploads |
174
+ | Caller attestation | Trust-anything on localhost; see ARCHITECTURE.md |
175
+
176
+ See [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md) for the full design, threat
177
+ model, and the list of deferred items.
178
+
179
+ ## Verifying the key swap
180
+
181
+ The sharpest test is to point keyward at a request-echoing endpoint and look
182
+ for your raw secret (and the absence of the token) in the response.
183
+
184
+ ```bash
185
+ # pick a distinctive fake secret so you can spot it in the echo
186
+ keyward add echotest --endpoint httpbin.org
187
+ # at the prompt, enter: sk-fake-secret-12345
188
+
189
+ keyward restart # only needed if a LaunchAgent daemon is already running
190
+
191
+ keyward run -- curl -s "$ECHOTEST_BASE_URL/anything" \
192
+ -H "Authorization: Bearer $ECHOTEST_API_KEY"
193
+ ```
194
+
195
+ In the JSON response, under `headers.Authorization`:
196
+ - `Bearer sk-fake-secret-12345` means the swap worked.
197
+ - Anything starting with `Bearer kw_` means the swap did not happen (bug).
198
+
199
+ For the Anthropic-style (x-api-key):
200
+
201
+ ```bash
202
+ keyward add echotestx --endpoint httpbin.org --auth-style x-api-key
203
+ keyward restart
204
+ keyward run -- curl -s "$ECHOTESTX_BASE_URL/anything" \
205
+ -H "x-api-key: $ECHOTESTX_API_KEY"
206
+ ```
207
+
208
+ Check `headers.X-Api-Key` in the response.
209
+
210
+ Clean up with `keyward rm echotest -y && keyward rm echotestx -y`.
211
+
212
+ There is also a Python equivalent that uses `keyward.activate()`:
213
+
214
+ ```bash
215
+ keyward add echotest --endpoint httpbin.org
216
+ keyward restart
217
+ uv run python scripts/verify_swap.py echotest
218
+ ```
219
+
220
+ ## License
221
+
222
+ MIT. See [LICENSE](LICENSE).
@@ -0,0 +1,15 @@
1
+ keyward/__init__.py,sha256=c-Ho30xFe6WSf6otERWOV40Kg1ke_11yrQW0nQk8b2Q,250
2
+ keyward/__main__.py,sha256=rfe4NvMNbr529NYVRRzK7zla8KyTJnGX9yKq-fnhb1Q,66
3
+ keyward/agent.py,sha256=BF7I1-mXAGuaLOZhkUYbE5YXEaIytMIdDiY-qkDcHUs,2508
4
+ keyward/cli.py,sha256=Q1uahOxzFbpGw8Z-Vz-FnIOHfI2FVlj0t662Ior-qAA,8828
5
+ keyward/config.py,sha256=ng0FCwvs5elCI-IG7sVH7zDbyC8u1KYjKTNTo7ddEs8,621
6
+ keyward/daemon.py,sha256=nw7pJ16MBU3uHvPg22Pw1qNZWYFshOudTSwOTBG_IpA,5786
7
+ keyward/discovery.py,sha256=qOrBEQiOPq1R72E3T-gO2I5JrNxzoekehjBYeSVHOQs,1061
8
+ keyward/inject.py,sha256=TUKhHOxz3ZSnZGqs33pc8fEBotfr7zgJNPz5WqfK4-8,2589
9
+ keyward/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
10
+ keyward/store.py,sha256=dY2majUn6SgvFm3O0V32sJwOZCLt-gDYQ5K2UMwLiio,4152
11
+ keyward-0.0.1.dist-info/METADATA,sha256=thmGi-3RsiCcTfzMQuOafHhEt6dZGV7m5KyYjTzD3OI,8958
12
+ keyward-0.0.1.dist-info/WHEEL,sha256=QccIxa26bgl1E6uMy58deGWi-0aeIkkangHcxk2kWfw,87
13
+ keyward-0.0.1.dist-info/entry_points.txt,sha256=JM_NpweffKRbcGU8oQuSuPTnnmoxhyokKTfmKS2mXlw,44
14
+ keyward-0.0.1.dist-info/licenses/LICENSE,sha256=SKD37zOb0Yc0gnPgr3IhnIu3cJMeer2FBrjDc9IwTWI,1063
15
+ keyward-0.0.1.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.29.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ keyward = keyward.cli:app
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 sumedh
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.