vesk-cli 0.1.3__tar.gz → 0.1.5__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.
Files changed (34) hide show
  1. {vesk_cli-0.1.3 → vesk_cli-0.1.5}/PKG-INFO +5 -2
  2. {vesk_cli-0.1.3 → vesk_cli-0.1.5}/README.md +4 -1
  3. {vesk_cli-0.1.3 → vesk_cli-0.1.5}/src/vesk/__init__.py +1 -1
  4. {vesk_cli-0.1.3 → vesk_cli-0.1.5}/src/vesk/cli.py +8 -2
  5. {vesk_cli-0.1.3 → vesk_cli-0.1.5}/src/vesk/config.py +38 -4
  6. {vesk_cli-0.1.3 → vesk_cli-0.1.5}/src/vesk/runner.py +39 -3
  7. {vesk_cli-0.1.3 → vesk_cli-0.1.5}/tests/integration/test_scan_cli.py +29 -0
  8. {vesk_cli-0.1.3 → vesk_cli-0.1.5}/tests/unit/test_scan.py +19 -0
  9. {vesk_cli-0.1.3 → vesk_cli-0.1.5}/.gitignore +0 -0
  10. {vesk_cli-0.1.3 → vesk_cli-0.1.5}/LICENSE +0 -0
  11. {vesk_cli-0.1.3 → vesk_cli-0.1.5}/NOTICE +0 -0
  12. {vesk_cli-0.1.3 → vesk_cli-0.1.5}/docs/.gitkeep +0 -0
  13. {vesk_cli-0.1.3 → vesk_cli-0.1.5}/examples/.gitkeep +0 -0
  14. {vesk_cli-0.1.3 → vesk_cli-0.1.5}/pyproject.toml +0 -0
  15. {vesk_cli-0.1.3 → vesk_cli-0.1.5}/src/vesk/client.py +0 -0
  16. {vesk_cli-0.1.3 → vesk_cli-0.1.5}/src/vesk/data/.gitkeep +0 -0
  17. {vesk_cli-0.1.3 → vesk_cli-0.1.5}/src/vesk/probes/__init__.py +0 -0
  18. {vesk_cli-0.1.3 → vesk_cli-0.1.5}/src/vesk/probes/base.py +0 -0
  19. {vesk_cli-0.1.3 → vesk_cli-0.1.5}/src/vesk/probes/data/.gitkeep +0 -0
  20. {vesk_cli-0.1.3 → vesk_cli-0.1.5}/src/vesk/probes/data/jailbreak.yaml +0 -0
  21. {vesk_cli-0.1.3 → vesk_cli-0.1.5}/src/vesk/probes/data/pii_leakage.yaml +0 -0
  22. {vesk_cli-0.1.3 → vesk_cli-0.1.5}/src/vesk/probes/data/prompt_injection.yaml +0 -0
  23. {vesk_cli-0.1.3 → vesk_cli-0.1.5}/src/vesk/probes/jailbreak.py +0 -0
  24. {vesk_cli-0.1.3 → vesk_cli-0.1.5}/src/vesk/probes/pii_leakage.py +0 -0
  25. {vesk_cli-0.1.3 → vesk_cli-0.1.5}/src/vesk/probes/prompt_injection.py +0 -0
  26. {vesk_cli-0.1.3 → vesk_cli-0.1.5}/src/vesk/report/__init__.py +0 -0
  27. {vesk_cli-0.1.3 → vesk_cli-0.1.5}/src/vesk/report/model.py +0 -0
  28. {vesk_cli-0.1.3 → vesk_cli-0.1.5}/src/vesk/report/render.py +0 -0
  29. {vesk_cli-0.1.3 → vesk_cli-0.1.5}/src/vesk/report/templates/report.html.j2 +0 -0
  30. {vesk_cli-0.1.3 → vesk_cli-0.1.5}/src/vesk/report/templates/report.md.j2 +0 -0
  31. {vesk_cli-0.1.3 → vesk_cli-0.1.5}/src/vesk/scaffold.py +0 -0
  32. {vesk_cli-0.1.3 → vesk_cli-0.1.5}/tests/unit/test_cli.py +0 -0
  33. {vesk_cli-0.1.3 → vesk_cli-0.1.5}/tests/unit/test_init.py +0 -0
  34. {vesk_cli-0.1.3 → vesk_cli-0.1.5}/tests/unit/test_report.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: vesk-cli
3
- Version: 0.1.3
3
+ Version: 0.1.5
4
4
  Summary: Vesk Verify: test LLM endpoints and produce compliance-mapped reports.
5
5
  Author: Vesk
6
6
  License-Expression: Apache-2.0
@@ -53,7 +53,7 @@ vesk scan # run probes, write results.json
53
53
  vesk report results.json -o report.md
54
54
  ```
55
55
 
56
- `api_key_env` holds the *name* of an environment variable (e.g. `VESK_API_KEY`), not a file path or the key. vesk does not read `.env` files; export the variable in your shell (or load your `.env` first, e.g. `set -a; . ./.env; set +a`). Omit `api_key_env` if the endpoint needs no key.
56
+ `api_key_env` holds the *name* of an environment variable (e.g. `VESK_API_KEY`), not a file path or the key. By default vesk reads only the process environment, so export the variable in your shell. To use a `.env` file instead, pass it explicitly: `vesk scan --env-file .env`. Variables already set in the environment take precedence over the file. Omit `api_key_env` if the endpoint needs no key.
57
57
 
58
58
  ## Commands
59
59
 
@@ -71,6 +71,9 @@ Runs the configured probes against the target and writes a results JSON file.
71
71
  - `-c, --config PATH`: config file (default `vesk.yaml`).
72
72
  - `--probe NAME`: run only this probe; repeatable.
73
73
  - `--out PATH`: results file (default `results.json`).
74
+ - `--env-file PATH`: optional dotenv-style file (`KEY=VALUE` lines) to read the API key from. Not read unless given; real environment variables win over the file.
75
+
76
+ While running, `vesk scan` prints progress to stderr: target host and model, each probe's start and status, and request counts. It never prints prompts, responses, API keys, URL paths or credentials; those stay in the results file only (with PII masked).
74
77
 
75
78
  Exit codes:
76
79
 
@@ -22,7 +22,7 @@ vesk scan # run probes, write results.json
22
22
  vesk report results.json -o report.md
23
23
  ```
24
24
 
25
- `api_key_env` holds the *name* of an environment variable (e.g. `VESK_API_KEY`), not a file path or the key. vesk does not read `.env` files; export the variable in your shell (or load your `.env` first, e.g. `set -a; . ./.env; set +a`). Omit `api_key_env` if the endpoint needs no key.
25
+ `api_key_env` holds the *name* of an environment variable (e.g. `VESK_API_KEY`), not a file path or the key. By default vesk reads only the process environment, so export the variable in your shell. To use a `.env` file instead, pass it explicitly: `vesk scan --env-file .env`. Variables already set in the environment take precedence over the file. Omit `api_key_env` if the endpoint needs no key.
26
26
 
27
27
  ## Commands
28
28
 
@@ -40,6 +40,9 @@ Runs the configured probes against the target and writes a results JSON file.
40
40
  - `-c, --config PATH`: config file (default `vesk.yaml`).
41
41
  - `--probe NAME`: run only this probe; repeatable.
42
42
  - `--out PATH`: results file (default `results.json`).
43
+ - `--env-file PATH`: optional dotenv-style file (`KEY=VALUE` lines) to read the API key from. Not read unless given; real environment variables win over the file.
44
+
45
+ While running, `vesk scan` prints progress to stderr: target host and model, each probe's start and status, and request counts. It never prints prompts, responses, API keys, URL paths or credentials; those stay in the results file only (with PII masked).
43
46
 
44
47
  Exit codes:
45
48
 
@@ -1,3 +1,3 @@
1
1
  """Vesk Verify: LLM endpoint testing with compliance-mapped reports."""
2
2
 
3
- __version__ = "0.1.3"
3
+ __version__ = "0.1.5"
@@ -54,6 +54,12 @@ def scan(
54
54
  config: Path = typer.Option(Path("vesk.yaml"), "-c", "--config", help="Config file."),
55
55
  probe: list[str] | None = typer.Option(None, "--probe", help="Probe to run (repeatable)."),
56
56
  out: Path = typer.Option(Path("results.json"), "--out", help="Results JSON path."),
57
+ env_file: Path | None = typer.Option(
58
+ None,
59
+ "--env-file",
60
+ help="Optional .env file to read the API key from. "
61
+ "Variables already set in the environment win. Without it, only the environment is used.",
62
+ ),
57
63
  ) -> None:
58
64
  """Run probes against the target and write a results JSON file."""
59
65
  import asyncio
@@ -65,7 +71,7 @@ def scan(
65
71
  try:
66
72
  cfg = load_config(config)
67
73
  probes = select_probes(cfg, probe)
68
- api_key = cfg.api_key()
74
+ api_key = cfg.api_key(env_file)
69
75
  except ConfigError as e:
70
76
  typer.echo(f"vesk scan: {e}", err=True)
71
77
  raise typer.Exit(2) from e
@@ -79,7 +85,7 @@ def scan(
79
85
  max_retries=cfg.target.max_retries,
80
86
  )
81
87
  try:
82
- return await run_scan(cfg, probes, client)
88
+ return await run_scan(cfg, probes, client, progress=lambda m: typer.echo(m, err=True))
83
89
  finally:
84
90
  await client.aclose()
85
91
 
@@ -51,8 +51,12 @@ class Config(_Strict):
51
51
  scan: ScanConfig = Field(default_factory=lambda: ScanConfig())
52
52
  report: ReportConfig = Field(default_factory=lambda: ReportConfig())
53
53
 
54
- def api_key(self) -> str | None:
55
- """Read the API key from the environment variable named in the config."""
54
+ def api_key(self, env_file: Path | None = None) -> str | None:
55
+ """Read the API key from the environment variable named in the config.
56
+
57
+ If env_file is given, its values fill in variables missing from the process
58
+ environment; variables already set in the environment take precedence.
59
+ """
56
60
  name = self.target.api_key_env
57
61
  if not name:
58
62
  return None
@@ -63,17 +67,47 @@ class Config(_Strict):
63
67
  "the key itself, then export that variable: export VESK_API_KEY=<your key>"
64
68
  )
65
69
  key = os.environ.get(name)
70
+ if not key and env_file is not None:
71
+ key = read_env_file(env_file).get(name)
66
72
  if not key:
67
73
  raise ConfigError(
68
74
  f"environment variable {name} is not set or is empty "
69
75
  f"(named by target.api_key_env in your config). "
70
76
  f"Export it in this shell first: export {name}=<your key>. "
71
- "vesk does not read .env files. If the endpoint needs no key, "
72
- "remove api_key_env from the config."
77
+ "Or put it in a .env file and pass --env-file .env "
78
+ "(vesk only reads .env files when you pass --env-file). "
79
+ "If the endpoint needs no key, remove api_key_env from the config."
73
80
  )
74
81
  return key
75
82
 
76
83
 
84
+ def read_env_file(path: Path) -> dict[str, str]:
85
+ """Parse a dotenv-style file: KEY=VALUE lines, # comments, optional `export `, quotes."""
86
+ try:
87
+ text = path.read_text(encoding="utf-8")
88
+ except FileNotFoundError:
89
+ raise ConfigError(f"env file not found: {path}") from None
90
+ except (OSError, UnicodeDecodeError) as e:
91
+ raise ConfigError(f"cannot read env file {path}: {e}") from e
92
+ values: dict[str, str] = {}
93
+ for line in text.splitlines():
94
+ line = line.strip()
95
+ if not line or line.startswith("#"):
96
+ continue
97
+ if line.startswith("export "):
98
+ line = line[len("export ") :].lstrip()
99
+ name, sep, value = line.partition("=")
100
+ name, value = name.strip(), value.strip()
101
+ if not sep or not name.isidentifier():
102
+ continue
103
+ if len(value) >= 2 and value[0] == value[-1] and value[0] in "\"'":
104
+ value = value[1:-1]
105
+ elif " #" in value:
106
+ value = value.split(" #", 1)[0].rstrip()
107
+ values[name] = value
108
+ return values
109
+
110
+
77
111
  def load_config(path: Path) -> Config:
78
112
  try:
79
113
  raw = yaml.safe_load(path.read_text(encoding="utf-8"))
@@ -5,6 +5,8 @@ from __future__ import annotations
5
5
  import asyncio
6
6
  from datetime import datetime, timezone
7
7
  from pathlib import Path
8
+ from typing import Callable
9
+ from urllib.parse import urlsplit
8
10
 
9
11
  from pydantic import BaseModel, Field
10
12
 
@@ -78,17 +80,35 @@ def _excerpt(text: str) -> str:
78
80
  return text if len(text) <= RESPONSE_LIMIT else text[:RESPONSE_LIMIT] + "…"
79
81
 
80
82
 
81
- async def _run_probe(probe: Probe, cfg: Config, client: ChatClient, sem: asyncio.Semaphore):
83
+ def display_host(base_url: str) -> str:
84
+ """Host[:port] only: drops credentials, path and query so nothing sensitive is echoed."""
85
+ return urlsplit(base_url).netloc.rpartition("@")[2] or "target"
86
+
87
+
88
+ async def _run_probe(
89
+ probe: Probe,
90
+ cfg: Config,
91
+ client: ChatClient,
92
+ sem: asyncio.Semaphore,
93
+ progress: Progress | None = None,
94
+ ):
82
95
  cases = probe.cases(cfg.target.system_prompt, cfg.scan.samples_per_probe, cfg.scan.seed)
83
96
  errors: list[str] = []
97
+ done = 0
98
+ step = max(1, len(cases) // 4)
84
99
 
85
100
  async def one(case) -> CaseResult | None:
101
+ nonlocal done
86
102
  async with sem:
87
103
  try:
88
104
  response = await client.complete(case.messages)
89
105
  except EndpointError as e:
90
106
  errors.append(str(e))
91
107
  return None
108
+ finally:
109
+ done += 1
110
+ if progress and (done == len(cases) or done % step == 0):
111
+ progress(f" {probe.name}: {done}/{len(cases)} requests")
92
112
  verdict = probe.evaluate(case, response)
93
113
  return CaseResult(
94
114
  id=case.id,
@@ -118,12 +138,28 @@ async def _run_probe(probe: Probe, cfg: Config, client: ChatClient, sem: asyncio
118
138
  )
119
139
 
120
140
 
141
+ Progress = Callable[[str], None]
142
+
143
+
121
144
  async def run_scan(
122
- cfg: Config, probes: list[Probe], client: ChatClient
145
+ cfg: Config, probes: list[Probe], client: ChatClient, progress: Progress | None = None
123
146
  ) -> ScanResults:
147
+ """`progress` receives short status lines: probe names and counts only, never content."""
124
148
  started = _now()
125
149
  sem = asyncio.Semaphore(cfg.scan.max_concurrency)
126
- runs = [await _run_probe(p, cfg, client, sem) for p in probes]
150
+ if progress:
151
+ progress(
152
+ f"Scanning {display_host(cfg.target.base_url)} (model {cfg.target.model}) "
153
+ f"with {len(probes)} probe(s)"
154
+ )
155
+ runs = []
156
+ for i, p in enumerate(probes, 1):
157
+ if progress:
158
+ progress(f"[{i}/{len(probes)}] {p.name}: running")
159
+ run = await _run_probe(p, cfg, client, sem, progress)
160
+ if progress:
161
+ progress(f"[{i}/{len(probes)}] {p.name}: {run.status}")
162
+ runs.append(run)
127
163
  return ScanResults(
128
164
  target=cfg.target.base_url,
129
165
  model=cfg.target.model,
@@ -120,6 +120,18 @@ def test_endpoint_down_exits_3_and_still_writes_results(cfg, tmp_path):
120
120
  assert all(p["status"] == "error" and p["error"] for p in data["probes"])
121
121
 
122
122
 
123
+ @respx.mock
124
+ def test_progress_shows_status_without_sensitive_data(cfg, tmp_path):
125
+ respx.post(URL).mock(side_effect=vulnerable)
126
+ r, out = scan(cfg, tmp_path)
127
+ assert "Scanning llm.test (model m) with 3 probe(s)" in r.output
128
+ assert "[1/3]" in r.output and "[3/3]" in r.output
129
+ assert "4/4 requests" in r.output
130
+ assert "secret-key" not in r.output
131
+ assert "/v1" not in r.output.split("Scanning", 1)[1].splitlines()[0]
132
+ assert not MARKER.search(r.output)
133
+
134
+
123
135
  @respx.mock
124
136
  def test_sends_bearer_key(cfg, tmp_path):
125
137
  route = respx.post(URL).mock(side_effect=hardened)
@@ -145,3 +157,20 @@ def test_missing_api_key_env_exits_2(cfg, tmp_path, monkeypatch):
145
157
  r, _ = scan(cfg, tmp_path)
146
158
  assert r.exit_code == 2
147
159
  assert "VESK_TEST_KEY" in r.output
160
+
161
+
162
+ @respx.mock
163
+ def test_env_file_supplies_key_when_env_unset(cfg, tmp_path, monkeypatch):
164
+ monkeypatch.delenv("VESK_TEST_KEY")
165
+ envf = tmp_path / ".env"
166
+ envf.write_text("VESK_TEST_KEY=file-key\n")
167
+ respx.post(URL).mock(side_effect=hardened)
168
+ r, _ = scan(cfg, tmp_path, "--env-file", str(envf))
169
+ assert r.exit_code == 0, r.output
170
+
171
+
172
+ def test_env_file_missing_exits_2(cfg, tmp_path, monkeypatch):
173
+ monkeypatch.delenv("VESK_TEST_KEY")
174
+ r, _ = scan(cfg, tmp_path, "--env-file", str(tmp_path / "nope.env"))
175
+ assert r.exit_code == 2
176
+ assert "env file not found" in r.output
@@ -221,3 +221,22 @@ async def test_client_retries_timeouts():
221
221
 
222
222
  with pytest.raises(EndpointError, match="ConnectTimeout|slow"):
223
223
  await _client(handler, retries=1).complete([])
224
+
225
+
226
+ def test_api_key_from_env_file(tmp_path, monkeypatch):
227
+ cfg = load_config(write(tmp_path, MINIMAL + " api_key_env: MY_KEY\n"))
228
+ monkeypatch.delenv("MY_KEY", raising=False)
229
+ env = tmp_path / ".env"
230
+ env.write_text("# c\nOTHER=1\nexport MY_KEY='from-file'\n")
231
+ assert cfg.api_key(env) == "from-file"
232
+ with pytest.raises(ConfigError, match="--env-file"):
233
+ cfg.api_key() # file is never read unless passed
234
+ monkeypatch.setenv("MY_KEY", "from-env")
235
+ assert cfg.api_key(env) == "from-env" # environment wins
236
+
237
+
238
+ def test_env_file_missing(tmp_path, monkeypatch):
239
+ cfg = load_config(write(tmp_path, MINIMAL + " api_key_env: MY_KEY\n"))
240
+ monkeypatch.delenv("MY_KEY", raising=False)
241
+ with pytest.raises(ConfigError, match="env file not found"):
242
+ cfg.api_key(tmp_path / "nope.env")
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes