driftwatch-llm 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.
@@ -0,0 +1,94 @@
1
+ # ---------------------------------------------------------------------------
2
+ # DriftWatch .gitignore
3
+ # Rule of thumb: nothing that holds a secret, a local path, a build output or
4
+ # runtime data belongs in git. If a pattern here ever stops matching a file you
5
+ # need, fix the pattern; do not force-add the file.
6
+ # ---------------------------------------------------------------------------
7
+
8
+ # --- Secrets and local environment ------------------------------------------
9
+ .env
10
+ .env.*
11
+ !.env.example
12
+ *.pem
13
+ *.key
14
+ *.p12
15
+ *.pfx
16
+ credentials.json
17
+ secrets.yaml
18
+ # Local tooling config: points assistants at a Supabase project, and local
19
+ # permission settings can hold tokens.
20
+ .mcp.json
21
+ .claude/
22
+ # A folder linked with `driftwatch init` (project id only). Stays on the machine.
23
+ .driftwatch/
24
+
25
+ # --- Python ---------------------------------------------------------------
26
+ __pycache__/
27
+ *.py[cod]
28
+ *.pyo
29
+ *.pyd
30
+ .Python
31
+ .venv/
32
+ venv/
33
+ env/
34
+ *.egg
35
+ *.egg-info/
36
+ .eggs/
37
+ dist/
38
+ build/
39
+ wheelhouse/
40
+ *.whl
41
+
42
+ # --- Test, coverage and lint output ----------------------------------------
43
+ .pytest_cache/
44
+ .mypy_cache/
45
+ .ruff_cache/
46
+ .hypothesis/
47
+ .tox/
48
+ .coverage
49
+ .coverage.*
50
+ coverage.xml
51
+ htmlcov/
52
+ .playwright-cli/
53
+ playwright-report/
54
+ test-results/
55
+
56
+ # --- Node / Next.js (frontend) ---------------------------------------------
57
+ node_modules/
58
+ .next/
59
+ out/
60
+ .vercel/
61
+ *.tsbuildinfo
62
+ npm-debug.log*
63
+ yarn-debug.log*
64
+ yarn-error.log*
65
+ .pnpm-debug.log*
66
+
67
+ # --- Runtime data, databases and reports ------------------------------------
68
+ *.db
69
+ *.db-journal
70
+ *.sqlite
71
+ *.sqlite3
72
+ *.sqlite3-wal
73
+ *.sqlite3-shm
74
+ eval_reports/
75
+ data/baseline_embeddings/
76
+ data/canary_probes/
77
+ data/synthetic_degradation/
78
+ logs/
79
+ *.log
80
+
81
+ # --- Local planning and scratch (kept out of the public repo) ---------------
82
+ # Docs/ holds specs, plans and decision logs. It is NOT backed up by git: copy
83
+ # it somewhere safe (a private repo, cloud drive) if you change machines.
84
+ Docs/
85
+ .superpowers/
86
+
87
+ # --- OS and editors ----------------------------------------------------------
88
+ .DS_Store
89
+ Thumbs.db
90
+ desktop.ini
91
+ .vscode/
92
+ .idea/
93
+ *.swp
94
+ *.swo
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Rajvardhan Singh
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,34 @@
1
+ Metadata-Version: 2.5
2
+ Name: driftwatch-llm
3
+ Version: 0.1.0
4
+ Summary: Command-line tool for DriftWatch, a drift monitor for LLM apps.
5
+ License-Expression: MIT
6
+ License-File: LICENSE
7
+ Requires-Python: >=3.10
8
+ Requires-Dist: httpx>=0.27
9
+ Requires-Dist: keyring>=25
10
+ Requires-Dist: platformdirs>=4
11
+ Requires-Dist: tomli>=2; python_version < '3.11'
12
+ Requires-Dist: typer>=0.12
13
+ Description-Content-Type: text/markdown
14
+
15
+ # driftwatch-llm
16
+
17
+ Command-line tool for [DriftWatch](https://drift-watch-rouge.vercel.app), a drift monitor for LLM apps.
18
+
19
+ **Status: early.** This release only links a project folder to your DriftWatch account:
20
+
21
+ ```
22
+ pip install driftwatch-llm
23
+ driftwatch init # sign in through your browser and link this folder to a project
24
+ driftwatch status
25
+ driftwatch logout
26
+ ```
27
+
28
+ It does not monitor anything yet. The background agent and the local dashboard come in later releases.
29
+
30
+ What it stores on your machine: one project API key in your OS keychain (or a private file if no
31
+ keychain exists), a trust record, and `.driftwatch/config.toml` in your project folder, which holds a
32
+ project id and nothing else. Installing the package changes nothing; only the commands above do.
33
+
34
+ MIT licensed.
@@ -0,0 +1,20 @@
1
+ # driftwatch-llm
2
+
3
+ Command-line tool for [DriftWatch](https://drift-watch-rouge.vercel.app), a drift monitor for LLM apps.
4
+
5
+ **Status: early.** This release only links a project folder to your DriftWatch account:
6
+
7
+ ```
8
+ pip install driftwatch-llm
9
+ driftwatch init # sign in through your browser and link this folder to a project
10
+ driftwatch status
11
+ driftwatch logout
12
+ ```
13
+
14
+ It does not monitor anything yet. The background agent and the local dashboard come in later releases.
15
+
16
+ What it stores on your machine: one project API key in your OS keychain (or a private file if no
17
+ keychain exists), a trust record, and `.driftwatch/config.toml` in your project folder, which holds a
18
+ project id and nothing else. Installing the package changes nothing; only the commands above do.
19
+
20
+ MIT licensed.
@@ -0,0 +1,7 @@
1
+ """DriftWatch command-line tool.
2
+
3
+ Importing this package has no side effects: it reads no files, opens no sockets
4
+ and installs no hooks. Everything happens only when a command runs.
5
+ """
6
+
7
+ __version__ = "0.1.0"
@@ -0,0 +1,4 @@
1
+ from driftwatch_llm.cli import app
2
+
3
+ if __name__ == "__main__":
4
+ app()
@@ -0,0 +1,140 @@
1
+ """The CLI's only network client: PostgREST RPC calls to the pinned DriftWatch project.
2
+
3
+ It sends the publishable key and nothing else (no session, no service key). Every error becomes a
4
+ fixed, user-safe sentence: request bodies (device codes, API keys) and raw server text never appear.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import re
10
+ from dataclasses import dataclass
11
+ from typing import Any
12
+
13
+ import httpx
14
+
15
+ from driftwatch_llm import __version__
16
+ from driftwatch_llm.endpoints import Endpoints
17
+ from driftwatch_llm.secrets import KEY_RE
18
+ from driftwatch_llm.validate import clean_text, is_uuid
19
+
20
+ _DEVICE_CODE_RE = re.compile(r"^[0-9a-f]{64}$")
21
+ _USER_CODE_RE = re.compile(r"^[BCDFGHJKLMNPQRSTVWXZ]{8}$")
22
+ _STATUSES = {"authorization_pending", "slow_down", "approved", "access_denied", "expired_token", "key_limit"}
23
+ _UNEXPECTED = "Unexpected response from DriftWatch. Try again, or update driftwatch-llm."
24
+ _UNREACHABLE = "Could not reach DriftWatch. Check your connection and try again."
25
+
26
+
27
+ class ApiError(Exception):
28
+ """A failed call. The message is safe to print."""
29
+
30
+
31
+ @dataclass(frozen=True)
32
+ class DeviceStart:
33
+ device_code: str
34
+ user_code: str
35
+ expires_in: int
36
+ interval: int
37
+
38
+
39
+ @dataclass(frozen=True)
40
+ class PollResult:
41
+ status: str
42
+ api_key: str | None = None
43
+ project_id: str | None = None
44
+ project_name: str | None = None
45
+
46
+ def __repr__(self) -> str: # never let a key reach a log line through repr()
47
+ return f"PollResult(status={self.status!r}, project_id={self.project_id!r})"
48
+
49
+
50
+ def _is_int(value: object, low: int, high: int) -> bool:
51
+ return isinstance(value, int) and not isinstance(value, bool) and low <= value <= high
52
+
53
+
54
+ def _error_for(status: int, body: object) -> ApiError:
55
+ code = body.get("code") if isinstance(body, dict) else None
56
+ message = body.get("message") if isinstance(body, dict) else None
57
+ if code == "PT429" or status == 429:
58
+ return ApiError("Too many sign-in requests. Try again in a minute.")
59
+ if code == "PT422":
60
+ # PT422 texts are written by our own SQL; anything long or odd gets a fixed sentence.
61
+ text = clean_text(message, 120)
62
+ return ApiError(text if text and isinstance(message, str) and len(message) <= 120 else "That request was not accepted.")
63
+ if status in (401, 403):
64
+ return ApiError("Your request was not accepted.")
65
+ return ApiError(f"DriftWatch had a problem (HTTP {status}). Try again later.")
66
+
67
+
68
+ class Api:
69
+ def __init__(
70
+ self,
71
+ endpoints: Endpoints,
72
+ transport: httpx.BaseTransport | None = None,
73
+ timeout: float = 15.0,
74
+ ) -> None:
75
+ self._ep = endpoints
76
+ self._transport = transport
77
+ self._timeout = timeout
78
+
79
+ def _rpc(self, fn: str, body: dict[str, Any], *, want_json: bool = True) -> Any:
80
+ url = f"{self._ep.supabase_url}/rest/v1/rpc/{fn}"
81
+ headers = {
82
+ "apikey": self._ep.publishable_key,
83
+ "content-type": "application/json",
84
+ "user-agent": f"driftwatch-llm/{__version__}",
85
+ }
86
+ try:
87
+ with httpx.Client(transport=self._transport, timeout=self._timeout, follow_redirects=False) as client:
88
+ response = client.post(url, json=body, headers=headers)
89
+ except httpx.HTTPError:
90
+ raise ApiError(_UNREACHABLE) from None
91
+ if response.status_code >= 400:
92
+ try:
93
+ parsed: object = response.json()
94
+ except ValueError:
95
+ parsed = None
96
+ raise _error_for(response.status_code, parsed)
97
+ if not want_json:
98
+ return None
99
+ try:
100
+ return response.json()
101
+ except ValueError:
102
+ raise ApiError(_UNEXPECTED) from None
103
+
104
+ def start_device_login(self, device_name: str, device_os: str) -> DeviceStart:
105
+ data = self._rpc("start_device_login", {"p_device_name": device_name, "p_device_os": device_os})
106
+ if not isinstance(data, dict):
107
+ raise ApiError(_UNEXPECTED)
108
+ device_code, user_code = data.get("device_code"), data.get("user_code")
109
+ expires_in, interval = data.get("expires_in"), data.get("interval")
110
+ if (
111
+ not isinstance(device_code, str) or not _DEVICE_CODE_RE.match(device_code)
112
+ or not isinstance(user_code, str) or not _USER_CODE_RE.match(user_code)
113
+ or not _is_int(expires_in, 1, 3600) or not _is_int(interval, 1, 60)
114
+ ):
115
+ raise ApiError(_UNEXPECTED)
116
+ return DeviceStart(device_code=device_code, user_code=user_code, expires_in=expires_in, interval=interval)
117
+
118
+ def poll_device_login(self, device_code: str) -> PollResult:
119
+ data = self._rpc("poll_device_login", {"p_device_code": device_code})
120
+ if not isinstance(data, dict):
121
+ raise ApiError(_UNEXPECTED)
122
+ status = data.get("status")
123
+ if status not in _STATUSES:
124
+ raise ApiError(_UNEXPECTED)
125
+ if status != "approved":
126
+ if "api_key" in data:
127
+ raise ApiError(_UNEXPECTED) # a key outside "approved" is never accepted
128
+ return PollResult(status=status)
129
+ key, project_id = data.get("api_key"), data.get("project_id")
130
+ if not isinstance(key, str) or not KEY_RE.match(key) or not is_uuid(project_id):
131
+ raise ApiError(_UNEXPECTED)
132
+ return PollResult(
133
+ status="approved",
134
+ api_key=key,
135
+ project_id=project_id,
136
+ project_name=clean_text(data.get("project_name")) or "your project",
137
+ )
138
+
139
+ def revoke_api_key(self, api_key: str) -> None:
140
+ self._rpc("revoke_api_key_by_secret", {"p_api_key": api_key}, want_json=False)
@@ -0,0 +1,331 @@
1
+ """The `driftwatch` command.
2
+
3
+ Nothing runs in the background yet. These commands link a project folder to a DriftWatch project
4
+ (init, login, logout, trust, status) and clean up (uninstall). Services (API client, keychain, trust
5
+ store, clock) are injected through the Typer context so tests need no network and no real keychain.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import platform
11
+ import shutil
12
+ import time
13
+ from dataclasses import dataclass
14
+ from pathlib import Path
15
+ from typing import Any, Callable, Mapping, NoReturn
16
+
17
+ import typer
18
+
19
+ from driftwatch_llm import __version__, paths
20
+ from driftwatch_llm.api import Api, ApiError, PollResult
21
+ from driftwatch_llm.device_flow import LoginDenied, LoginExpired, LoginFailed, run_device_login
22
+ from driftwatch_llm.endpoints import EndpointError, Endpoints, resolve
23
+ from driftwatch_llm.hooks import remove_autostart_hook
24
+ from driftwatch_llm.project import (
25
+ ConfigError,
26
+ ProjectConfig,
27
+ ensure_gitignore,
28
+ find_project_root,
29
+ read_config,
30
+ write_config,
31
+ )
32
+ from driftwatch_llm.secrets import SecretStore
33
+ from driftwatch_llm.trust import TrustStore
34
+ from driftwatch_llm.validate import clean_text
35
+
36
+ app = typer.Typer(
37
+ help="DriftWatch: link a project folder to drift monitoring.",
38
+ no_args_is_help=True,
39
+ add_completion=False,
40
+ )
41
+
42
+
43
+ @dataclass
44
+ class Services:
45
+ endpoints: Endpoints
46
+ api: Any
47
+ secrets: SecretStore
48
+ trust: TrustStore
49
+ home: Path
50
+ cwd: Callable[[], Path] = Path.cwd
51
+ open_url: Callable[[str], object] | None = None
52
+ sleep: Callable[[float], None] = time.sleep
53
+ clock: Callable[[], float] = time.monotonic
54
+ device_name: str | None = None
55
+ device_os: str | None = None
56
+
57
+
58
+ def build_services(dev: bool, env: Mapping[str, str] | None = None) -> Services:
59
+ endpoints = resolve(dev, env)
60
+ home = paths.config_dir()
61
+ return Services(
62
+ endpoints=endpoints,
63
+ api=Api(endpoints),
64
+ secrets=SecretStore(home=home),
65
+ trust=TrustStore(home=home),
66
+ home=home,
67
+ )
68
+
69
+
70
+ def _version(value: bool) -> None:
71
+ if value:
72
+ typer.echo(f"driftwatch-llm {__version__}")
73
+ raise typer.Exit()
74
+
75
+
76
+ @app.callback()
77
+ def main(
78
+ ctx: typer.Context,
79
+ version: bool = typer.Option(False, "--version", callback=_version, is_eager=True, help="Show the version and exit."),
80
+ dev: bool = typer.Option(False, "--dev", hidden=True, help="Use DRIFTWATCH_DEV_* endpoints (development only)."),
81
+ ) -> None:
82
+ """DriftWatch: link a project folder to drift monitoring."""
83
+ if ctx.obj is None:
84
+ try:
85
+ ctx.obj = build_services(dev)
86
+ except EndpointError as exc:
87
+ typer.echo(f"Error: {exc}.", err=True)
88
+ raise typer.Exit(2) from None
89
+
90
+
91
+ def _svc(ctx: typer.Context) -> Services:
92
+ return ctx.obj # type: ignore[no-any-return]
93
+
94
+
95
+ def _fail(message: str, code: int = 1) -> NoReturn:
96
+ typer.echo(message, err=True)
97
+ raise typer.Exit(code)
98
+
99
+
100
+ def _device_name(s: Services) -> str:
101
+ return s.device_name or clean_text(platform.node()) or "unknown device"
102
+
103
+
104
+ def _device_os(s: Services) -> str:
105
+ return s.device_os or clean_text(f"{platform.system()} {platform.release()}") or "unknown OS"
106
+
107
+
108
+ def _device_login(s: Services, open_browser: bool) -> PollResult:
109
+ try:
110
+ return run_device_login(
111
+ s.api,
112
+ device_name=_device_name(s),
113
+ device_os=_device_os(s),
114
+ web_origin=s.endpoints.web_origin,
115
+ open_browser=open_browser,
116
+ echo=typer.echo,
117
+ open_url=s.open_url,
118
+ sleep=s.sleep,
119
+ clock=s.clock,
120
+ )
121
+ except KeyboardInterrupt:
122
+ typer.echo("Cancelled.")
123
+ raise typer.Exit(130) from None
124
+ except (LoginDenied, LoginExpired, LoginFailed, ApiError) as exc:
125
+ _fail(f"Error: {exc}")
126
+
127
+
128
+ def _revoke_quietly(s: Services, key: str) -> None:
129
+ try:
130
+ s.api.revoke_api_key(key)
131
+ except Exception: # noqa: BLE001 - best effort; the key is also visible on the keys page
132
+ pass
133
+
134
+
135
+ def _read_config(root: Path) -> ProjectConfig:
136
+ try:
137
+ cfg = read_config(root)
138
+ except ConfigError as exc:
139
+ _fail(f"Error: {exc}.")
140
+ if cfg is None:
141
+ _fail("Error: no .driftwatch/config.toml here.")
142
+ return cfg
143
+
144
+
145
+ def _project(s: Services) -> tuple[Path, ProjectConfig]:
146
+ root = find_project_root(s.cwd())
147
+ if root is None:
148
+ _fail("No DriftWatch project here. Run `driftwatch init` first.")
149
+ return root, _read_config(root)
150
+
151
+
152
+ def _store_key(s: Services, project_id: str, key: str) -> str:
153
+ """Save the new key, then revoke the key it replaces (never one that came from the environment)."""
154
+ previous = s.secrets.load(project_id)
155
+ where = s.secrets.save(project_id, key)
156
+ if previous and previous[1] != "env" and previous[0] != key:
157
+ _revoke_quietly(s, previous[0])
158
+ return where
159
+
160
+
161
+ @app.command()
162
+ def init(
163
+ ctx: typer.Context,
164
+ force: bool = typer.Option(False, "--force", help="Link this folder again, even if it already has a config."),
165
+ no_browser: bool = typer.Option(False, "--no-browser", help="Print the link instead of opening a browser."),
166
+ ) -> None:
167
+ """Sign in through your browser and link this folder to a DriftWatch project."""
168
+ s = _svc(ctx)
169
+ root = s.cwd()
170
+ try:
171
+ cfg = read_config(root)
172
+ except ConfigError as exc:
173
+ _fail(f"Error: {exc}.")
174
+ if cfg is not None and not force:
175
+ if s.trust.is_trusted(root, cfg.project_id):
176
+ typer.echo(f"Already linked to project {cfg.project_id}. Use --force to link again.")
177
+ return
178
+ _fail(
179
+ f"This folder already has a .driftwatch/config.toml for project {cfg.project_id}, "
180
+ "but you have not trusted it.\n"
181
+ " To use that project, run: driftwatch trust\n"
182
+ " To link this folder to a project of your own, run: driftwatch init --force"
183
+ )
184
+
185
+ typer.echo(
186
+ "This will:\n"
187
+ " - sign you in through your browser\n"
188
+ " - write .driftwatch/config.toml (a project id, nothing else)\n"
189
+ " - add .driftwatch/ to .gitignore\n"
190
+ " - store a project key in your OS keychain (or a private file if there is none)\n"
191
+ " - mark this folder as trusted\n"
192
+ "Nothing runs in the background yet."
193
+ )
194
+ result = _device_login(s, open_browser=not no_browser)
195
+ assert result.api_key and result.project_id # guaranteed by the client for "approved"
196
+
197
+ try:
198
+ write_config(root, result.project_id)
199
+ ensure_gitignore(root)
200
+ where = _store_key(s, result.project_id, result.api_key)
201
+ s.trust.trust(root, result.project_id)
202
+ except (OSError, ConfigError):
203
+ _revoke_quietly(s, result.api_key)
204
+ s.secrets.delete(result.project_id)
205
+ _fail("Error: could not save the link on this machine. The new key was revoked. Check the folder permissions and try again.")
206
+
207
+ place = "your OS keychain" if where == "keychain" else "a private file in your user config folder"
208
+ typer.echo(f'Linked to project "{result.project_name}" ({result.project_id}).')
209
+ typer.echo(f"Key stored in {place}.")
210
+
211
+
212
+ @app.command()
213
+ def login(
214
+ ctx: typer.Context,
215
+ no_browser: bool = typer.Option(False, "--no-browser", help="Print the link instead of opening a browser."),
216
+ ) -> None:
217
+ """Get a fresh key for this folder's project (for example on a new machine)."""
218
+ s = _svc(ctx)
219
+ root, cfg = _project(s)
220
+ if not s.trust.is_trusted(root, cfg.project_id):
221
+ _fail("This folder is not trusted yet. Run `driftwatch trust` first.")
222
+ result = _device_login(s, open_browser=not no_browser)
223
+ assert result.api_key and result.project_id
224
+ if result.project_id != cfg.project_id:
225
+ _revoke_quietly(s, result.api_key)
226
+ _fail(
227
+ "You approved a different project than the one this folder is linked to. Nothing changed.\n"
228
+ "To link this folder to that project instead, run: driftwatch init --force"
229
+ )
230
+ _store_key(s, cfg.project_id, result.api_key)
231
+ typer.echo("Signed in. A new key is stored on this machine.")
232
+
233
+
234
+ @app.command()
235
+ def logout(ctx: typer.Context) -> None:
236
+ """Revoke this machine's key for the project and remove it."""
237
+ s = _svc(ctx)
238
+ _, cfg = _project(s)
239
+ found = s.secrets.load(cfg.project_id)
240
+ if found is None:
241
+ typer.echo("No key is stored for this project on this machine.")
242
+ return
243
+ key, source = found
244
+ if source == "env":
245
+ typer.echo("The key comes from DRIFTWATCH_API_KEY, which logout does not change. Unset the variable yourself.")
246
+ return
247
+ try:
248
+ s.api.revoke_api_key(key)
249
+ typer.echo("Key revoked on the server.")
250
+ except ApiError as exc:
251
+ typer.echo(f"Could not revoke the key on the server ({exc}). Revoke it on the keys page of the website.")
252
+ s.secrets.delete(cfg.project_id)
253
+ typer.echo("Removed the key from this machine.")
254
+
255
+
256
+ @app.command()
257
+ def trust(
258
+ ctx: typer.Context,
259
+ yes: bool = typer.Option(False, "--yes", "-y", help="Do not ask for confirmation."),
260
+ ) -> None:
261
+ """Trust this folder's .driftwatch config (needed for a folder you cloned)."""
262
+ s = _svc(ctx)
263
+ root = find_project_root(s.cwd())
264
+ if root is None:
265
+ _fail("Error: no .driftwatch/config.toml found in this folder or above.")
266
+ cfg = _read_config(root)
267
+ if s.trust.is_trusted(root, cfg.project_id):
268
+ typer.echo("Already trusted.")
269
+ return
270
+ typer.echo(f"Folder: {root}\nProject: {cfg.project_id}")
271
+ if cfg.ignored_keys:
272
+ typer.echo(f"Ignored settings in that file (DriftWatch never reads them): {', '.join(cfg.ignored_keys)}")
273
+ if not yes and not typer.confirm("Trust this folder for this project?", default=False):
274
+ typer.echo("Not trusted.")
275
+ raise typer.Exit(1)
276
+ s.trust.trust(root, cfg.project_id)
277
+ typer.echo("Trusted. Run `driftwatch login` to get a key for this machine.")
278
+
279
+
280
+ @app.command()
281
+ def status(ctx: typer.Context) -> None:
282
+ """Show what this folder is linked to. Never prints a key."""
283
+ s = _svc(ctx)
284
+ typer.echo(f"server: {s.endpoints.web_origin}")
285
+ root = find_project_root(s.cwd())
286
+ if root is None:
287
+ typer.echo("No DriftWatch project found from here. Run `driftwatch init` to link this folder.")
288
+ return
289
+ cfg = _read_config(root)
290
+ trusted = s.trust.is_trusted(root, cfg.project_id)
291
+ typer.echo(f"project: {cfg.project_id}")
292
+ typer.echo(f"folder: {root}")
293
+ typer.echo(f"trusted: {'yes' if trusted else 'no'}")
294
+ typer.echo(f"key: {s.secrets.source(cfg.project_id)}")
295
+ if cfg.ignored_keys:
296
+ typer.echo(f"ignored settings in the config: {', '.join(cfg.ignored_keys)}")
297
+ if not trusted:
298
+ typer.echo("This folder is not trusted. Run `driftwatch trust` if you want to use it.")
299
+
300
+
301
+ @app.command()
302
+ def uninstall(
303
+ ctx: typer.Context,
304
+ yes: bool = typer.Option(False, "--yes", "-y", help="Do not ask for confirmation."),
305
+ project: bool = typer.Option(False, "--project", help="Also remove this folder's .driftwatch directory."),
306
+ ) -> None:
307
+ """Remove everything this tool stored on this machine."""
308
+ s = _svc(ctx)
309
+ typer.echo("This will remove, from this machine only:")
310
+ typer.echo(" - every stored project key (keychain and private file)")
311
+ typer.echo(" - the list of trusted folders")
312
+ typer.echo(" - the autostart hook, if one was installed")
313
+ if project:
314
+ typer.echo(" - this folder's .driftwatch directory")
315
+ if not yes and not typer.confirm("Continue?", default=False):
316
+ typer.echo("Cancelled.")
317
+ raise typer.Exit(1)
318
+ s.secrets.delete_all()
319
+ s.trust.clear()
320
+ remove_autostart_hook()
321
+ try:
322
+ s.home.rmdir() # only succeeds when nothing else lives there
323
+ except OSError:
324
+ pass
325
+ if project:
326
+ root = find_project_root(s.cwd())
327
+ if root is not None:
328
+ shutil.rmtree(root / ".driftwatch", ignore_errors=True)
329
+ typer.echo("Removed.")
330
+ typer.echo("To finish, run: pip uninstall driftwatch-llm")
331
+ typer.echo("Keys you created stay valid on the server until you revoke them on the keys page of the website.")
@@ -0,0 +1,89 @@
1
+ """Browser sign-in for the CLI (RFC 8628 device authorization).
2
+
3
+ The CLI shows a short code and a link, the user approves on the website, and the CLI polls until it
4
+ receives a project key. Time, sleeping, printing and the browser are injected so tests need no
5
+ network and no real waiting.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import time
11
+ import webbrowser
12
+ from typing import Callable
13
+ from urllib.parse import quote
14
+
15
+ from driftwatch_llm.api import Api, ApiError, PollResult
16
+
17
+ _MAX_CONSECUTIVE_ERRORS = 3
18
+
19
+
20
+ class LoginDenied(Exception):
21
+ """The user chose Deny on the website."""
22
+
23
+
24
+ class LoginExpired(Exception):
25
+ """The code expired before anyone approved it."""
26
+
27
+
28
+ class LoginFailed(Exception):
29
+ """Sign-in could not finish. The message is safe to print."""
30
+
31
+
32
+ def run_device_login(
33
+ api: Api,
34
+ *,
35
+ device_name: str,
36
+ device_os: str,
37
+ web_origin: str,
38
+ open_browser: bool = True,
39
+ echo: Callable[[str], None] = print,
40
+ open_url: Callable[[str], object] | None = None,
41
+ sleep: Callable[[float], None] = time.sleep,
42
+ clock: Callable[[], float] = time.monotonic,
43
+ ) -> PollResult:
44
+ start = api.start_device_login(device_name, device_os)
45
+ dashed = f"{start.user_code[:4]}-{start.user_code[4:]}"
46
+ url = f"{web_origin}/auth/device?code={quote(dashed)}"
47
+
48
+ echo(f"Your code: {dashed}")
49
+ echo("Open this link in your browser to approve it:")
50
+ echo(f" {url}")
51
+ echo("Only approve this if you started it yourself.")
52
+ if open_browser:
53
+ opener = open_url or webbrowser.open
54
+ try:
55
+ opened = bool(opener(url))
56
+ except Exception: # noqa: BLE001 - a missing browser must never stop sign-in
57
+ opened = False
58
+ if not opened:
59
+ echo("Could not open a browser here. Open the link above yourself.")
60
+ echo("Waiting for approval (Ctrl+C to cancel)...")
61
+
62
+ deadline = clock() + start.expires_in
63
+ interval = float(start.interval)
64
+ errors = 0
65
+ while True:
66
+ sleep(interval)
67
+ try:
68
+ result = api.poll_device_login(start.device_code)
69
+ errors = 0
70
+ except ApiError as exc:
71
+ errors += 1
72
+ if errors > _MAX_CONSECUTIVE_ERRORS:
73
+ raise LoginFailed(str(exc)) from None
74
+ if clock() >= deadline:
75
+ raise LoginExpired("The sign-in code expired. Run the command again.") from None
76
+ continue
77
+
78
+ if result.status == "approved":
79
+ return result
80
+ if result.status == "access_denied":
81
+ raise LoginDenied("The sign-in was denied.")
82
+ if result.status == "expired_token":
83
+ raise LoginExpired("The sign-in code expired. Run the command again.")
84
+ if result.status == "key_limit":
85
+ raise LoginFailed("This project already has 10 active keys. Revoke one on the website and try again.")
86
+ if result.status == "slow_down":
87
+ interval += 5
88
+ if clock() >= deadline:
89
+ raise LoginExpired("The sign-in code expired. Run the command again.")
@@ -0,0 +1,61 @@
1
+ """Where the CLI talks to. Pinned in the package so a project's own files can never redirect it.
2
+
3
+ Only the global `--dev` flag lets `DRIFTWATCH_DEV_*` environment variables override these values,
4
+ and even then plain http is accepted only for loopback hosts.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import os
10
+ from dataclasses import dataclass
11
+ from typing import Mapping
12
+ from urllib.parse import urlsplit
13
+
14
+
15
+ class EndpointError(ValueError):
16
+ """A dev endpoint override was refused. The message names the setting, never its value."""
17
+
18
+
19
+ @dataclass(frozen=True)
20
+ class Endpoints:
21
+ supabase_url: str
22
+ publishable_key: str # public by design: it only identifies the project to the API gateway
23
+ web_origin: str
24
+
25
+
26
+ PINNED = Endpoints(
27
+ supabase_url="https://zaodlvgqowhdqobvvhpx.supabase.co",
28
+ publishable_key="sb_publishable_mMch89F43lrLgpGHPKbhgg_Z43-lzes",
29
+ web_origin="https://drift-watch-rouge.vercel.app",
30
+ )
31
+
32
+ _LOOPBACK = {"localhost", "127.0.0.1", "::1"}
33
+
34
+
35
+ def _checked_url(setting: str, value: str) -> str:
36
+ parts = urlsplit(value)
37
+ if parts.username or parts.password:
38
+ raise EndpointError(f"{setting} must not contain credentials")
39
+ if parts.scheme == "https" and parts.hostname:
40
+ return value.rstrip("/")
41
+ if parts.scheme == "http" and parts.hostname in _LOOPBACK:
42
+ return value.rstrip("/")
43
+ raise EndpointError(f"{setting} must be an https URL, or http on localhost")
44
+
45
+
46
+ def resolve(dev: bool, env: Mapping[str, str] | None = None) -> Endpoints:
47
+ if not dev:
48
+ return PINNED
49
+ env = os.environ if env is None else env
50
+
51
+ def pick(name: str, fallback: str, *, url: bool = True) -> str:
52
+ raw = (env.get(name) or "").strip()
53
+ if not raw:
54
+ return fallback
55
+ return _checked_url(name, raw) if url else raw
56
+
57
+ return Endpoints(
58
+ supabase_url=pick("DRIFTWATCH_DEV_SUPABASE_URL", PINNED.supabase_url),
59
+ publishable_key=pick("DRIFTWATCH_DEV_SUPABASE_KEY", PINNED.publishable_key, url=False),
60
+ web_origin=pick("DRIFTWATCH_DEV_WEB_ORIGIN", PINNED.web_origin),
61
+ )
@@ -0,0 +1,37 @@
1
+ """Small file helpers: private (owner-only) atomic writes."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ import tempfile
7
+ from pathlib import Path
8
+
9
+ _POSIX = os.name == "posix"
10
+
11
+
12
+ def write_private(path: Path, text: str) -> None:
13
+ """Write `text` to `path` atomically, readable only by the current user (POSIX: 0600, dir 0700).
14
+
15
+ On Windows the file inherits the user profile ACL, which is owner-only by default.
16
+ """
17
+ path = Path(path)
18
+ parent = path.parent
19
+ created = not parent.exists()
20
+ parent.mkdir(parents=True, exist_ok=True)
21
+ if created and _POSIX:
22
+ os.chmod(parent, 0o700)
23
+ fd, tmp = tempfile.mkstemp(dir=parent, prefix=".tmp-")
24
+ try:
25
+ with os.fdopen(fd, "w", encoding="utf-8", newline="\n") as f:
26
+ f.write(text)
27
+ f.flush()
28
+ os.fsync(f.fileno())
29
+ if _POSIX:
30
+ os.chmod(tmp, 0o600)
31
+ os.replace(tmp, path)
32
+ except BaseException:
33
+ try:
34
+ os.unlink(tmp)
35
+ except OSError:
36
+ pass
37
+ raise
@@ -0,0 +1,37 @@
1
+ """The optional autostart hook (installed by a later release; `uninstall` already knows how to remove it).
2
+
3
+ Only an explicit `driftwatch init` may ever install this file, and `driftwatch uninstall` removes it.
4
+ The wheel itself ships no .pth file.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import site
10
+ from pathlib import Path
11
+ from typing import Iterable
12
+
13
+ AUTOSTART_PTH = "driftwatch_llm_autostart.pth"
14
+
15
+
16
+ def _site_dirs() -> list[Path]:
17
+ dirs: list[str] = []
18
+ for getter in (site.getsitepackages, lambda: [site.getusersitepackages()]):
19
+ try:
20
+ dirs.extend(getter())
21
+ except Exception: # noqa: BLE001 - some embedded or virtual environments lack these
22
+ pass
23
+ return [Path(d) for d in dirs]
24
+
25
+
26
+ def remove_autostart_hook(site_dirs: Iterable[Path] | None = None) -> list[Path]:
27
+ """Delete our own .pth file from the given (or the current environment's) site directories."""
28
+ removed: list[Path] = []
29
+ for directory in site_dirs if site_dirs is not None else _site_dirs():
30
+ target = Path(directory) / AUTOSTART_PTH
31
+ try:
32
+ if target.is_file():
33
+ target.unlink()
34
+ removed.append(target)
35
+ except OSError:
36
+ pass
37
+ return removed
@@ -0,0 +1,16 @@
1
+ """Where the CLI keeps its per-user files (trust record, fallback credentials)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ from pathlib import Path
7
+
8
+ import platformdirs
9
+
10
+
11
+ def config_dir() -> Path:
12
+ """`DRIFTWATCH_HOME` wins (tests, containers); otherwise the platform user config directory."""
13
+ override = os.environ.get("DRIFTWATCH_HOME")
14
+ if override:
15
+ return Path(override)
16
+ return Path(platformdirs.user_config_dir("driftwatch-llm", appauthor=False))
@@ -0,0 +1,106 @@
1
+ """The per-project folder: `.driftwatch/config.toml` and the `.gitignore` entry.
2
+
3
+ The config file holds a project id and a mode, nothing else. It never holds an endpoint or a
4
+ secret (hardening item C1): a cloned repository can ship any config it likes, so this module
5
+ reads two keys, ignores the rest, and nothing else in the package reads the file.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import re
11
+ from dataclasses import dataclass
12
+ from pathlib import Path
13
+
14
+ try:
15
+ import tomllib
16
+ except ModuleNotFoundError: # Python 3.10
17
+ import tomli as tomllib # type: ignore[no-redef]
18
+
19
+ from driftwatch_llm.validate import is_uuid
20
+
21
+ CONFIG_DIR_NAME = ".driftwatch"
22
+ CONFIG_FILE_NAME = "config.toml"
23
+ MAX_CONFIG_BYTES = 8192
24
+ _KNOWN = {"project_id", "mode"}
25
+ _SAFE_NAME = re.compile(r"^[A-Za-z0-9_.-]{1,40}$")
26
+ _GITIGNORE_ENTRIES = {".driftwatch", ".driftwatch/", "/.driftwatch", "/.driftwatch/"}
27
+
28
+
29
+ class ConfigError(ValueError):
30
+ """The project config is missing something or malformed. Messages never quote the file."""
31
+
32
+
33
+ @dataclass(frozen=True)
34
+ class ProjectConfig:
35
+ project_id: str
36
+ mode: str
37
+ ignored_keys: tuple[str, ...] = ()
38
+
39
+
40
+ def config_path(root: Path) -> Path:
41
+ return Path(root) / CONFIG_DIR_NAME / CONFIG_FILE_NAME
42
+
43
+
44
+ def write_config(root: Path, project_id: str) -> Path:
45
+ if not is_uuid(project_id):
46
+ raise ConfigError("project id must be a UUID")
47
+ path = config_path(root)
48
+ path.parent.mkdir(parents=True, exist_ok=True)
49
+ path.write_text(
50
+ "# Written by `driftwatch init`.\n"
51
+ "# This folder holds a project id and nothing else: no endpoint and no secret belongs here.\n"
52
+ f'project_id = "{project_id}"\n'
53
+ 'mode = "cloud"\n',
54
+ encoding="utf-8",
55
+ newline="\n",
56
+ )
57
+ return path
58
+
59
+
60
+ def read_config(root: Path) -> ProjectConfig | None:
61
+ path = config_path(root)
62
+ try:
63
+ raw = path.read_bytes()
64
+ except FileNotFoundError:
65
+ return None
66
+ except OSError:
67
+ raise ConfigError("could not read the project config") from None
68
+ if len(raw) > MAX_CONFIG_BYTES:
69
+ raise ConfigError("the project config is too large")
70
+ try:
71
+ data = tomllib.loads(raw.decode("utf-8"))
72
+ except (UnicodeDecodeError, tomllib.TOMLDecodeError):
73
+ raise ConfigError("the project config is not valid TOML") from None
74
+ project_id = data.get("project_id")
75
+ if not is_uuid(project_id):
76
+ raise ConfigError("the project config needs a project_id that is a UUID")
77
+ if data.get("mode") != "cloud":
78
+ raise ConfigError('the project config needs mode = "cloud"')
79
+ ignored = sorted(
80
+ {(k if _SAFE_NAME.match(k) else "<invalid name>") for k in data if k not in _KNOWN}
81
+ )
82
+ return ProjectConfig(project_id=str(project_id), mode="cloud", ignored_keys=tuple(ignored))
83
+
84
+
85
+ def find_project_root(start: Path) -> Path | None:
86
+ """Walk up from `start` to the nearest folder that has `.driftwatch/config.toml`."""
87
+ here = Path(start).resolve()
88
+ for folder in (here, *here.parents):
89
+ if config_path(folder).is_file():
90
+ return folder
91
+ return None
92
+
93
+
94
+ def ensure_gitignore(root: Path) -> bool:
95
+ """Make sure `.driftwatch/` is git-ignored. Returns True if the file changed."""
96
+ path = Path(root) / ".gitignore"
97
+ try:
98
+ existing = path.read_bytes().decode("utf-8", errors="replace")
99
+ except FileNotFoundError:
100
+ existing = ""
101
+ if any(line.strip() in _GITIGNORE_ENTRIES for line in existing.splitlines()):
102
+ return False
103
+ eol = "\r\n" if "\r\n" in existing else "\n"
104
+ prefix = "" if not existing or existing.endswith(("\n", "\r")) else eol
105
+ path.write_bytes((existing + prefix + ".driftwatch/" + eol).encode("utf-8"))
106
+ return True
@@ -0,0 +1,172 @@
1
+ """Where the project API key lives on this machine.
2
+
3
+ Lookup order: OS keychain, then the `DRIFTWATCH_API_KEY` environment variable, then a private
4
+ file (`credentials.json`, mode 0600) in the user config dir. Saving tries the keychain first and
5
+ falls back to the file when no keychain exists (Docker, CI, WSL, headless Linux).
6
+
7
+ Nothing here ever prints, logs or raises with a key in its message.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import json
13
+ import os
14
+ import re
15
+ from pathlib import Path
16
+ from typing import Any, Mapping
17
+
18
+ from driftwatch_llm import paths
19
+ from driftwatch_llm.fileutil import write_private
20
+ from driftwatch_llm.validate import is_uuid
21
+
22
+ SERVICE = "driftwatch-llm"
23
+ ENV_VAR = "DRIFTWATCH_API_KEY"
24
+ KEY_RE = re.compile(r"^dw_[0-9a-f]{10}_[0-9a-f]{64}$")
25
+
26
+
27
+ class SecretError(ValueError):
28
+ """Invalid input to the secret store. Messages never contain the value."""
29
+
30
+
31
+ def _default_backend() -> Any | None:
32
+ try:
33
+ import keyring
34
+ from keyring.backends import fail
35
+
36
+ kr = keyring.get_keyring()
37
+ if isinstance(kr, fail.Keyring) or getattr(kr, "priority", 1) <= 0:
38
+ return None
39
+ return keyring
40
+ except Exception: # noqa: BLE001 - any keychain problem means "no keychain"
41
+ return None
42
+
43
+
44
+ class SecretStore:
45
+ def __init__(
46
+ self,
47
+ home: Path | None = None,
48
+ backend: Any | None = None,
49
+ env: Mapping[str, str] | None = None,
50
+ ) -> None:
51
+ self._home = Path(home) if home is not None else paths.config_dir()
52
+ self._injected = backend is not None
53
+ self._backend = backend
54
+ self._env = os.environ if env is None else env
55
+
56
+ def __repr__(self) -> str:
57
+ return f"SecretStore(home={str(self._home)!r})"
58
+
59
+ # -- file -----------------------------------------------------------------
60
+ @property
61
+ def _file(self) -> Path:
62
+ return self._home / "credentials.json"
63
+
64
+ def _read(self) -> dict[str, Any]:
65
+ empty: dict[str, Any] = {"version": 1, "keys": {}, "keychain_projects": []}
66
+ try:
67
+ data = json.loads(self._file.read_text(encoding="utf-8"))
68
+ except (OSError, ValueError):
69
+ return empty
70
+ if not isinstance(data, dict):
71
+ return empty
72
+ keys = data.get("keys")
73
+ index = data.get("keychain_projects")
74
+ return {
75
+ "version": 1,
76
+ "keys": (
77
+ {k: v for k, v in keys.items() if isinstance(k, str) and isinstance(v, str)}
78
+ if isinstance(keys, dict)
79
+ else {}
80
+ ),
81
+ "keychain_projects": [p for p in index if isinstance(p, str)] if isinstance(index, list) else [],
82
+ }
83
+
84
+ def _write(self, data: dict[str, Any]) -> None:
85
+ if not data["keys"] and not data["keychain_projects"]:
86
+ try:
87
+ self._file.unlink()
88
+ except OSError:
89
+ pass
90
+ return
91
+ write_private(self._file, json.dumps(data, indent=2) + "\n")
92
+
93
+ # -- keychain -------------------------------------------------------------
94
+ def _kr(self) -> Any | None:
95
+ if self._injected:
96
+ return self._backend
97
+ if self._backend is None:
98
+ self._backend = _default_backend()
99
+ return self._backend
100
+
101
+ # -- public API -----------------------------------------------------------
102
+ def save(self, project_id: str, key: str) -> str:
103
+ """Store `key` for the project. Returns where it went: "keychain" or "file"."""
104
+ if not is_uuid(project_id):
105
+ raise SecretError("invalid project id")
106
+ if not isinstance(key, str) or not KEY_RE.match(key):
107
+ raise SecretError("invalid API key format")
108
+ data = self._read()
109
+ kr = self._kr()
110
+ if kr is not None:
111
+ try:
112
+ kr.set_password(SERVICE, project_id, key)
113
+ data["keys"].pop(project_id, None) # drop a stale file copy
114
+ if project_id not in data["keychain_projects"]:
115
+ data["keychain_projects"].append(project_id)
116
+ self._write(data)
117
+ return "keychain"
118
+ except Exception: # noqa: BLE001 - never surface keychain errors: they can echo the value
119
+ pass
120
+ data["keys"][project_id] = key
121
+ data["keychain_projects"] = [p for p in data["keychain_projects"] if p != project_id]
122
+ self._write(data)
123
+ return "file"
124
+
125
+ def load(self, project_id: str) -> tuple[str, str] | None:
126
+ """Return (key, source) with source in {"keychain", "env", "file"}, or None."""
127
+ if not is_uuid(project_id):
128
+ return None
129
+ kr = self._kr()
130
+ if kr is not None:
131
+ try:
132
+ value = kr.get_password(SERVICE, project_id)
133
+ if isinstance(value, str) and KEY_RE.match(value):
134
+ return value, "keychain"
135
+ except Exception: # noqa: BLE001
136
+ pass
137
+ env_key = (self._env.get(ENV_VAR) or "").strip()
138
+ if KEY_RE.match(env_key):
139
+ return env_key, "env"
140
+ value = self._read()["keys"].get(project_id)
141
+ if isinstance(value, str) and KEY_RE.match(value):
142
+ return value, "file"
143
+ return None
144
+
145
+ def source(self, project_id: str) -> str:
146
+ found = self.load(project_id)
147
+ return found[1] if found else "none"
148
+
149
+ def delete(self, project_id: str) -> None:
150
+ """Remove this machine's stored key for the project (never touches the environment)."""
151
+ if not is_uuid(project_id):
152
+ return
153
+ kr = self._kr()
154
+ if kr is not None:
155
+ try:
156
+ kr.delete_password(SERVICE, project_id)
157
+ except Exception: # noqa: BLE001
158
+ pass
159
+ data = self._read()
160
+ data["keys"].pop(project_id, None)
161
+ data["keychain_projects"] = [p for p in data["keychain_projects"] if p != project_id]
162
+ self._write(data)
163
+
164
+ def delete_all(self) -> None:
165
+ """Remove every key this machine stored, for every project."""
166
+ data = self._read()
167
+ for project_id in set(data["keychain_projects"]) | set(data["keys"]):
168
+ self.delete(project_id)
169
+ try:
170
+ self._file.unlink()
171
+ except OSError:
172
+ pass
@@ -0,0 +1,69 @@
1
+ """Trust-on-first-use for project folders (hardening item C1).
2
+
3
+ A folder is trusted only when the user ran `driftwatch init` or `driftwatch trust` there. The record
4
+ is keyed by a hash of the folder's real path AND holds the project id, so a cloned repository that
5
+ ships its own `.driftwatch/config.toml` stays inactive until the user says otherwise, and a trusted
6
+ repository whose config is later changed to another project is untrusted again.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import hashlib
12
+ import json
13
+ import os
14
+ from pathlib import Path
15
+ from typing import Any
16
+
17
+ from driftwatch_llm import paths
18
+ from driftwatch_llm.fileutil import write_private
19
+ from driftwatch_llm.validate import is_uuid
20
+
21
+
22
+ class TrustStore:
23
+ def __init__(self, home: Path | None = None) -> None:
24
+ self._home = Path(home) if home is not None else paths.config_dir()
25
+
26
+ @property
27
+ def _file(self) -> Path:
28
+ return self._home / "trust.json"
29
+
30
+ @staticmethod
31
+ def _key(root: Path) -> str:
32
+ normal = os.path.normcase(str(Path(root).resolve()))
33
+ return hashlib.sha256(normal.encode("utf-8")).hexdigest()
34
+
35
+ def _read(self) -> dict[str, str]:
36
+ try:
37
+ data: Any = json.loads(self._file.read_text(encoding="utf-8"))
38
+ except (OSError, ValueError):
39
+ return {}
40
+ folders = data.get("folders") if isinstance(data, dict) else None
41
+ if not isinstance(folders, dict):
42
+ return {}
43
+ return {k: v for k, v in folders.items() if isinstance(k, str) and isinstance(v, str)}
44
+
45
+ def _write(self, folders: dict[str, str]) -> None:
46
+ write_private(self._file, json.dumps({"version": 1, "folders": folders}, indent=2) + "\n")
47
+
48
+ def is_trusted(self, root: Path, project_id: str) -> bool:
49
+ if not is_uuid(project_id):
50
+ return False
51
+ return self._read().get(self._key(root)) == project_id
52
+
53
+ def trust(self, root: Path, project_id: str) -> None:
54
+ if not is_uuid(project_id):
55
+ raise ValueError("invalid project id")
56
+ folders = self._read()
57
+ folders[self._key(root)] = project_id
58
+ self._write(folders)
59
+
60
+ def untrust(self, root: Path) -> None:
61
+ folders = self._read()
62
+ if folders.pop(self._key(root), None) is not None:
63
+ self._write(folders)
64
+
65
+ def clear(self) -> None:
66
+ try:
67
+ self._file.unlink()
68
+ except OSError:
69
+ pass
@@ -0,0 +1,20 @@
1
+ """Tiny shared validators."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+
7
+ _UUID_RE = re.compile(r"^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$", re.IGNORECASE)
8
+ _CONTROL_RE = re.compile(r"[\x00-\x1f\x7f-\x9f]+")
9
+
10
+
11
+ def is_uuid(value: object) -> bool:
12
+ return isinstance(value, str) and _UUID_RE.match(value) is not None
13
+
14
+
15
+ def clean_text(value: object, limit: int = 64) -> str:
16
+ """Make server- or device-supplied text safe to print: no control characters (so no terminal
17
+ escape sequences), single spaces, at most `limit` characters."""
18
+ if not isinstance(value, str):
19
+ return ""
20
+ return " ".join(_CONTROL_RE.sub(" ", value).split())[:limit]
@@ -0,0 +1,31 @@
1
+ [build-system]
2
+ requires = ["hatchling>=1.27"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "driftwatch-llm"
7
+ dynamic = ["version"]
8
+ description = "Command-line tool for DriftWatch, a drift monitor for LLM apps."
9
+ readme = "driftwatch_llm/README.md"
10
+ requires-python = ">=3.10"
11
+ license = "MIT"
12
+ license-files = ["LICENSE"]
13
+ dependencies = [
14
+ "typer>=0.12",
15
+ "httpx>=0.27",
16
+ "keyring>=25",
17
+ "platformdirs>=4",
18
+ 'tomli>=2; python_version < "3.11"',
19
+ ]
20
+
21
+ [project.scripts]
22
+ driftwatch = "driftwatch_llm.cli:app"
23
+
24
+ [tool.hatch.version]
25
+ path = "driftwatch_llm/__init__.py"
26
+
27
+ [tool.hatch.build.targets.wheel]
28
+ packages = ["driftwatch_llm"]
29
+
30
+ [tool.hatch.build.targets.sdist]
31
+ include = ["driftwatch_llm", "pyproject.toml", "LICENSE"]