abrade-cli 0.1.2__tar.gz → 0.1.4__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 (23) hide show
  1. {abrade_cli-0.1.2 → abrade_cli-0.1.4}/PKG-INFO +1 -1
  2. {abrade_cli-0.1.2 → abrade_cli-0.1.4}/pyproject.toml +1 -1
  3. {abrade_cli-0.1.2 → abrade_cli-0.1.4}/pyproject.toml.orig +1 -1
  4. {abrade_cli-0.1.2 → abrade_cli-0.1.4}/src/abrade_cli/auth.py +21 -5
  5. {abrade_cli-0.1.2 → abrade_cli-0.1.4}/src/abrade_cli/cli.py +100 -2
  6. {abrade_cli-0.1.2 → abrade_cli-0.1.4}/src/abrade_cli/report.py +13 -1
  7. abrade_cli-0.1.4/src/abrade_cli/safeio.py +147 -0
  8. {abrade_cli-0.1.2 → abrade_cli-0.1.4}/src/abrade_cli/storage.py +41 -24
  9. abrade_cli-0.1.4/src/abrade_cli/upgrade.py +97 -0
  10. abrade_cli-0.1.4/src/abrade_cli/verify.py +248 -0
  11. {abrade_cli-0.1.2 → abrade_cli-0.1.4}/README.md +0 -0
  12. {abrade_cli-0.1.2 → abrade_cli-0.1.4}/src/abrade_cli/__init__.py +0 -0
  13. {abrade_cli-0.1.2 → abrade_cli-0.1.4}/src/abrade_cli/canary.py +0 -0
  14. {abrade_cli-0.1.2 → abrade_cli-0.1.4}/src/abrade_cli/config.py +0 -0
  15. {abrade_cli-0.1.2 → abrade_cli-0.1.4}/src/abrade_cli/containment.py +0 -0
  16. {abrade_cli-0.1.2 → abrade_cli-0.1.4}/src/abrade_cli/corpus.py +0 -0
  17. {abrade_cli-0.1.2 → abrade_cli-0.1.4}/src/abrade_cli/data/__init__.py +0 -0
  18. {abrade_cli-0.1.2 → abrade_cli-0.1.4}/src/abrade_cli/data/sample_corpus.yaml +0 -0
  19. {abrade_cli-0.1.2 → abrade_cli-0.1.4}/src/abrade_cli/execution.py +0 -0
  20. {abrade_cli-0.1.2 → abrade_cli-0.1.4}/src/abrade_cli/models.py +0 -0
  21. {abrade_cli-0.1.2 → abrade_cli-0.1.4}/src/abrade_cli/runner.py +0 -0
  22. {abrade_cli-0.1.2 → abrade_cli-0.1.4}/src/abrade_cli/tos.py +0 -0
  23. {abrade_cli-0.1.2 → abrade_cli-0.1.4}/src/abrade_cli/upstream_errors.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: abrade-cli
3
- Version: 0.1.2
3
+ Version: 0.1.4
4
4
  Summary: Abrade's self-hosted CLI — run the same injection suite entirely offline, no SaaS required.
5
5
  Keywords: ai-agents,security-testing,prompt-injection,red-teaming,llm-security
6
6
  Author: Abrade
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "abrade-cli"
3
- version = "0.1.2"
3
+ version = "0.1.4"
4
4
  description = "Abrade's self-hosted CLI — run the same injection suite entirely offline, no SaaS required."
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.12"
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "abrade-cli"
3
- version = "0.1.2"
3
+ version = "0.1.4"
4
4
  description = "Abrade's self-hosted CLI — run the same injection suite entirely offline, no SaaS required."
5
5
  readme = "README.md"
6
6
  # The project, not a personal handle. PyPI renders this as "Author" on
@@ -20,10 +20,21 @@ import urllib.request
20
20
  from pathlib import Path
21
21
  from typing import TYPE_CHECKING
22
22
 
23
+ from abrade_cli.safeio import write_secret_text
24
+
23
25
  if TYPE_CHECKING:
24
26
  from abrade_cli.models import RunRecord
25
27
 
26
- DEFAULT_API_URL = "http://localhost:8123"
28
+ # The hosted console. This defaulted to http://localhost:8123, which is
29
+ # where the API runs under docker-compose — correct for someone developing
30
+ # Abrade, wrong for everyone who installs it. `abrade login` from a clean
31
+ # machine tried to reach a port on the user's own laptop and failed with a
32
+ # connection error that named localhost, which reads like the CLI is
33
+ # broken rather than misconfigured.
34
+ #
35
+ # ABRADE_API_URL still overrides, which is how local development points it
36
+ # back at compose.
37
+ DEFAULT_API_URL = "https://abrade.ai"
27
38
 
28
39
 
29
40
  class AbradeApiError(Exception):
@@ -64,14 +75,19 @@ def save_credentials(
64
75
  *, api_key: str, api_url: str, org: dict, home: Path | None = None
65
76
  ) -> None:
66
77
  path = _credentials_path(home)
67
- path.parent.mkdir(parents=True, exist_ok=True)
68
- path.write_text(
78
+ # Was write_text() followed by chmod(0o600). Between those two calls
79
+ # the file holding a live API key sat at whatever the umask allowed —
80
+ # 0644 on a typical workstation — and that window is the whole
81
+ # exposure. Creating it 0600 has no window. ~/.abrade itself is
82
+ # tightened to 0700 for the same reason: it previously inherited the
83
+ # umask too.
84
+ write_secret_text(
85
+ path,
69
86
  json.dumps(
70
87
  {"api_key": api_key, "api_url": api_url, "org_name": org.get("name")},
71
88
  indent=2,
72
- )
89
+ ),
73
90
  )
74
- path.chmod(0o600) # contains a live secret, unlike tos_accepted.json
75
91
 
76
92
 
77
93
  def load_credentials(home: Path | None = None) -> dict | None:
@@ -16,7 +16,7 @@ from abrade_cli.execution import MissingCredentialError
16
16
  from abrade_cli.models import SuiteMode
17
17
  from abrade_cli.report import render_pdf
18
18
  from abrade_cli.runner import run_suite
19
- from abrade_cli.storage import list_runs, load_run, save_run
19
+ from abrade_cli.storage import latest_run_id, list_runs, load_run, save_run
20
20
 
21
21
  app = typer.Typer(
22
22
  name="abrade",
@@ -97,6 +97,85 @@ def init(
97
97
  console.print(f"[green]Wrote {path}.[/green] Edit it, then run `abrade run`.")
98
98
 
99
99
 
100
+ @app.command()
101
+ def upgrade(
102
+ check_only: bool = typer.Option(
103
+ False,
104
+ "--check",
105
+ help="Exit non-zero if a newer version exists. For CI.",
106
+ ),
107
+ ) -> None:
108
+ """Check whether a newer CLI is published, and how to install it."""
109
+ from abrade_cli import upgrade as upgrade_module
110
+
111
+ installed = _installed_version()
112
+ try:
113
+ release = upgrade_module.check(installed)
114
+ except upgrade_module.UpgradeCheckError as exc:
115
+ # A failed check must not look like "you are up to date". Someone
116
+ # offline is the expected case for this tool, not an error worth a
117
+ # stack trace — but they should know the question went unanswered.
118
+ console.print(f"[yellow]Could not check for updates:[/yellow] {exc}")
119
+ console.print(f"Installed: [bold]{installed}[/bold]")
120
+ raise typer.Exit(code=0 if not check_only else 1) from exc
121
+
122
+ if not release.outdated:
123
+ console.print(f"[green]Up to date.[/green] abrade {release.installed}")
124
+ return
125
+
126
+ console.print(
127
+ f"[yellow]Update available:[/yellow] "
128
+ f"{release.installed} → [bold]{release.latest}[/bold]"
129
+ )
130
+ console.print(f"\n {upgrade_module.install_command()}\n")
131
+ if check_only:
132
+ raise typer.Exit(code=1)
133
+
134
+
135
+ @app.command()
136
+ def verify(
137
+ agent: Path = typer.Option(config_module.DEFAULT_CONFIG_PATH, "--agent"),
138
+ corpus_path: Path | None = typer.Option(None, "--corpus"),
139
+ probe: bool = typer.Option(
140
+ False,
141
+ "--probe",
142
+ help="Also send ONE real request to the model endpoint. Costs tokens.",
143
+ ),
144
+ ) -> None:
145
+ """Check the configuration a run needs, without firing a suite."""
146
+ from abrade_cli import verify as verify_module
147
+
148
+ checks = verify_module.run_checks(
149
+ config_path=agent, corpus_path=corpus_path, probe=probe
150
+ )
151
+
152
+ symbol = {
153
+ verify_module.Level.OK: "[green]ok [/green]",
154
+ verify_module.Level.WARN: "[yellow]warn[/yellow]",
155
+ verify_module.Level.FAIL: "[red]FAIL[/red]",
156
+ }
157
+ for check in checks:
158
+ console.print(f" {symbol[check.level]} [bold]{check.name}[/bold]")
159
+ console.print(f" [dim]{check.detail}[/dim]")
160
+
161
+ failures = [c for c in checks if c.blocking]
162
+ warnings = [c for c in checks if c.level is verify_module.Level.WARN]
163
+
164
+ console.print()
165
+ if failures:
166
+ console.print(
167
+ f"[red]{len(failures)} blocking problem(s).[/red] "
168
+ f"`abrade run` would not produce a meaningful result."
169
+ )
170
+ # Non-zero so CI can gate on this rather than discovering the same
171
+ # problems one failed run later.
172
+ raise typer.Exit(code=1)
173
+ if warnings:
174
+ console.print(f"[yellow]Ready, with {len(warnings)} warning(s).[/yellow]")
175
+ return
176
+ console.print("[green]Ready.[/green]")
177
+
178
+
100
179
  @app.command()
101
180
  def run(
102
181
  agent: Path = typer.Option(config_module.DEFAULT_CONFIG_PATH, "--agent"),
@@ -236,7 +315,12 @@ def report(
236
315
 
237
316
  @app.command()
238
317
  def push(
239
- run_id: str = typer.Option(..., "--run"),
318
+ run_id: str | None = typer.Option(None, "--run"),
319
+ # For CI, which has just produced a run and has no way to learn its id
320
+ # without parsing stdout. Ordered by write time, not by id — see
321
+ # storage.latest_run_id, because run ids are UUIDs and sorting them
322
+ # would upload an arbitrary run.
323
+ latest: bool = typer.Option(False, "--latest"),
240
324
  agent: Path = typer.Option(config_module.DEFAULT_CONFIG_PATH, "--agent"),
241
325
  api_url: str | None = typer.Option(None, "--api-url"),
242
326
  ) -> None:
@@ -245,6 +329,20 @@ def push(
245
329
  Optional — abrade run/list/report never require this. Nothing is sent
246
330
  unless you run this command explicitly (see `abrade login --help`).
247
331
  """
332
+ if latest and run_id:
333
+ console.print("[red]Pass either --run or --latest, not both.[/red]")
334
+ raise typer.Exit(code=2)
335
+ if latest:
336
+ run_id = latest_run_id()
337
+ if run_id is None:
338
+ console.print("[red]No local runs found — run `abrade run` first.[/red]")
339
+ raise typer.Exit(code=1)
340
+ if not run_id:
341
+ console.print(
342
+ "[red]Which run? Pass --run <id>, or --latest for the most recent.[/red]"
343
+ )
344
+ raise typer.Exit(code=2)
345
+
248
346
  try:
249
347
  record = load_run(run_id)
250
348
  except FileNotFoundError as exc:
@@ -4,6 +4,7 @@ from fpdf import FPDF
4
4
  from fpdf.enums import XPos, YPos
5
5
 
6
6
  from abrade_cli.models import RunRecord
7
+ from abrade_cli.safeio import harden_existing, secure_dir
7
8
 
8
9
  # Same palette as Console's light-theme tokens.css and the CLI's own
9
10
  # VERDICT_COLOR in cli.py — one source of truth for what each verdict
@@ -146,6 +147,17 @@ def render_pdf(run: RunRecord, output_path: Path) -> Path:
146
147
  pdf.rect(pdf.l_margin - 4, top, 1.2, pdf.get_y() - top - 1, style="F")
147
148
  pdf.ln(3)
148
149
 
149
- output_path.parent.mkdir(parents=True, exist_ok=True)
150
+ # A report contains every finding the run produced — the agent's raw
151
+ # replies and the planted canaries — so it is as sensitive as the
152
+ # evidence it summarises, and it was being written at whatever the
153
+ # umask allowed (0644 typically).
154
+ #
155
+ # fpdf2's output() takes a path, not a file descriptor, so this cannot
156
+ # use the create-at-0600 primitive the other writers do. Tightening
157
+ # immediately afterwards leaves a window this code cannot close
158
+ # without reimplementing the PDF writer; it is narrow, and the
159
+ # alternative was a world-readable report forever.
160
+ secure_dir(output_path.parent)
150
161
  pdf.output(str(output_path))
162
+ harden_existing(output_path)
151
163
  return output_path
@@ -0,0 +1,147 @@
1
+ """Writing files that hold evidence or credentials, safely. (ABR-017)
2
+
3
+ Four call sites wrote sensitive material four slightly different ways, and
4
+ each got a different subset of this right. One primitive instead, because
5
+ the parts that were missing are exactly the parts that are easy to forget:
6
+
7
+ **Permissions must exist before the content does.** `write_text()` then
8
+ `chmod(0o600)` leaves the file world-readable under a typical 022 umask
9
+ for the window between the two calls, and that window is when a co-tenant
10
+ process reads it. `os.open(..., 0o600)` has no window.
11
+
12
+ **Writes must not follow symlinks.** Without `O_NOFOLLOW`, a local actor
13
+ who controls the path can point `.abrade/runs/<id>.json` at any file the
14
+ user can write, and the CLI truncates it. `O_NOFOLLOW` makes that an
15
+ error instead.
16
+
17
+ **Replacement must be atomic.** Writing in place with `O_TRUNC` means a
18
+ crash — or a full disk — leaves a truncated file that still parses as
19
+ "the run". Crash corruption needs no attacker, which makes it the more
20
+ likely of the two failures here. Writing a temp file and `os.replace()`
21
+ means a reader sees either the old content or the new, never half.
22
+
23
+ **fsync before rename**, or the rename can land before the bytes do and a
24
+ power loss leaves an empty file where a complete one is expected.
25
+
26
+ POSIX-only in one respect: `O_NOFOLLOW` does not exist on Windows, and
27
+ `os.open` ignores the mode argument there. The CLI's threat model is a
28
+ shared CI runner or a developer workstation, both POSIX in practice; on
29
+ Windows this degrades to an atomic write without the symlink guard rather
30
+ than failing.
31
+ """
32
+
33
+ from __future__ import annotations
34
+
35
+ import os
36
+ import tempfile
37
+ from pathlib import Path
38
+
39
+ # Owner-only, for anything holding evidence or a credential.
40
+ SECRET_FILE_MODE = 0o600
41
+ SECRET_DIR_MODE = 0o700
42
+
43
+ _NOFOLLOW = getattr(os, "O_NOFOLLOW", 0)
44
+
45
+
46
+ class UnsafePathError(RuntimeError):
47
+ """The target is a symlink, or its directory is not owner-controlled."""
48
+
49
+
50
+ def secure_dir(path: Path) -> Path:
51
+ """Create (or tighten) a directory to 0700 and return it.
52
+
53
+ `mkdir(mode=...)` is masked by the umask and skipped entirely when the
54
+ directory already exists, so the mode is set explicitly either way —
55
+ the pre-existing directory is the common case and the one that would
56
+ otherwise keep whatever permissions it was created with.
57
+ """
58
+ path.mkdir(parents=True, exist_ok=True, mode=SECRET_DIR_MODE)
59
+ try:
60
+ os.chmod(path, SECRET_DIR_MODE)
61
+ except OSError:
62
+ # A directory we cannot chmod (someone else's, or a read-only
63
+ # mount) is not worth aborting a run over — the file write below
64
+ # still creates 0600, and it will fail loudly if the directory is
65
+ # genuinely unusable.
66
+ pass
67
+ return path
68
+
69
+
70
+ def _reject_symlink(path: Path) -> None:
71
+ # lstat rather than exists(): exists() follows the link and reports on
72
+ # the target, which is the thing being defended against.
73
+ try:
74
+ if path.is_symlink():
75
+ raise UnsafePathError(
76
+ f"{path} is a symlink. Refusing to write evidence or "
77
+ f"credentials through one — the target may be a file you did "
78
+ f"not intend to overwrite."
79
+ )
80
+ except OSError:
81
+ return
82
+
83
+
84
+ def write_secret_text(path: Path, content: str) -> Path:
85
+ """Atomically write `content` to `path`, owner-readable only.
86
+
87
+ The temp file is created in the destination directory rather than
88
+ /tmp, because `os.replace` is only atomic within a filesystem and
89
+ /tmp is frequently a different one.
90
+ """
91
+ _reject_symlink(path)
92
+ secure_dir(path.parent)
93
+
94
+ fd, tmp_name = tempfile.mkstemp(
95
+ dir=str(path.parent), prefix=f".{path.name}.", suffix=".tmp"
96
+ )
97
+ tmp = Path(tmp_name)
98
+ try:
99
+ os.fchmod(fd, SECRET_FILE_MODE)
100
+ with os.fdopen(fd, "w") as handle:
101
+ handle.write(content)
102
+ handle.flush()
103
+ os.fsync(handle.fileno())
104
+ os.replace(tmp, path)
105
+ except BaseException:
106
+ # Never leave a partial temp file holding evidence behind.
107
+ tmp.unlink(missing_ok=True)
108
+ raise
109
+ return path
110
+
111
+
112
+ def create_secret_file_exclusively(path: Path, content: str) -> bool:
113
+ """Write `content` only if `path` does not already exist.
114
+
115
+ Returns False when it did. `O_EXCL | O_CREAT` makes the check and the
116
+ creation one operation — an `exists()` test followed by a write is a
117
+ race, and the loser silently overwrites whatever the winner put there.
118
+ """
119
+ secure_dir(path.parent)
120
+ try:
121
+ fd = os.open(
122
+ path,
123
+ os.O_WRONLY | os.O_CREAT | os.O_EXCL | _NOFOLLOW,
124
+ SECRET_FILE_MODE,
125
+ )
126
+ except FileExistsError:
127
+ return False
128
+ with os.fdopen(fd, "w") as handle:
129
+ handle.write(content)
130
+ handle.flush()
131
+ os.fsync(handle.fileno())
132
+ return True
133
+
134
+
135
+ def harden_existing(path: Path) -> None:
136
+ """Tighten a file some other library created.
137
+
138
+ For outputs written by code that does not accept a file descriptor —
139
+ fpdf2's `output()` takes a path. The window between its create and
140
+ this chmod is real and unavoidable without reimplementing the writer;
141
+ it is narrower than leaving the file 0644 forever, which is what
142
+ happened before.
143
+ """
144
+ try:
145
+ os.chmod(path, SECRET_FILE_MODE)
146
+ except OSError:
147
+ pass
@@ -4,11 +4,15 @@ here ever leaves the local filesystem (hard constraint #4).
4
4
  """
5
5
 
6
6
  import json
7
- import os
8
7
  from dataclasses import asdict
9
8
  from pathlib import Path
10
9
 
11
10
  from abrade_cli.models import Posture, ResultRow, RunRecord, SuiteMode, Verdict
11
+ from abrade_cli.safeio import (
12
+ create_secret_file_exclusively,
13
+ secure_dir,
14
+ write_secret_text,
15
+ )
12
16
 
13
17
  RUNS_DIR = Path(".abrade") / "runs"
14
18
 
@@ -33,34 +37,31 @@ def save_run(run: RunRecord, base: Path | None = None) -> Path:
33
37
  makes that safe by default rather than by documentation.
34
38
  """
35
39
  path = _run_path(run.id, base)
36
- path.parent.mkdir(parents=True, exist_ok=True, mode=0o700)
37
- # mkdir's mode is masked by umask and skipped entirely if the
38
- # directory already exists, so set it explicitly either way.
39
- os.chmod(path.parent, 0o700)
40
+ secure_dir(path.parent)
40
41
  _ensure_gitignore(path.parent)
41
42
 
42
- # Create with 0600 rather than writing and then chmod-ing: between
43
- # those two calls the file is readable, and that window is when a
44
- # co-tenant process gets it.
45
- fd = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
46
- with os.fdopen(fd, "w") as handle:
47
- handle.write(json.dumps(asdict(run), indent=2, default=str))
48
- os.chmod(path, 0o600)
49
- return path
43
+ # Atomic, 0600 from creation, and refuses to write through a symlink.
44
+ # The previous version opened the real path with O_TRUNC, so a crash
45
+ # mid-write left a truncated file that still looked like the run — and
46
+ # crash corruption, unlike symlink redirection, needs no attacker.
47
+ return write_secret_text(path, json.dumps(asdict(run), indent=2, default=str))
50
48
 
51
49
 
52
50
  def _ensure_gitignore(runs_dir: Path) -> None:
53
- """Keep run evidence out of the customer's repository."""
54
- marker = runs_dir.parent / ".gitignore"
55
- if marker.exists():
56
- return
57
- fd = os.open(marker, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
58
- with os.fdopen(fd, "w") as handle:
59
- handle.write(
60
- "# Run evidence contains raw agent replies, injected telemetry\n"
61
- "# and planted canaries. Not for version control.\n"
62
- "*\n"
63
- )
51
+ """Keep run evidence out of the customer's repository.
52
+
53
+ Exclusive-create rather than exists()-then-write: those are two
54
+ operations, and between them another process can create the file, at
55
+ which point this one silently truncates whatever was written. It also
56
+ means an existing .gitignore is never overwritten, which was the
57
+ intent of the original `exists()` check.
58
+ """
59
+ create_secret_file_exclusively(
60
+ runs_dir.parent / ".gitignore",
61
+ "# Run evidence contains raw agent replies, injected telemetry\n"
62
+ "# and planted canaries. Not for version control.\n"
63
+ "*\n",
64
+ )
64
65
 
65
66
 
66
67
  def load_run(run_id: str, base: Path | None = None) -> RunRecord:
@@ -109,6 +110,22 @@ def load_run(run_id: str, base: Path | None = None) -> RunRecord:
109
110
  )
110
111
 
111
112
 
113
+ def latest_run_id(base: Path | None = None) -> str | None:
114
+ """The most recently written run, or None if there are none.
115
+
116
+ Ordered by write time rather than by id. Run ids are UUIDs, so sorting
117
+ them alphabetically returns an arbitrary run — and `abrade push
118
+ --latest` picking the wrong one would upload the wrong evidence
119
+ silently, which is worse than failing.
120
+ """
121
+ root = base or Path.cwd()
122
+ runs_dir = root / RUNS_DIR
123
+ if not runs_dir.exists():
124
+ return None
125
+ runs = sorted(runs_dir.glob("*.json"), key=lambda p: p.stat().st_mtime)
126
+ return runs[-1].stem if runs else None
127
+
128
+
112
129
  def list_runs(base: Path | None = None) -> list[str]:
113
130
  root = base or Path.cwd()
114
131
  runs_dir = root / RUNS_DIR
@@ -0,0 +1,97 @@
1
+ """Is this copy of the CLI current, and how would you replace it?
2
+
3
+ Deliberately never automatic. This CLI's whole claim is "self-hosted,
4
+ offline-by-default" — a version check that phoned PyPI on every `abrade
5
+ run` would make that false for a convenience nobody asked for, and would
6
+ leak a usage signal from air-gapped environments that chose this tool
7
+ precisely because it does not talk to anyone. So the network call happens
8
+ only when someone types `abrade upgrade`.
9
+
10
+ The command deliberately does NOT perform the upgrade. It prints the
11
+ command for the installer it detected. Running the upgrade itself means
12
+ guessing between uv, pipx and pip, and guessing wrong means either a
13
+ confusing failure or — worse — a second copy installed alongside the first
14
+ under a different manager, which is exactly the state that produces "I
15
+ updated it and the new command still isn't there".
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ import json
21
+ import os
22
+ import sys
23
+ import urllib.error
24
+ import urllib.request
25
+ from dataclasses import dataclass
26
+ from pathlib import Path
27
+
28
+ PYPI_URL = "https://pypi.org/pypi/abrade-cli/json"
29
+
30
+
31
+ class UpgradeCheckError(RuntimeError):
32
+ pass
33
+
34
+
35
+ @dataclass(frozen=True)
36
+ class Release:
37
+ installed: str
38
+ latest: str
39
+
40
+ @property
41
+ def outdated(self) -> bool:
42
+ return _parse(self.latest) > _parse(self.installed)
43
+
44
+
45
+ def _parse(version: str) -> tuple:
46
+ """Compare versions numerically, so 0.1.10 sorts above 0.1.9.
47
+
48
+ A string comparison gets that backwards, and the failure is silent:
49
+ the CLI would simply stop reporting upgrades once the patch number
50
+ reached double digits. Non-numeric segments (rc, dev) sort below a
51
+ plain release, which is the conservative direction — it can suggest an
52
+ upgrade that is not needed, never hide one that is.
53
+ """
54
+ parts = []
55
+ for chunk in version.split("."):
56
+ digits = ""
57
+ for char in chunk:
58
+ if not char.isdigit():
59
+ break
60
+ digits += char
61
+ parts.append((int(digits) if digits else 0, chunk == digits))
62
+ return tuple(parts)
63
+
64
+
65
+ def latest_version(*, timeout: float = 10.0) -> str:
66
+ try:
67
+ with urllib.request.urlopen(PYPI_URL, timeout=timeout) as response: # noqa: S310 — fixed https URL constant
68
+ return str(json.loads(response.read())["info"]["version"])
69
+ except urllib.error.URLError as exc:
70
+ raise UpgradeCheckError(f"Could not reach PyPI: {exc.reason}") from exc
71
+ except (KeyError, ValueError) as exc:
72
+ raise UpgradeCheckError("PyPI returned an unexpected response.") from exc
73
+
74
+
75
+ def install_command() -> str:
76
+ """The upgrade command for whichever installer put this here.
77
+
78
+ Detected from the path of the running interpreter rather than asked
79
+ for, because the common failure is not knowing which manager was used
80
+ — and the answer is sitting in sys.executable.
81
+ """
82
+ executable = Path(sys.executable).resolve()
83
+ parts = {p.lower() for p in executable.parts}
84
+
85
+ if "uv" in parts or "uv-tool" in parts or os.environ.get("UV_TOOL_DIR"):
86
+ return "uv tool upgrade abrade-cli"
87
+ if "pipx" in parts:
88
+ return "pipx upgrade abrade-cli"
89
+ if any("virtualenvs" in p or ".venv" in p for p in parts):
90
+ # Inside a project environment, so upgrading globally would touch a
91
+ # different copy than the one being run.
92
+ return "uv pip install --upgrade abrade-cli"
93
+ return "pip install --upgrade abrade-cli"
94
+
95
+
96
+ def check(installed: str, *, timeout: float = 10.0) -> Release:
97
+ return Release(installed=installed, latest=latest_version(timeout=timeout))
@@ -0,0 +1,248 @@
1
+ """Pre-flight checks for everything a run needs, before it needs it.
2
+
3
+ A run fires a whole suite of payloads at a live agent and judges each one —
4
+ two LLM calls per injection, against two different credentials. Every one
5
+ of the things that can be missing is discovered *during* that, when the
6
+ cost has already been paid and a partly-finished run has to be explained.
7
+
8
+ The checks are ordered cheapest-first and none of them spend a token by
9
+ default. The endpoint probe is opt-in for exactly that reason: confirming
10
+ an endpoint answers means sending it a real request, and a pre-flight check
11
+ that quietly bills you is a worse trade than one that says "not checked".
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import os
17
+ from dataclasses import dataclass
18
+ from enum import StrEnum
19
+ from pathlib import Path
20
+ from urllib.parse import urlparse
21
+
22
+
23
+ class Level(StrEnum):
24
+ OK = "ok"
25
+ WARN = "warn"
26
+ FAIL = "fail"
27
+
28
+
29
+ @dataclass(frozen=True)
30
+ class Check:
31
+ name: str
32
+ level: Level
33
+ detail: str
34
+
35
+ @property
36
+ def blocking(self) -> bool:
37
+ return self.level is Level.FAIL
38
+
39
+
40
+ def _config_checks(config_path: Path) -> tuple[list[Check], object | None]:
41
+ from abrade_cli import config as config_module
42
+
43
+ try:
44
+ config = config_module.load_config(config_path)
45
+ except FileNotFoundError as exc:
46
+ return [Check("agent config", Level.FAIL, str(exc))], None
47
+ except Exception as exc: # noqa: BLE001 — yaml and pydantic both land here
48
+ # Pydantic's own message names the offending field, which is more
49
+ # useful than anything paraphrasing it would be.
50
+ return [Check("agent config", Level.FAIL, f"{config_path}: {exc}")], None
51
+
52
+ checks = [
53
+ Check("agent config", Level.OK, f"{config_path} parsed — agent {config.name!r}")
54
+ ]
55
+
56
+ # A default system prompt means the scaffold was never edited, so the
57
+ # run would measure the template rather than the customer's agent. Not
58
+ # fatal — someone may genuinely be trying the tool out — but reporting
59
+ # it as fine would make the resulting verdicts look meaningful.
60
+ if "Replace this with your actual agent" in config.system_prompt:
61
+ checks.append(
62
+ Check(
63
+ "system prompt",
64
+ Level.WARN,
65
+ "still the scaffold's placeholder — the run would test the "
66
+ "template, not your agent",
67
+ )
68
+ )
69
+ else:
70
+ checks.append(Check("system prompt", Level.OK, "customised"))
71
+
72
+ return checks, config
73
+
74
+
75
+ def _credential_checks(config) -> list[Check]: # noqa: ANN001
76
+ checks: list[Check] = []
77
+
78
+ aut_env = config.model_key_env
79
+ if os.environ.get(aut_env, "").strip():
80
+ checks.append(
81
+ Check(f"${aut_env}", Level.OK, "set (agent-under-test credential)")
82
+ )
83
+ else:
84
+ checks.append(
85
+ Check(
86
+ f"${aut_env}",
87
+ Level.FAIL,
88
+ "unset — this is the credential the run authenticates to your "
89
+ "agent with. Every injection would fail before reaching it.",
90
+ )
91
+ )
92
+
93
+ judge_env = "ABRADE_JUDGE_API_KEY"
94
+ if os.environ.get(judge_env, "").strip():
95
+ checks.append(Check(f"${judge_env}", Level.OK, "set (judge credential)"))
96
+ else:
97
+ checks.append(
98
+ Check(
99
+ f"${judge_env}",
100
+ Level.FAIL,
101
+ "unset — every result would score ERROR. A scan that cannot "
102
+ "reach the judge is not a passing scan.",
103
+ )
104
+ )
105
+
106
+ # The one configuration mistake that is silently expensive rather than
107
+ # loudly broken: the same key on both sides still runs, still scores,
108
+ # and quietly means a payload that talks the agent into disclosing its
109
+ # credential has disclosed the judge's.
110
+ aut_value = os.environ.get(aut_env, "").strip()
111
+ judge_value = os.environ.get(judge_env, "").strip()
112
+ if aut_value and judge_value and aut_value == judge_value:
113
+ checks.append(
114
+ Check(
115
+ "credential separation",
116
+ Level.FAIL,
117
+ f"${aut_env} and ${judge_env} are the SAME value. The agent "
118
+ f"under test is being handed the judge's credential, so any "
119
+ f"payload that succeeds in extracting it has extracted the "
120
+ f"one key that scores your results.",
121
+ )
122
+ )
123
+ elif aut_value and judge_value:
124
+ checks.append(Check("credential separation", Level.OK, "two distinct keys"))
125
+
126
+ return checks
127
+
128
+
129
+ def _endpoint_checks(config, *, probe: bool) -> list[Check]: # noqa: ANN001
130
+ endpoint = config.model_endpoint
131
+ parsed = urlparse(endpoint)
132
+
133
+ if parsed.scheme not in ("http", "https") or not parsed.netloc:
134
+ return [
135
+ Check(
136
+ "model endpoint",
137
+ Level.FAIL,
138
+ f"{endpoint!r} is not a valid http(s) URL",
139
+ )
140
+ ]
141
+
142
+ checks = [Check("model endpoint", Level.OK, endpoint)]
143
+
144
+ # Plaintext to anywhere but the local machine puts the agent's
145
+ # credential on the wire in clear. Localhost is the lab case and is
146
+ # fine, so it is not worth a warning that trains people to ignore
147
+ # warnings.
148
+ host = parsed.hostname or ""
149
+ if parsed.scheme == "http" and host not in ("localhost", "127.0.0.1", "::1"):
150
+ checks.append(
151
+ Check(
152
+ "endpoint transport",
153
+ Level.WARN,
154
+ f"plaintext http to {host} — the agent credential would cross "
155
+ f"the network unencrypted",
156
+ )
157
+ )
158
+
159
+ if not probe:
160
+ checks.append(
161
+ Check(
162
+ "endpoint reachable",
163
+ Level.WARN,
164
+ "not checked — pass --probe to send one real request",
165
+ )
166
+ )
167
+ return checks
168
+
169
+ checks.append(_probe(config))
170
+ return checks
171
+
172
+
173
+ def _probe(config) -> Check: # noqa: ANN001
174
+ """One real Messages call, minimal tokens.
175
+
176
+ Deliberately the same client the run uses rather than a bare socket
177
+ connect: what matters is not whether the port is open but whether the
178
+ credential is accepted and the endpoint speaks the wire format. A TCP
179
+ check would pass against something that 401s every request.
180
+ """
181
+ from anthropic import Anthropic, APIStatusError
182
+
183
+ key = os.environ.get(config.model_key_env, "").strip()
184
+ if not key:
185
+ return Check(
186
+ "endpoint reachable", Level.FAIL, "cannot probe without a credential"
187
+ )
188
+
189
+ try:
190
+ client = Anthropic(api_key=key, base_url=config.model_endpoint, timeout=30.0)
191
+ client.messages.create(
192
+ model=config.model,
193
+ max_tokens=1,
194
+ messages=[{"role": "user", "content": "ping"}],
195
+ )
196
+ except APIStatusError as exc:
197
+ return Check(
198
+ "endpoint reachable",
199
+ Level.FAIL,
200
+ f"HTTP {exc.status_code} from {config.model_endpoint} — "
201
+ + (
202
+ "credential rejected"
203
+ if exc.status_code in (401, 403)
204
+ else "endpoint error"
205
+ ),
206
+ )
207
+ except Exception as exc: # noqa: BLE001 — connection, timeout, protocol
208
+ return Check(
209
+ "endpoint reachable",
210
+ Level.FAIL,
211
+ f"{type(exc).__name__} contacting {config.model_endpoint}: {exc}",
212
+ )
213
+ return Check("endpoint reachable", Level.OK, "answered a live request")
214
+
215
+
216
+ def _corpus_checks(corpus_path: Path | None) -> list[Check]:
217
+ from abrade_cli import corpus as corpus_module
218
+
219
+ try:
220
+ injections = corpus_module.load_corpus(corpus_path)
221
+ except Exception as exc: # noqa: BLE001
222
+ return [Check("corpus", Level.FAIL, f"could not load: {exc}")]
223
+
224
+ if not injections:
225
+ return [
226
+ Check("corpus", Level.FAIL, "loaded but empty — nothing would be fired")
227
+ ]
228
+ return [Check("corpus", Level.OK, f"{len(injections)} injection(s) available")]
229
+
230
+
231
+ def run_checks(
232
+ *,
233
+ config_path: Path,
234
+ corpus_path: Path | None = None,
235
+ probe: bool = False,
236
+ ) -> list[Check]:
237
+ """Every check, in cheapest-first order. Never raises — a failed check
238
+ is data, and the caller decides the exit code."""
239
+ checks, config = _config_checks(config_path)
240
+ if config is None:
241
+ # Everything downstream needs a parsed config; reporting ten
242
+ # cascading failures from one missing file is noise.
243
+ return checks
244
+
245
+ checks.extend(_credential_checks(config))
246
+ checks.extend(_endpoint_checks(config, probe=probe))
247
+ checks.extend(_corpus_checks(corpus_path))
248
+ return checks
File without changes