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.
- driftwatch_llm-0.1.0/.gitignore +94 -0
- driftwatch_llm-0.1.0/LICENSE +21 -0
- driftwatch_llm-0.1.0/PKG-INFO +34 -0
- driftwatch_llm-0.1.0/driftwatch_llm/README.md +20 -0
- driftwatch_llm-0.1.0/driftwatch_llm/__init__.py +7 -0
- driftwatch_llm-0.1.0/driftwatch_llm/__main__.py +4 -0
- driftwatch_llm-0.1.0/driftwatch_llm/api.py +140 -0
- driftwatch_llm-0.1.0/driftwatch_llm/cli.py +331 -0
- driftwatch_llm-0.1.0/driftwatch_llm/device_flow.py +89 -0
- driftwatch_llm-0.1.0/driftwatch_llm/endpoints.py +61 -0
- driftwatch_llm-0.1.0/driftwatch_llm/fileutil.py +37 -0
- driftwatch_llm-0.1.0/driftwatch_llm/hooks.py +37 -0
- driftwatch_llm-0.1.0/driftwatch_llm/paths.py +16 -0
- driftwatch_llm-0.1.0/driftwatch_llm/project.py +106 -0
- driftwatch_llm-0.1.0/driftwatch_llm/secrets.py +172 -0
- driftwatch_llm-0.1.0/driftwatch_llm/trust.py +69 -0
- driftwatch_llm-0.1.0/driftwatch_llm/validate.py +20 -0
- driftwatch_llm-0.1.0/pyproject.toml +31 -0
|
@@ -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,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"]
|