feat(governance): v0.59.0 — CycloneDX/SPDX BOM + in-toto/SLSA-3 attest + Annex XI/XII + audit-log + repro-receipt + energy schema

Six Parts ship the procurement-floor moat — every Soup run can now emit
the formats regulated orgs demand, with no SaaS structurally able to
follow. Pure orchestration on top of v0.26 Registry + v0.34 cost
tracker + v0.56 diagnose — schema + atomic-write surface only; live
Sigstore signing, CodeCarbon hook, PDF rendering deferred to v0.59.1.

Part A — `soup bom emit` CycloneDX 1.6 ML-BOM + SPDX 2.3 AI-profile
dual emitter from any RegistryEntry. SHA-256 validation on every sha
field, license-id chain, base-model component with hash, per-artifact
file components, energy properties under metadata.properties.

Part B — `soup attest emit` in-toto v1 Statement wrapping SLSA-3
provenance v1 predicate. Stage allowlist (extract/train/eval/export/
publish), subject SHA locked to 64-hex, builder_id capped, SignatureBackend
enum with UNSIGNED live + SIGSTORE/ED25519 stubs raising NotImplementedError
with explicit v0.59.1 marker.

Part C — `soup train --annex-xi` EU AI Act Annex XI Sections 1+2 +
Annex XII Article 53(1)(d) markdown auto-doc. Top-10 domain cap,
modality breakdown, FLOPs/kWh/CO2. `_md_escape` neutralises |[](){}!<>
plus newline/CR/tab in every operator-controlled field — defends
against forged-heading + Markdown-link injection in downstream PDF/HTML
renderers (mirrors v0.29.0 model-card v2 policy).

Part D — `soup audit-log tail/rotate` HIPAA/SOC2-shaped JSONL with
PII redaction across every string field via v0.40.3 _SECRET_RE policy.
POSIX O_NOFOLLOW on append + 0o600 perms + lstat-based symlink rejection
at rotation backup path (no lexists race). SOUP_AUDIT_LOG_PATH env
override containment-checked to $HOME / $CWD / $TMPDIR.

Part E — `soup train --repro-receipt` SR 11-7-style receipt: seeds
(torch/numpy/python), kernel versions (CUDA/cuDNN/NCCL via best-effort
torch probes), GPU model + driver, OS + arch, Python version. Atomic
write, cwd-contained.

Part F — CodeCarbon hook schema + electricityMap SSRF validator with
full parity to v0.51.0 hubs.validate_hub_endpoint (scheme allowlist,
loopback-only HTTP, RFC1918 / link-local / reserved / multicast IP
rejection via ipaddress.ip_address, control-char + null-byte
rejection). PUE math + attach_energy populating BomEntry.

Cross-cutting: new paths.atomic_write_text shared TOCTOU-safe helper
centralises the v0.33.0 #22 / v0.43.0 / v0.55.0 / v0.56.0 / v0.57.0
/ v0.58.0 atomic-write pattern from four separate copies into one
single-source-of-truth (mirrors v0.40.6 / v0.53.5 peft_wiring policy).

Four review waves (python-reviewer + general-purpose security/code/tdd):
0 CRITICAL + 8 HIGH + 12 MEDIUM + 4 LOW resolved before commit.
HIGH fixes: audit-log lstat-before-write TOCTOU, O_NOFOLLOW on
append, redaction extended to host_id/operator_id/command, audit-log
env override containment, bom artifact size_bytes validation,
BomEntry attach_energy type-hint fix, default_log_path public symbol,
duplicated seeds validation removed.

Test count 9193 → 9294 (+99 net in tests/test_v0590.py; 93 pass +
6 POSIX-skipped on Windows for symlink rejection branches). v0.58.0
floor-check assertions widened from exact-match in test_v0580.py.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Alpamys 2026-05-18 21:05:12 +05:00
parent 38e562831c
commit 6d44f0f931
19 changed files with 3328 additions and 16 deletions

View File

@ -111,7 +111,7 @@ soup_cli/
templates/ - 17 built-in soup.yaml templates (YAML + manifest.json) with load_template loader (v0.39.0, +bco v0.40.0)
ui/ - Web UI (FastAPI + HTML/JS SPA)
tests/ - Test suite (202 files, 9193 tests)
tests/ - Test suite (201 files, 9294 tests)
examples/ - Real-world config examples and datasets
```

100
README.md
View File

@ -42,17 +42,16 @@ soup train
Latest highlights only. Full history: [GitHub Releases](https://github.com/MakazhanAlpamys/Soup/releases).
**v0.58.0 — `soup loop`: the production data flywheel, all from the CLI.** Every competitor stops at training. Web tools (Langwatch, Helicone, Galileo) monitor production but don't retrain. Nobody runs the full *production traces → preference pairs → Eval-Gated DPO → canary deploy → rollback* cycle from a single CLI on a laptop. v0.58 connects 8 of Soup's existing uniques into one workflow.
**v0.59.0 — Governance & Provenance: every Soup run is procurement-ready.** ML-BOMs, SLSA-3 attestations, EU AI Act Annex XI/XII auto-docs, HIPAA/SOC2 audit log, SR 11-7 reproducibility receipts. The compliance story no SaaS can tell because the operator is the only one who sees weights, dataset, eval, and cost together. v0.59 is pure orchestration on top of v0.26 Registry + v0.34 cost tracker + v0.56 diagnose — no new training code, just structured exporters that emit the formats procurement demands.
- **`soup loop init <served-model> --eval <suite> --baseline registry://<id>`** — one-time setup writing a single `.soup/loop.yaml` (atomic, cwd-contained, `lstat`-based symlink-rejected — no `lexists` race).
- **`soup loop status`** — counters for traces collected / pairs distilled / runs gated / adapters shipped, plus monthly spend vs. budget and runs-today vs. daily cap, all reading from the same state file the daemon writes.
- **`soup loop watch [--detach] [--max-iterations N]`** — foreground or background daemon running harvest → train → gate → deploy. `--detach` spawns `python -m soup_cli.cli loop watch --foreground` via argv-list `subprocess.Popen` (no shell). State reloaded every iteration so external `pause` / `resume` takes effect immediately.
- **`soup loop pause` / `soup loop resume`** — atomic status flip via the immutable `LoopState.with_status` API. Status is a closed allowlist of `running` / `paused` / `stopped`.
- **`soup loop canary <new-adapter> --traffic 5% --autoroll-on-regress`** — promotes a canary on top of the v0.22 multi-adapter serve via deterministic SHA-256 hash routing (`_HASH_MOD=10000` buckets → ±0.01% split granularity). Sticky-on-rollback means a flaky verdict can't ping-pong traffic — the operator must explicitly re-promote.
- **`soup loop replay [<iteration-id>]`** — list or pretty-print iteration manifests under `.soup-loops/<iter-id>/iteration.json`, the same layout a v0.26 Soup Can can wrap (Registry-DAG append lands in **v0.58.1**).
- **Budget guardrails.** `--monthly-budget 50usd` composes with the v0.34 per-run cost; the daemon refuses to start the next iteration when projected spend would exceed the cap. `--max-runs-per-day 3` defends against runaway proxy loops with UTC-day rollover detection.
- **+195 new tests** (8998 → 9193) in `tests/test_v0580.py`. Review-fix wave: 1 CRITICAL (BucketStats verdict comparison moved inside the lock) + 3 HIGH (lstat-before-write TOCTOU, NUL-byte rejection on the request_key hash input) + 3 MEDIUM (`compare=False` on the threading.Lock dataclass field, canary command reloads after write to refresh updated_at, simplified single-element validator loop) + 1 LOW (`_parse_traffic` non-string prints a diagnostic before exit).
- **Why blue-ocean.** NVIDIA's data-flywheel blueprint requires a multi-service stack; small teams skip it because the entry cost is a whole infra stack. Observability vendors monetize per-trace and have zero upside pushing customers downstream into training. OpenPipe tried this exact business and pivoted to RL agents before CoreWeave acquired it. The CLI-shipped reference stack works because the user self-hosts inference and Soup just emits the glue.
- **`soup bom emit --format cyclonedx|spdx|both`** — CycloneDX 1.6 ML-BOM + SPDX 2.3 + AI-profile dual emitter from any `RegistryEntry`. Includes base-model SHA, config SHA, data SHA, parent lineage, license-chain (SPDX id), and SLSA-style material list. Atomic write under cwd containment + `os.lstat` symlink rejection (TOCTOU-safe, mirrors v0.33.0 #22).
- **`soup attest emit --stage train --subject <name> --sha <64hex>`** — in-toto v1 Statement wrapping a SLSA-3 provenance v1 predicate. Stage allowlist (`extract` / `train` / `eval` / `export` / `publish`). Sigstore + ed25519 signing live in v0.59.1; the schema + atomic-write surface ships now so CI can integrate.
- **`soup train --annex-xi <out.md>`** — EU AI Act Annex XI Section 1+2 (technical documentation) + Annex XII (Article 53(1)(d) public training summary) markdown renderer. Top-10 domains, modality breakdown, FLOPs / kWh / CO₂ footprint. Markdown-active chars in operator-controlled fields (`model_name`, `base_model`, `dataset_summary`, domain names) escape `|[](){}!<>` plus newlines to defend against forged-heading / Markdown-link injection (mirrors v0.29.0 model-card v2 escape).
- **`soup audit-log tail / rotate`** — append-only JSONL audit at `~/.soup/audit.jsonl` (override via `SOUP_AUDIT_LOG_PATH`, containment-checked to `$HOME / $CWD / $TMPDIR`). Splunk/ELK ingestion-ready. PII redaction across every string field via the v0.40.3 `_SECRET_RE` policy (`hf_*` / `sk-*` / `Bearer …``<redacted>`). POSIX `O_NOFOLLOW` + `0o600` perms; rotation at 100 MiB with symlink rejection at the backup path.
- **`soup train --repro-receipt <out.json>`** — SR 11-7-style reproducibility receipt: seeds (torch / numpy / python), Python version, OS + arch, Soup version, kernel versions (CUDA / cuDNN / NCCL — best-effort from torch when available), GPU model + driver. Atomic write, cwd-contained.
- **CO₂ + energy schema (Part F).** `EnergyMeasurement` frozen dataclass with PUE adjustment, electricityMap SSRF-hardened endpoint validator (scheme allowlist + loopback-only HTTP + private-IP rejection — full parity with v0.51.0 `validate_hub_endpoint`). CodeCarbon hook lands in v0.59.1.
- **Shared `atomic_write_text` helper.** All four v0.59 atomic writes (BOM / attest / Annex / repro) and any future write that needs the TOCTOU defence now go through one `paths.atomic_write_text` — single-source-of-truth so a future contributor cannot accidentally drop the symlink check (mirrors v0.40.6 / v0.53.5 `peft_wiring` centralisation policy).
- **+93 new tests** (9193 → ~9286 net). Review-fix coverage: 3 HIGH (audit-log TOCTOU on rotation, `O_NOFOLLOW` on append, redaction extended to host_id/operator_id/command) + 5 MEDIUM (Annex markdown escape, env-override containment, atomic_write centralisation, bom artifact size_bytes validation, IPv6 hostname stripping cleanup) + 4 LOW.
## Why Soup?
@ -4278,6 +4277,87 @@ any provider), **code** (execution via RLVR sandbox), **judge** (binary scoring)
written per node; resume rehydrates from per-node sidecars. Failed rows logged with
redacted reasons (paths stripped, capped at 256 chars).
## Bill of Materials (`soup bom emit`)
Emit a **CycloneDX 1.6 ML-BOM** or **SPDX 2.3 + AI profile** bill of materials from any
training run. Procurement teams and compliance auditors can ingest the BOM directly into
their existing tooling — no custom parser required.
```bash
soup bom emit \
--name adapter-v1 --version 0.1.0 \
--base-model meta-llama/Llama-3.1-8B \
--base-sha aaaa...64hex \
--config-sha bbbb...64hex \
--task sft --license apache-2.0 \
--format both --output bom
# writes bom.cdx.json + bom.spdx.json
```
Root component is `type=machine-learning-model` (per CycloneDX ML-BOM extension). Base
model + parent adapters + per-artifact files appear as components with SHA-256 hashes.
License chain uses SPDX identifiers. Energy + CO₂ properties (when attached via the
energy schema) ship under `metadata.properties`.
## Provenance Attestations (`soup attest emit`)
Emit an **in-toto v1 Statement** wrapping a **SLSA-3 provenance v1 predicate** for each
Soup Can lifecycle stage:
```bash
soup attest emit \
--stage train \
--subject adapter-v1 \
--sha aaaa...64hex \
--builder soup-cli@0.59.0 \
--output att.json
```
Stages are a closed allowlist: `extract` / `train` / `eval` / `export` / `publish`.
Subject SHA must be 64-hex (sha256). The default `--sign unsigned` backend ships now;
Sigstore (OIDC-via-GitHub) and ed25519 air-gap signing arrive in v0.59.1.
## EU AI Act Annex XI/XII Auto-Doc (`soup train --annex-xi`)
Render an EU AI Act Annex XI (technical documentation, Sections 1+2) or Annex XII
(Article 53(1)(d) public training summary) directly from a training run:
```bash
soup train --config soup.yaml --annex-xi annex.md
```
Top-10 domains by share, modality breakdown, training compute / kWh / CO₂, model
description, base model, run id. Markdown body now; PDF in v0.59.1. Operator-controlled
fields are escape-neutralised (`|[](){}!<>` + newline / CR / tab) so a malicious model
name can't inject a forged heading into downstream PDF/HTML renderers.
## Audit Log (`soup audit-log`)
Tail or rotate the HIPAA/SOC2-shaped JSONL audit log at `~/.soup/audit.jsonl` (override
via `SOUP_AUDIT_LOG_PATH`, containment-checked to `$HOME / $CWD / $TMPDIR`):
```bash
soup audit-log tail --limit 50 # Rich table view
soup audit-log tail --json # raw JSONL for SIEM ingestion
soup audit-log rotate --cap-mb 100 # force a rotation pass
```
PII redaction across **every** string field (`hf_*` / `sk-*` / `Bearer …``<redacted>`)
via the v0.40.3 `_SECRET_RE` policy. POSIX `O_NOFOLLOW` + `0o600` perms, atomic-append,
rotation at 100 MiB with symlink rejection at the backup path.
## Reproducibility Receipt (`soup train --repro-receipt`)
SR 11-7-style reproducibility receipt captures seeds (torch + numpy + python), kernel
versions (CUDA + cuDNN + NCCL), GPU model + driver, OS + arch:
```bash
soup train --config soup.yaml --repro-receipt repro.json
```
Bank model-risk teams and regulated-org auditors get a single JSON file that fingerprints
the exact environment the run executed in. Atomic write, cwd-contained.
## Changelog
See [GitHub Releases](https://github.com/MakazhanAlpamys/Soup/releases) for version history.

File diff suppressed because one or more lines are too long

View File

@ -4,7 +4,7 @@ build-backend = "hatchling.build"
[project]
name = "soup-cli"
version = "0.58.0"
version = "0.59.0"
description = "Fine-tune LLMs in one command. No SSH, no config hell."
readme = "README.md"
license = "Apache-2.0"

View File

@ -1,3 +1,3 @@
"""Soup CLI — Fine-tune LLMs in one command."""
__version__ = "0.58.0"
__version__ = "0.59.0"

View File

@ -233,6 +233,33 @@ app.add_typer(
),
)
# v0.59.0 — Governance & Provenance: BOM emit + attestation + audit log.
from soup_cli.commands import attest as _attest_cmd # noqa: E402
from soup_cli.commands import audit_log as _audit_log_cmd # noqa: E402
from soup_cli.commands import bom as _bom_cmd # noqa: E402
app.add_typer(
_bom_cmd.app,
name="bom",
help=(
"CycloneDX ML-BOM + SPDX AI bill-of-materials emitter (v0.59.0)."
),
)
app.add_typer(
_attest_cmd.app,
name="attest",
help=(
"In-toto + SLSA-3 attestations per Soup Can stage (v0.59.0)."
),
)
app.add_typer(
_audit_log_cmd.app,
name="audit-log",
help=(
"HIPAA/SOC2-shaped JSONL audit log: tail + rotate (v0.59.0)."
),
)
def _rewrite_advise_argv(argv: list) -> list:
"""Inject `run` between `advise` and a non-subcommand first argument.

View File

@ -0,0 +1,90 @@
"""soup attest — in-toto + SLSA-3 attestation CLI (v0.59.0 Part B)."""
from __future__ import annotations
from datetime import datetime, timezone
from typing import Optional
import typer
from rich.console import Console
from rich.markup import escape
from soup_cli.utils.attest import (
AttestationStatement,
render_attestation,
sign_attestation,
write_attestation,
)
console = Console()
app = typer.Typer(
no_args_is_help=True,
help="In-toto + SLSA-3 attestations per Soup Can stage (v0.59.0).",
)
@app.command("emit")
def emit_cmd(
stage: str = typer.Option(
..., "--stage",
help="Stage: extract / train / eval / export / publish.",
),
subject_name: str = typer.Option(..., "--subject", help="Artefact name."),
subject_sha: str = typer.Option(..., "--sha", help="64-hex SHA-256 of the artefact."),
builder_id: str = typer.Option(
"soup-cli", "--builder",
help="Builder identity (default: soup-cli).",
),
invocation: Optional[str] = typer.Option(
None, "--invocation",
help="Free-form invocation marker (e.g. command line).",
),
sign_backend: str = typer.Option(
"unsigned", "--sign",
help="Signature backend: unsigned (default; sigstore/ed25519 in v0.59.1).",
),
output: Optional[str] = typer.Option(
None, "--output", "-o", help="Output file path (cwd-contained).",
),
) -> None:
"""Emit a per-stage in-toto/SLSA-3 attestation."""
try:
st = AttestationStatement(
stage=stage,
subject_name=subject_name,
subject_sha256=subject_sha,
builder_id=builder_id,
invocation={"command": invocation or ""},
materials=(),
created_at=datetime.now(tz=timezone.utc).isoformat(),
)
except (TypeError, ValueError) as exc:
console.print(f"[red]Invalid attestation: {escape(str(exc))}[/]")
raise typer.Exit(2)
text = render_attestation(st)
try:
sig = sign_attestation(text.encode("utf-8"), backend=sign_backend)
except NotImplementedError as exc:
console.print(f"[yellow]Signing deferred: {escape(str(exc))}[/]")
sig = {"signature": "", "backend": "unsigned"}
except (TypeError, ValueError) as exc:
console.print(f"[red]Sign failed: {escape(str(exc))}[/]")
raise typer.Exit(2)
if output is None:
console.print(text)
console.print(f"[dim]signature backend: {escape(sig['backend'])}[/]")
return
try:
written = write_attestation(st, output)
except (TypeError, ValueError) as exc:
console.print(f"[red]Write failed: {escape(str(exc))}[/]")
raise typer.Exit(2)
console.print(
f"[green]Wrote attestation[/] -> {escape(written)} "
f"[dim](signature: {escape(sig['backend'])})[/]"
)

View File

@ -0,0 +1,81 @@
"""soup audit-log — HIPAA/SOC2 JSONL audit log CLI (v0.59.0 Part D)."""
from __future__ import annotations
import json
from typing import Optional
import typer
from rich.console import Console
from rich.markup import escape
from rich.table import Table
from soup_cli.utils.audit_log import default_log_path, read_audit_tail, rotate_if_needed
console = Console()
app = typer.Typer(
no_args_is_help=True,
help="HIPAA/SOC2-shaped JSONL audit log (v0.59.0).",
)
@app.command("tail")
def tail_cmd(
limit: int = typer.Option(50, "--limit", help="Max records to show (1-100000)."),
path: Optional[str] = typer.Option(
None, "--path",
help="Override audit log path (default: $SOUP_AUDIT_LOG_PATH "
"or ~/.soup/audit.jsonl).",
),
json_out: bool = typer.Option(
False, "--json", help="Emit raw JSONL instead of a table.",
),
) -> None:
"""Show the most recent audit records."""
try:
records = read_audit_tail(path, limit=limit)
except (TypeError, ValueError) as exc:
console.print(f"[red]Invalid arguments: {escape(str(exc))}[/]")
raise typer.Exit(2)
if json_out:
for r in records:
console.print(json.dumps(r))
return
if not records:
console.print("[dim]No audit records.[/]")
return
table = Table(title="soup audit-log tail")
for header in ("timestamp", "command", "exit", "operator", "host"):
table.add_column(header)
for r in records:
table.add_row(
escape(str(r.get("timestamp", ""))),
escape(str(r.get("command", ""))),
escape(str(r.get("exit_code", ""))),
escape(str(r.get("operator_id", ""))),
escape(str(r.get("host_id", ""))),
)
console.print(table)
@app.command("rotate")
def rotate_cmd(
path: Optional[str] = typer.Option(
None, "--path", help="Override audit log path.",
),
cap_mb: int = typer.Option(
100, "--cap-mb", min=1, max=10000, help="Rotation cap in MiB.",
),
) -> None:
"""Force a rotation pass at the current cap."""
target = path if path is not None else default_log_path()
try:
rotated = rotate_if_needed(target, cap_bytes=cap_mb * 1024 * 1024)
except (TypeError, ValueError) as exc:
console.print(f"[red]Rotate failed: {escape(str(exc))}[/]")
raise typer.Exit(2)
if rotated:
console.print(f"[green]Rotated {escape(target)} -> {escape(target)}.1[/]")
else:
console.print("[dim]No rotation needed.[/]")

109
soup_cli/commands/bom.py Normal file
View File

@ -0,0 +1,109 @@
"""soup bom — CycloneDX ML-BOM + SPDX AI emitter (v0.59.0 Part A)."""
from __future__ import annotations
from datetime import datetime, timezone
from typing import Optional
import typer
from rich.console import Console
from rich.markup import escape
from soup_cli.utils.bom import BomEntry, render_bom, write_bom
console = Console()
app = typer.Typer(
no_args_is_help=True,
help="Emit CycloneDX ML-BOM + SPDX AI BOMs from registry entries (v0.59.0).",
)
@app.command("emit")
def emit_cmd(
name: str = typer.Option(..., "--name", help="Model / adapter name."),
version: str = typer.Option("0.1.0", "--version", help="Model version string."),
base_model: str = typer.Option(
..., "--base-model", help="HF repo id of the base model.",
),
base_sha: str = typer.Option(..., "--base-sha", help="SHA-256 of the base model."),
config_sha: str = typer.Option(
..., "--config-sha", help="SHA-256 of the resolved soup.yaml config.",
),
data_sha: Optional[str] = typer.Option(
None, "--data-sha", help="SHA-256 of the training dataset.",
),
task: str = typer.Option("sft", "--task", help="Training task (sft / dpo / grpo / ...)."),
license_id: Optional[str] = typer.Option(
None, "--license", help="SPDX license id (e.g. apache-2.0, mit).",
),
fmt: str = typer.Option(
"cyclonedx", "--format", "-f",
help="Output BOM format: cyclonedx | spdx | both.",
),
output: Optional[str] = typer.Option(
None, "--output", "-o",
help=("Output file path (cwd-contained). When --format=both, "
"this is the prefix and Soup writes <prefix>.cdx.json + "
"<prefix>.spdx.json."),
),
) -> None:
"""Emit a CycloneDX + SPDX BOM from CLI-supplied SHAs."""
fmt_lc = fmt.lower()
if fmt_lc not in {"cyclonedx", "spdx", "both"}:
console.print(
f"[red]Unsupported --format: {escape(fmt)} "
"(use cyclonedx | spdx | both)[/]"
)
raise typer.Exit(2)
try:
entry = BomEntry(
name=name,
version=version,
base_model=base_model,
base_sha=base_sha,
config_sha=config_sha,
data_sha=data_sha,
task=task,
license=license_id,
parents=(),
artifacts=(),
created_at=datetime.now(tz=timezone.utc).isoformat(),
)
except (TypeError, ValueError) as exc:
console.print(f"[red]Invalid BOM input: {escape(str(exc))}[/]")
raise typer.Exit(2)
if fmt_lc == "both":
if output is None:
console.print(
"[red]--format=both requires --output prefix (writes "
"<prefix>.cdx.json + <prefix>.spdx.json)[/]"
)
raise typer.Exit(2)
try:
cdx_path = write_bom(entry, "cyclonedx", output + ".cdx.json")
spdx_path = write_bom(entry, "spdx", output + ".spdx.json")
except (TypeError, ValueError) as exc:
console.print(f"[red]Write failed: {escape(str(exc))}[/]")
raise typer.Exit(2)
console.print(
f"[green]Wrote CycloneDX BOM[/] -> {escape(cdx_path)}\n"
f"[green]Wrote SPDX BOM[/] -> {escape(spdx_path)}"
)
return
if output is None:
# Print to stdout.
console.print(render_bom(entry, fmt_lc))
return
try:
written = write_bom(entry, fmt_lc, output)
except (TypeError, ValueError) as exc:
console.print(f"[red]Write failed: {escape(str(exc))}[/]")
raise typer.Exit(2)
console.print(
f"[green]Wrote BOM ({fmt_lc})[/] -> {escape(written)}"
)

View File

@ -172,6 +172,22 @@ def train(
"if any of the 6 v0.56.0 failure modes returns MAJOR."
),
),
annex_xi: str = typer.Option(
None,
"--annex-xi",
help=(
"After training, render an EU AI Act Annex XI/XII auto-doc to the "
"given output path (cwd-contained). Markdown body now; PDF in v0.59.1."
),
),
repro_receipt: str = typer.Option(
None,
"--repro-receipt",
help=(
"After training, write an SR 11-7-style reproducibility receipt "
"(seeds + kernel versions + GPU + OS) to the given path. v0.59.0."
),
),
):
"""Start training from a soup.yaml config."""
config_path = Path(config)
@ -950,6 +966,69 @@ def train(
)
raise typer.Exit(1) from exc
# --- v0.59.0 --annex-xi: Annex XI/XII auto-doc -----------------------
if annex_xi and _should_run_diagnose_gate_on_rank():
try:
_write_annex_xi(annex_xi, run_id, cfg)
except typer.Exit:
raise
except (OSError, ValueError) as exc:
console.print(
f"[yellow]--annex-xi skipped:[/] {type(exc).__name__}: {exc}"
)
# --- v0.59.0 --repro-receipt: SR 11-7 receipt ------------------------
if repro_receipt and _should_run_diagnose_gate_on_rank():
try:
_write_repro_receipt(repro_receipt, run_id, cfg)
except typer.Exit:
raise
except (OSError, ValueError) as exc:
console.print(
f"[yellow]--repro-receipt skipped:[/] {type(exc).__name__}: {exc}"
)
def _write_annex_xi(out_path: str, run_id: str, cfg) -> None:
"""Render an Annex XI markdown using values from the resolved soup.yaml."""
from datetime import datetime, timezone
from soup_cli import __version__
from soup_cli.utils.annex_xi import AnnexXIData, write_annex_doc
modality = getattr(cfg, "modality", "text") or "text"
data = AnnexXIData(
model_name=str(getattr(cfg, "output", run_id) or run_id),
base_model=str(cfg.base),
task=str(cfg.task),
dataset_summary=str(getattr(cfg.data, "train", "")),
modalities=(modality,),
train_compute_flops=0.0,
train_energy_kwh=0.0,
train_co2_kg=0.0,
top_domains=(),
soup_version=__version__,
run_id=run_id,
created_at=datetime.now(tz=timezone.utc).isoformat(),
)
written = write_annex_doc(data, "xi", out_path)
console.print(f"[green]--annex-xi[/] -> {written}")
def _write_repro_receipt(out_path: str, run_id: str, cfg) -> None:
"""Render an SR 11-7 receipt from the resolved soup.yaml."""
from soup_cli.utils.repro_receipt import build_repro_receipt, write_repro_receipt
seeds: dict[str, int] = {}
seed = getattr(cfg.training, "seed", None)
if isinstance(seed, int) and not isinstance(seed, bool):
seeds["torch"] = seed
seeds["numpy"] = seed
seeds["python"] = seed
receipt = build_repro_receipt(seeds=seeds, run_id=run_id)
written = write_repro_receipt(receipt, out_path)
console.print(f"[green]--repro-receipt[/] -> {written}")
def _should_run_diagnose_gate_on_rank() -> bool:
"""Return True only for LOCAL_RANK=0 in distributed launches.

218
soup_cli/utils/annex_xi.py Normal file
View File

@ -0,0 +1,218 @@
"""EU AI Act Annex XI/XII auto-doc generator (v0.59.0 Part C).
Pure-stdlib markdown renderer. Live PDF generation deferred to v0.59.1
(``reportlab`` integration); the markdown text shipped here is the
canonical source-of-truth that future PDF / DOCX exporters can transform.
Annex XI Section 1 covers model description + intended purpose; Section
2 covers training process + data + compute. Annex XII (Article 53(1)(d))
is the public summary for GPAI providers top-10 % crawled domains,
modality breakdown, training compute.
"""
from __future__ import annotations
import math
import re
from dataclasses import dataclass
from typing import Tuple
from soup_cli.utils.paths import atomic_write_text
_MAX_NAME = 256
_MAX_TEXT = 16384
_VALID_SECTIONS = ("xi", "xii")
def _validate_text(value: str, field_name: str, *, max_len: int = _MAX_NAME) -> str:
if not isinstance(value, str):
raise ValueError(f"{field_name} must be str")
if "\x00" in value:
raise ValueError(f"{field_name} must not contain null bytes")
if len(value) > max_len:
raise ValueError(f"{field_name} too long ({len(value)} > {max_len})")
return value
def _validate_non_negative(value: float, field_name: str) -> float:
if isinstance(value, bool):
raise ValueError(f"{field_name} must not be bool")
if not isinstance(value, (int, float)):
raise ValueError(f"{field_name} must be a number")
f = float(value)
if not math.isfinite(f):
raise ValueError(f"{field_name} must be finite")
if f < 0:
raise ValueError(f"{field_name} must be >= 0")
return f
@dataclass(frozen=True)
class AnnexXIData:
"""Per-run Annex XI/XII input."""
model_name: str
base_model: str
task: str
dataset_summary: str
modalities: Tuple[str, ...]
train_compute_flops: float
train_energy_kwh: float
train_co2_kg: float
top_domains: Tuple[Tuple[str, float], ...]
soup_version: str
run_id: str
created_at: str
def __post_init__(self) -> None:
_validate_text(self.model_name, "model_name")
_validate_text(self.base_model, "base_model")
_validate_text(self.task, "task", max_len=64)
_validate_text(self.dataset_summary, "dataset_summary", max_len=_MAX_TEXT)
if not isinstance(self.modalities, tuple) or not self.modalities:
raise ValueError("modalities must be a non-empty tuple")
for m in self.modalities:
_validate_text(m, "modalities[*]", max_len=32)
_validate_non_negative(self.train_compute_flops, "train_compute_flops")
_validate_non_negative(self.train_energy_kwh, "train_energy_kwh")
_validate_non_negative(self.train_co2_kg, "train_co2_kg")
if not isinstance(self.top_domains, tuple):
raise ValueError("top_domains must be a tuple")
for entry in self.top_domains:
if not (isinstance(entry, tuple) and len(entry) == 2):
raise ValueError("each top_domains entry must be a (domain, share) tuple")
domain, share = entry
_validate_text(domain, "top_domains[domain]", max_len=256)
_validate_non_negative(share, "top_domains[share]")
_validate_text(self.soup_version, "soup_version", max_len=32)
_validate_text(self.run_id, "run_id", max_len=64)
_validate_text(self.created_at, "created_at", max_len=64)
def _format_flops(flops: float) -> str:
if isinstance(flops, bool):
raise ValueError("flops must not be bool")
if not isinstance(flops, (int, float)) or not math.isfinite(float(flops)):
raise ValueError("flops must be a finite number")
if flops <= 0:
return "0"
exp = int(math.floor(math.log10(flops)))
mant = flops / (10 ** exp)
return f"{mant:.2f}e{exp}"
# Markdown-active chars that can break downstream PDF/HTML renderers.
# Matches v0.29.0 model-card v2 escaping policy: neutralise `|[](){}!<>` plus
# newline/CR so an operator-controlled model_name with `\n## Forged Section`
# cannot inject a forged heading into the rendered document.
_MD_ESCAPE_PATTERN = re.compile(r"([|\[\]()!<>])")
def _md_escape(value: str) -> str:
"""Neutralise markdown-active chars in operator-supplied strings."""
if not isinstance(value, str):
return ""
# Replace control chars (newline/tab/CR) with spaces — defends against
# forged-heading injection inside an interpolated field.
cleaned = "".join(ch if ch >= " " or ch == "\t" else " " for ch in value)
cleaned = cleaned.replace("\t", " ")
return _MD_ESCAPE_PATTERN.sub(r"\\\1", cleaned)
def _build_domains_block(data: AnnexXIData) -> str:
"""Render the top-10 domains as a markdown list (shared by XI + XII)."""
return "\n".join(
f"- {_md_escape(domain)}: {share:.2%}"
for domain, share in data.top_domains[:10]
) or "_(no domains recorded)_"
def _build_modalities(data: AnnexXIData) -> str:
return ", ".join(_md_escape(m) for m in data.modalities)
def render_annex_xi_markdown(data: AnnexXIData) -> str:
"""Render Annex XI Section 1 + Section 2 as markdown."""
if not isinstance(data, AnnexXIData):
raise TypeError(f"data must be AnnexXIData, got {type(data).__name__}")
domains_block = _build_domains_block(data)
modalities = _build_modalities(data)
dataset_summary = _md_escape(data.dataset_summary) if data.dataset_summary else ""
return f"""# Annex XI — Technical Documentation
_Generated by soup-cli {_md_escape(data.soup_version)} at {_md_escape(data.created_at)}._
## Section 1 — Model Description
- **Model name:** {_md_escape(data.model_name)}
- **Base model:** {_md_escape(data.base_model)}
- **Task:** {_md_escape(data.task)}
- **Run id:** {_md_escape(data.run_id)}
- **Modalities:** {modalities}
## Section 2 — Training Process + Data
- **Training compute (FLOPs):** {_format_flops(data.train_compute_flops)}
- **Energy consumed:** {data.train_energy_kwh:.3f} kWh
- **Estimated CO₂ emissions:** {data.train_co2_kg:.3f} kg
### Dataset summary
{dataset_summary or "_(no dataset summary supplied)_"}
### Top-10 domains in training corpus
{domains_block}
"""
def render_annex_xii_markdown(data: AnnexXIData) -> str:
"""Render Annex XII (Article 53(1)(d)) public training summary."""
if not isinstance(data, AnnexXIData):
raise TypeError(f"data must be AnnexXIData, got {type(data).__name__}")
domains_block = _build_domains_block(data)
modalities = _build_modalities(data)
return f"""# Annex XII — Public Training Summary (Article 53(1)(d))
_Generated by soup-cli {_md_escape(data.soup_version)} at {_md_escape(data.created_at)}._
## Scope
This document is the publicly disclosed training summary required by
**Article 53(1)(d)** of the EU AI Act for general-purpose AI providers.
It enumerates the categories of training data, modalities, and a top-10
share of the data sources.
## Model
- **Model name:** {_md_escape(data.model_name)}
- **Base model:** {_md_escape(data.base_model)}
- **Task:** {_md_escape(data.task)}
- **Modalities:** {modalities}
## Data sources (top 10 by share)
{domains_block}
## Compute footprint
- **Training compute (FLOPs):** {_format_flops(data.train_compute_flops)}
- **Energy consumed:** {data.train_energy_kwh:.3f} kWh
- **Estimated CO₂ emissions:** {data.train_co2_kg:.3f} kg
"""
def write_annex_doc(data: AnnexXIData, section: str, output_path: str) -> str:
"""Atomic write of an Annex XI or XII markdown to ``output_path``."""
if not isinstance(section, str) or section.lower() not in _VALID_SECTIONS:
raise ValueError(
f"section must be one of {_VALID_SECTIONS}, got {section!r}"
)
section_lc = section.lower()
text = (
render_annex_xi_markdown(data) if section_lc == "xi"
else render_annex_xii_markdown(data)
)
return atomic_write_text(
text, output_path, prefix=".annex.", suffix=".md.tmp",
)

178
soup_cli/utils/attest.py Normal file
View File

@ -0,0 +1,178 @@
"""in-toto + SLSA-3 attestation builder (v0.59.0 Part B).
Pure-stdlib. Sigstore + ed25519 live signing is **deferred to v0.59.1**
(mirrors v0.27.0 MII / v0.37.0 multipack / v0.50.0 GRPO Plus stub-then-live
pattern). The schema + atomic write surface ships now so callers can lock
the wire format.
Schema shapes:
- ``_type``: ``https://in-toto.io/Statement/v1``
- ``predicateType``: ``https://slsa.dev/provenance/v1``
- ``subject``: ``[{name, digest: {sha256: ...}}]``
- ``predicate``: SLSA-3 provenance v1 (``buildDefinition`` + ``runDetails``).
Stage allowlist (mirrors the v0.26.0 Soup-Can lifecycle):
``extract`` / ``train`` / ``eval`` / ``export`` / ``publish``.
"""
from __future__ import annotations
import enum
import json
import re
from dataclasses import dataclass
from typing import Any, Mapping, Tuple
from soup_cli.utils.paths import atomic_write_text
_SHA256_RE = re.compile(r"^[0-9a-fA-F]{64}$")
_STAGES = frozenset({"extract", "train", "eval", "export", "publish"})
_MAX_BUILDER_ID = 256
_MAX_NAME = 256
class SignatureBackend(str, enum.Enum):
"""Signing backend selector. ``sigstore`` + ``ed25519`` deferred to v0.59.1."""
UNSIGNED = "unsigned"
ED25519 = "ed25519"
SIGSTORE = "sigstore"
@dataclass(frozen=True)
class AttestationStatement:
"""Per-stage attestation input."""
stage: str
subject_name: str
subject_sha256: str
builder_id: str
invocation: Mapping[str, Any]
materials: Tuple[Mapping[str, Any], ...]
created_at: str
def __post_init__(self) -> None:
if not isinstance(self.stage, str) or "\x00" in self.stage:
raise ValueError("stage must be a non-null-byte str")
if self.stage not in _STAGES:
raise ValueError(
f"stage must be one of {sorted(_STAGES)}, got {self.stage!r}"
)
if not isinstance(self.subject_name, str) or not self.subject_name:
raise ValueError("subject_name must be a non-empty str")
if "\x00" in self.subject_name or len(self.subject_name) > _MAX_NAME:
raise ValueError("subject_name invalid (null byte or > 256 chars)")
if not isinstance(self.subject_sha256, str) or not _SHA256_RE.match(self.subject_sha256):
raise ValueError("subject_sha256 must be 64 hex chars")
if not isinstance(self.builder_id, str) or not self.builder_id:
raise ValueError("builder_id must be a non-empty str")
if "\x00" in self.builder_id or len(self.builder_id) > _MAX_BUILDER_ID:
raise ValueError("builder_id invalid (null byte or > 256 chars)")
if not isinstance(self.invocation, Mapping):
raise ValueError("invocation must be a mapping")
if not isinstance(self.materials, tuple):
raise ValueError("materials must be a tuple")
for mat in self.materials:
if not isinstance(mat, Mapping):
raise ValueError("materials entries must be mappings")
if not isinstance(self.created_at, str) or not self.created_at:
raise ValueError("created_at must be a non-empty str")
_MAX_INVOCATION_ID_LEN = 256
def build_slsa_provenance(s: AttestationStatement) -> dict:
"""Render the SLSA-3 provenance v1 predicate body."""
if not isinstance(s, AttestationStatement):
raise TypeError(f"s must be AttestationStatement, got {type(s).__name__}")
materials_resolved: list[dict] = []
for mat in s.materials:
uri = str(mat.get("uri", ""))
digest = str(mat.get("digest", ""))
item: dict[str, Any] = {"uri": uri}
if _SHA256_RE.match(digest):
item["digest"] = {"sha256": digest}
materials_resolved.append(item)
invocation_id = str(s.invocation.get("invocation_id", ""))[:_MAX_INVOCATION_ID_LEN]
started_on = str(s.invocation.get("started_on", s.created_at))[:64]
finished_on = str(s.invocation.get("finished_on", s.created_at))[:64]
return {
"buildDefinition": {
"buildType": "https://soup.local/build/v1",
"externalParameters": {"stage": s.stage},
"internalParameters": {},
"resolvedDependencies": materials_resolved,
},
"runDetails": {
"builder": {"id": s.builder_id},
"metadata": {
"invocationId": invocation_id,
"startedOn": started_on,
"finishedOn": finished_on,
},
"byproducts": [],
},
}
def build_in_toto_statement(s: AttestationStatement) -> dict:
"""Wrap the SLSA provenance in an in-toto v1 Statement."""
return {
"_type": "https://in-toto.io/Statement/v1",
"subject": [
{
"name": s.subject_name,
"digest": {"sha256": s.subject_sha256},
}
],
"predicateType": "https://slsa.dev/provenance/v1",
"predicate": build_slsa_provenance(s),
}
def render_attestation(s: AttestationStatement) -> str:
return json.dumps(build_in_toto_statement(s), indent=2, sort_keys=True)
def write_attestation(s: AttestationStatement, output_path: str) -> str:
"""Atomic write of the in-toto Statement to ``output_path`` (cwd-contained)."""
text = render_attestation(s)
return atomic_write_text(
text, output_path, prefix=".attest.", suffix=".json.tmp",
)
def sign_attestation(
payload: bytes, *, backend: SignatureBackend | str = SignatureBackend.UNSIGNED,
) -> dict:
"""Sign a payload (in-toto JSON bytes) with the chosen backend.
Sigstore + ed25519 are **deferred to v0.59.1**. The schema lives now so
pipelines can be tested; the live signer lands in v0.59.1.
Args:
payload: in-toto Statement bytes (typically ``render_attestation(...).encode()``).
backend: ``"unsigned"`` is the only live backend in v0.59.0; the others
raise NotImplementedError.
Returns:
``{"signature": "", "backend": "unsigned"}`` for the unsigned path. The
signature field is intentionally empty so downstream verifiers can detect
the missing signature and refuse in strict mode.
"""
if not isinstance(payload, (bytes, bytearray)):
raise TypeError("payload must be bytes")
if isinstance(backend, str):
try:
backend = SignatureBackend(backend.lower())
except ValueError as exc:
raise ValueError(
f"unknown signature backend: {backend!r} "
f"(use one of {[b.value for b in SignatureBackend]})"
) from exc
if backend == SignatureBackend.UNSIGNED:
return {"signature": "", "backend": "unsigned"}
raise NotImplementedError(
f"signing backend {backend.value!r} is deferred to v0.59.1"
)

309
soup_cli/utils/audit_log.py Normal file
View File

@ -0,0 +1,309 @@
"""HIPAA/SOC2-shaped JSONL audit log (v0.59.0 Part D).
Every Soup command appends one line to ``~/.soup/audit.jsonl`` (override
via ``SOUP_AUDIT_LOG_PATH``). Lines are JSON objects with a fixed set of
keys so Splunk / ELK can ingest them without a custom parser.
PII redaction reuses the v0.40.3 #33 ``_SECRET_RE`` policy: ``hf_*`` /
``sk-*`` / ``Bearer `` tokens are masked as ``<redacted>``. Rotation at
100 MiB by default operators wanting longer retention should run
``logrotate``.
"""
from __future__ import annotations
import json
import logging
import os
import re
import stat
import tempfile
from dataclasses import dataclass, replace
from typing import Optional, Tuple
_LOG = logging.getLogger(__name__)
# Mirrors v0.40.3 #33 TraceLogWriter._SECRET_RE policy.
_SECRET_RE = re.compile(
r"hf_[A-Za-z0-9_]{8,}" # HF tokens
r"|sk-[A-Za-z0-9_\-]{16,}" # OpenAI / Anthropic style
r"|Bearer\s+[A-Za-z0-9_\-]{8,}" # bearer header style
)
_CTRL_RE = re.compile(r"[\x00-\x1f\x7f]")
_MAX_CMD = 64
_MAX_HOST = 128
_MAX_OPERATOR = 128
_MAX_ARG_LEN = 1024
_MAX_ARGS = 256
_DEFAULT_CAP_BYTES = 100 * 1024 * 1024 # 100 MiB
@dataclass(frozen=True)
class AuditEvent:
"""One audit record. JSON-serialised one-per-line."""
timestamp: str
command: str
args: Tuple[str, ...]
exit_code: int
host_id: str
operator_id: str
def __post_init__(self) -> None:
for value, name, max_len in (
(self.timestamp, "timestamp", 64),
(self.command, "command", _MAX_CMD),
(self.host_id, "host_id", _MAX_HOST),
(self.operator_id, "operator_id", _MAX_OPERATOR),
):
if not isinstance(value, str) or not value:
raise ValueError(f"{name} must be a non-empty str")
if "\x00" in value:
raise ValueError(f"{name} must not contain null bytes")
if len(value) > max_len:
raise ValueError(f"{name} too long ({len(value)} > {max_len})")
if not isinstance(self.args, tuple):
raise ValueError("args must be a tuple")
if len(self.args) > _MAX_ARGS:
raise ValueError(f"too many args ({len(self.args)} > {_MAX_ARGS})")
for arg in self.args:
if not isinstance(arg, str):
raise ValueError("args[*] must be str")
if "\x00" in arg:
raise ValueError("args[*] must not contain null bytes")
if len(arg) > _MAX_ARG_LEN:
raise ValueError(f"args[*] too long (> {_MAX_ARG_LEN})")
if isinstance(self.exit_code, bool):
raise ValueError("exit_code must be int, not bool")
if not isinstance(self.exit_code, int):
raise ValueError("exit_code must be int")
def to_dict(self) -> dict:
return {
"timestamp": self.timestamp,
"command": self.command,
"args": list(self.args),
"exit_code": self.exit_code,
"host_id": self.host_id,
"operator_id": self.operator_id,
}
def _redact_str(value: str) -> str:
return _SECRET_RE.sub("<redacted>", value)
def redact_event(ev: AuditEvent) -> AuditEvent:
"""Return a new ``AuditEvent`` with secrets masked in every string field.
Security review HIGH fix: previously only ``args`` was redacted. An
operator_id or host_id containing ``Bearer `` / ``hf_`` would persist
verbatim. We now walk every string field (mirrors v0.34.0 ``crash.py``
policy of recursive secret redaction).
"""
return replace(
ev,
command=_redact_str(ev.command),
args=tuple(_redact_str(a) for a in ev.args),
host_id=_redact_str(ev.host_id),
operator_id=_redact_str(ev.operator_id),
)
def _check_symlink_at(path: str) -> bool:
"""Return True when ``path`` exists AND is a symlink (TOCTOU-safe).
Uses ``os.lstat`` directly inside try/except FileNotFoundError instead of
``lexists``-then-``lstat`` closes the race between the existence check
and the stat (security review HIGH fix; mirrors v0.33.0 #22 / v0.55.0).
"""
try:
st = os.lstat(path)
except FileNotFoundError:
return False
except OSError:
# Other OSError (perm, etc.) - treat as "do not touch".
return True
return stat.S_ISLNK(st.st_mode)
def rotate_if_needed(path: str, *, cap_bytes: int = _DEFAULT_CAP_BYTES) -> bool:
"""Rotate ``<path>`` -> ``<path>.1`` when file size exceeds ``cap_bytes``.
Symlink at the backup path is rejected via direct ``os.lstat`` (no lexists
race security review HIGH fix). Returns True when rotation happened.
"""
if isinstance(cap_bytes, bool) or not isinstance(cap_bytes, int):
raise ValueError("cap_bytes must be int")
if cap_bytes <= 0:
raise ValueError("cap_bytes must be > 0")
if not isinstance(path, str) or not path:
raise ValueError("path must be a non-empty str")
try:
size = os.path.getsize(path)
except FileNotFoundError:
return False
except OSError as exc:
_LOG.warning("rotate_if_needed: getsize(%s) failed: %s", path, exc)
return False
if size <= cap_bytes:
return False
backup = path + ".1"
if _check_symlink_at(backup):
# Refuse to overwrite a symlink — TOCTOU defence.
_LOG.warning(
"rotate_if_needed: refusing to rotate, backup path %s is a symlink",
backup,
)
return False
# If a regular backup exists, remove it.
try:
if os.path.isfile(backup):
os.unlink(backup)
except OSError as exc:
_LOG.warning("rotate_if_needed: unlink(%s) failed: %s", backup, exc)
return False
try:
os.replace(path, backup)
except OSError as exc:
_LOG.warning("rotate_if_needed: replace(%s) failed: %s", path, exc)
return False
return True
def _validate_log_path_override(override: str) -> Optional[str]:
"""Validate the ``SOUP_AUDIT_LOG_PATH`` env override.
Per v0.36.0 ``SOUP_BATCH_CACHE_PATH`` / v0.54.0 ``SOUP_ADVISE_HISTORY_PATH``
policy: rejects null bytes / control chars / overlong values, and refuses
paths outside ``$HOME / $CWD / $TMPDIR``. Returns the validated path or
``None`` when invalid (caller then falls back to the safe default).
"""
if not isinstance(override, str) or not override:
return None
if _CTRL_RE.search(override):
_LOG.warning(
"SOUP_AUDIT_LOG_PATH contains null/control chars; falling back to default"
)
return None
if len(override) > 4096:
_LOG.warning("SOUP_AUDIT_LOG_PATH too long; falling back to default")
return None
try:
realpath = os.path.realpath(override)
except (OSError, ValueError):
return None
home = os.path.realpath(os.path.expanduser("~"))
cwd = os.path.realpath(os.getcwd())
tmpdir = os.path.realpath(tempfile.gettempdir())
for allowed in (home, cwd, tmpdir):
try:
common = os.path.commonpath([realpath, allowed])
except ValueError:
continue
if common == allowed:
return override
_LOG.warning(
"SOUP_AUDIT_LOG_PATH %r outside $HOME / $CWD / $TMPDIR; "
"falling back to default",
override,
)
return None
def default_log_path() -> str:
"""Resolve the audit log path (env override first, else ``~/.soup/audit.jsonl``).
The env override goes through ``_validate_log_path_override`` so callers
cannot smuggle a system file (``/etc/cron.d``) through the override.
"""
override = os.environ.get("SOUP_AUDIT_LOG_PATH")
if override:
validated = _validate_log_path_override(override)
if validated is not None:
return validated
home = os.path.expanduser("~")
return os.path.join(home, ".soup", "audit.jsonl")
# Backwards-compatible alias for tests / internal callers.
_default_log_path = default_log_path
def append_audit_event(
ev: AuditEvent,
path: Optional[str] = None,
*,
cap_bytes: int = _DEFAULT_CAP_BYTES,
redact: bool = True,
) -> None:
"""Append one audit record to ``path``. Rotates at ``cap_bytes``.
Atomic-ish append: opens with ``O_APPEND | O_CREAT`` (and ``O_NOFOLLOW``
on POSIX security review HIGH fix). Concurrent writers can interleave
on POSIX without truncation; on Windows the OS does not guarantee atomic
append for buffered writes, so very high concurrency may interleave
bytes within a line.
"""
# Explicit `is None` check (project policy since v0.40.6 — empty-string is
# a distinct operator error, NOT silent missing).
if path is None:
target = default_log_path()
elif not isinstance(path, str):
raise ValueError(f"path must be str, got {type(path).__name__}")
elif not path:
raise ValueError("path must be a non-empty str")
else:
target = path
if "\x00" in target:
raise ValueError("path must not contain null bytes")
parent = os.path.dirname(os.path.abspath(target)) or "."
os.makedirs(parent, exist_ok=True)
# Best-effort rotation before writing the new line.
rotate_if_needed(target, cap_bytes=cap_bytes)
line = json.dumps((redact_event(ev) if redact else ev).to_dict()) + "\n"
flags = os.O_WRONLY | os.O_CREAT | os.O_APPEND
# O_NOFOLLOW: refuse to follow a symlink at the target path.
# Not available on Windows.
nofollow = getattr(os, "O_NOFOLLOW", 0)
if nofollow:
flags |= nofollow
mode = 0o600
fd = os.open(target, flags, mode)
try:
os.write(fd, line.encode("utf-8"))
finally:
os.close(fd)
if os.name != "nt":
try:
current = stat.S_IMODE(os.stat(target).st_mode)
if current != 0o600:
os.chmod(target, 0o600)
except OSError as exc:
_LOG.debug("audit-log chmod failed: %s", exc)
def read_audit_tail(path: Optional[str] = None, *, limit: int = 50) -> list[dict]:
"""Read the last ``limit`` audit records (newest last)."""
if isinstance(limit, bool):
raise ValueError("limit must be int")
if not isinstance(limit, int) or limit < 1 or limit > 100_000:
raise ValueError("limit must be an int in [1, 100000]")
target = path or default_log_path()
if not os.path.isfile(target):
return []
out: list[dict] = []
try:
with open(target, encoding="utf-8") as fh:
for line in fh:
raw = line.strip()
if not raw:
continue
try:
out.append(json.loads(raw))
except (ValueError, TypeError):
continue
except OSError:
return []
return out[-limit:]

346
soup_cli/utils/bom.py Normal file
View File

@ -0,0 +1,346 @@
"""CycloneDX ML-BOM + SPDX 2.3 AI-profile emitter (v0.59.0 Part A).
Pure-stdlib, no heavy imports reads from a small ``BomEntry`` frozen
dataclass that the caller derives from a v0.26.0 ``RegistryEntry``. Two
output formats:
- **CycloneDX 1.6 + ML-BOM extension** (``bomFormat: CycloneDX``,
``specVersion: 1.6``, root component ``type=machine-learning-model``).
- **SPDX 2.3 + AI profile** (``spdxVersion: SPDX-2.3``, ``dataLicense:
CC0-1.0``, package with ``primaryPackagePurpose: AI-MODEL``).
Atomic write via ``tempfile.mkstemp + os.replace`` under cwd containment
+ ``os.lstat + S_ISLNK`` rejection (TOCTOU defence mirrors v0.33.0 #22
/ v0.43.0 Part C / v0.46.0 Part A / v0.56.0 / v0.57.0 / v0.58.0 policy).
"""
from __future__ import annotations
import json
import math
import re
import secrets
from dataclasses import dataclass, replace
from typing import TYPE_CHECKING, Any, Mapping, Optional, Tuple
from soup_cli.utils.paths import atomic_write_text
if TYPE_CHECKING:
from soup_cli.utils.energy import EnergyMeasurement
_SHA256_RE = re.compile(r"^[0-9a-fA-F]{64}$")
_MAX_NAME = 256
_MAX_VERSION = 64
_MAX_LICENSE = 64
_MAX_TASK = 64
_VALID_FORMATS = ("cyclonedx", "spdx", "both")
def _check_str(
value: object, *, field_name: str, max_len: int, allow_none: bool = False,
) -> Optional[str]:
if value is None:
if allow_none:
return None
raise ValueError(f"{field_name} must not be None")
if not isinstance(value, str):
raise ValueError(f"{field_name} must be str, got {type(value).__name__}")
if "\x00" in value:
raise ValueError(f"{field_name} must not contain null bytes")
if not value:
raise ValueError(f"{field_name} must not be empty")
if len(value) > max_len:
raise ValueError(f"{field_name} length {len(value)} exceeds {max_len}")
return value
def _check_sha256(value: object, *, field_name: str, allow_none: bool = False) -> Optional[str]:
if value is None:
if allow_none:
return None
raise ValueError(f"{field_name} must not be None")
if not isinstance(value, str):
raise ValueError(f"{field_name} must be str, got {type(value).__name__}")
if not _SHA256_RE.match(value):
raise ValueError(f"{field_name} must be 64 hex chars (got {len(value)})")
return value
@dataclass(frozen=True)
class BomEntry:
"""Per-run BOM input.
The shape mirrors what we can read from a v0.26.0 ``RegistryEntry``:
``base_model`` + ``base_sha`` + ``config_sha`` + ``data_sha`` + ``task`` +
``parents`` (tuple of parent entry ids for SLSA materials) + ``artifacts``
(tuple of dicts with ``kind`` + ``sha256`` + ``size_bytes``).
"""
name: str
version: str
base_model: str
base_sha: str
config_sha: str
data_sha: Optional[str]
task: str
license: Optional[str]
parents: Tuple[str, ...]
artifacts: Tuple[Mapping[str, Any], ...]
created_at: str
# Optional energy attachments (Part F) — see attach_energy().
energy_kwh: Optional[float] = None
co2_kg: Optional[float] = None
pue: Optional[float] = None
grid_intensity_g_per_kwh: Optional[float] = None
energy_source: Optional[str] = None
def __post_init__(self) -> None:
_check_str(self.name, field_name="name", max_len=_MAX_NAME)
_check_str(self.version, field_name="version", max_len=_MAX_VERSION)
_check_str(self.base_model, field_name="base_model", max_len=_MAX_NAME)
_check_sha256(self.base_sha, field_name="base_sha")
_check_sha256(self.config_sha, field_name="config_sha")
_check_sha256(self.data_sha, field_name="data_sha", allow_none=True)
_check_str(self.task, field_name="task", max_len=_MAX_TASK)
if self.license is not None:
_check_str(self.license, field_name="license", max_len=_MAX_LICENSE)
_check_str(self.created_at, field_name="created_at", max_len=64)
if not isinstance(self.parents, tuple):
raise ValueError("parents must be a tuple")
for p in self.parents:
_check_str(p, field_name="parents[*]", max_len=_MAX_NAME)
if not isinstance(self.artifacts, tuple):
raise ValueError("artifacts must be a tuple")
for value, name in (
(self.energy_kwh, "energy_kwh"),
(self.co2_kg, "co2_kg"),
(self.pue, "pue"),
(self.grid_intensity_g_per_kwh, "grid_intensity_g_per_kwh"),
):
if value is None:
continue
if isinstance(value, bool):
raise ValueError(f"{name} must not be bool")
if not isinstance(value, (int, float)):
raise ValueError(f"{name} must be a number")
if not math.isfinite(float(value)):
raise ValueError(f"{name} must be finite")
if float(value) < 0:
raise ValueError(f"{name} must be >= 0")
def attach_energy(entry: BomEntry, measurement: EnergyMeasurement) -> BomEntry:
"""Return a new BomEntry with energy/CO2 fields populated from a v0.59 EnergyMeasurement.
Caller is responsible for ensuring the measurement is finite + non-negative
(the EnergyMeasurement dataclass already enforces this).
"""
return replace(
entry,
energy_kwh=float(measurement.energy_kwh),
co2_kg=float(measurement.co2_kg),
pue=float(measurement.pue),
grid_intensity_g_per_kwh=float(measurement.grid_intensity_g_per_kwh),
energy_source=str(measurement.source),
)
def _energy_properties(entry: BomEntry) -> list[dict]:
props: list[dict] = []
if entry.energy_kwh is not None:
props.append({"name": "soup:energy_kwh", "value": str(entry.energy_kwh)})
if entry.co2_kg is not None:
props.append({"name": "soup:co2_kg", "value": str(entry.co2_kg)})
if entry.pue is not None:
props.append({"name": "soup:pue", "value": str(entry.pue)})
if entry.grid_intensity_g_per_kwh is not None:
props.append({
"name": "soup:grid_intensity_g_per_kwh",
"value": str(entry.grid_intensity_g_per_kwh),
})
if entry.energy_source is not None:
props.append({"name": "soup:energy_source", "value": entry.energy_source})
return props
def build_cyclonedx_bom(entry: BomEntry) -> dict:
"""Render a CycloneDX 1.6 ML-BOM dict (in-memory)."""
if not isinstance(entry, BomEntry):
raise TypeError(f"entry must be BomEntry, got {type(entry).__name__}")
licenses: list[dict] = []
if entry.license:
licenses.append({"license": {"id": entry.license}})
components: list[dict] = [
{
"type": "machine-learning-model",
"name": entry.base_model,
"bom-ref": f"base:{entry.base_sha}",
"hashes": [{"alg": "SHA-256", "content": entry.base_sha}],
"mime-type": "application/x-machine-learning-model",
}
]
for parent in entry.parents:
components.append({
"type": "machine-learning-model",
"name": parent,
"bom-ref": f"parent:{parent}",
})
for index, art in enumerate(entry.artifacts):
kind = str(art.get("kind", "artifact"))
digest = str(art.get("sha256", "")).lower()
raw_size = art.get("size_bytes", 0)
if isinstance(raw_size, bool):
raise ValueError(f"artifact[{index}].size_bytes must not be bool")
try:
size = int(raw_size)
except (TypeError, ValueError) as exc:
raise ValueError(
f"artifact[{index}].size_bytes must be int-like: {exc}"
) from exc
comp = {
"type": "file",
"name": f"{entry.name}/{kind}",
"bom-ref": f"artifact:{kind}:{digest[:12]}",
}
if _SHA256_RE.match(digest):
comp["hashes"] = [{"alg": "SHA-256", "content": digest}]
if size > 0:
comp["properties"] = [{"name": "size_bytes", "value": str(size)}]
components.append(comp)
properties = [
{"name": "soup:task", "value": entry.task},
{"name": "soup:config_sha256", "value": entry.config_sha},
]
if entry.data_sha:
properties.append({"name": "soup:data_sha256", "value": entry.data_sha})
properties.extend(_energy_properties(entry))
doc = {
"bomFormat": "CycloneDX",
"specVersion": "1.6",
"serialNumber": f"urn:uuid:{secrets.token_hex(16)}",
"version": 1,
"metadata": {
"timestamp": entry.created_at,
"tools": [{"name": "soup-cli", "version": _read_soup_version()}],
"component": {
"type": "machine-learning-model",
"name": entry.name,
"version": entry.version,
"bom-ref": f"soup:{entry.name}@{entry.version}",
**({"licenses": licenses} if licenses else {}),
"properties": [
{"name": "soup:base_model", "value": entry.base_model},
{"name": "soup:task", "value": entry.task},
],
},
"properties": properties,
},
"components": components,
}
return doc
def build_spdx_bom(entry: BomEntry) -> dict:
"""Render an SPDX 2.3 + AI-profile dict (in-memory)."""
if not isinstance(entry, BomEntry):
raise TypeError(f"entry must be BomEntry, got {type(entry).__name__}")
spdx_id_main = "SPDXRef-Model"
pkg = {
"SPDXID": spdx_id_main,
"name": entry.name,
"versionInfo": entry.version,
"downloadLocation": "NOASSERTION",
"filesAnalyzed": False,
"licenseConcluded": entry.license or "NOASSERTION",
"licenseDeclared": entry.license or "NOASSERTION",
"copyrightText": "NOASSERTION",
"primaryPackagePurpose": "AI-MODEL",
"annotations": [
{
"annotator": "Tool: soup-cli",
"annotationDate": entry.created_at,
"annotationType": "OTHER",
"annotationComment": f"task={entry.task} base={entry.base_model}",
}
],
"checksums": [{"algorithm": "SHA256", "checksumValue": entry.config_sha}],
}
pkg_base = {
"SPDXID": "SPDXRef-Base",
"name": entry.base_model,
"downloadLocation": "NOASSERTION",
"filesAnalyzed": False,
"licenseConcluded": "NOASSERTION",
"licenseDeclared": "NOASSERTION",
"copyrightText": "NOASSERTION",
"primaryPackagePurpose": "AI-MODEL",
"checksums": [{"algorithm": "SHA256", "checksumValue": entry.base_sha}],
}
relationships = [
{
"spdxElementId": spdx_id_main,
"relatedSpdxElement": "SPDXRef-Base",
"relationshipType": "DERIVED_FROM",
}
]
doc = {
"spdxVersion": "SPDX-2.3",
"dataLicense": "CC0-1.0",
"SPDXID": "SPDXRef-DOCUMENT",
"name": entry.name,
"documentNamespace": f"https://soup.local/bom/{entry.name}-{secrets.token_hex(8)}",
"creationInfo": {
"created": entry.created_at,
"creators": [f"Tool: soup-cli-{_read_soup_version()}"],
},
"packages": [pkg, pkg_base],
"relationships": relationships,
}
if entry.data_sha:
doc["packages"].append({
"SPDXID": "SPDXRef-Data",
"name": "training-data",
"downloadLocation": "NOASSERTION",
"filesAnalyzed": False,
"licenseConcluded": "NOASSERTION",
"licenseDeclared": "NOASSERTION",
"copyrightText": "NOASSERTION",
"primaryPackagePurpose": "SOURCE",
"checksums": [{"algorithm": "SHA256", "checksumValue": entry.data_sha}],
})
relationships.append({
"spdxElementId": spdx_id_main,
"relatedSpdxElement": "SPDXRef-Data",
"relationshipType": "BUILD_DEPENDENCY_OF",
})
return doc
def render_bom(entry: BomEntry, fmt: str) -> str:
"""Return JSON-serialised BOM (CycloneDX or SPDX) for the given entry."""
if not isinstance(fmt, str):
raise ValueError("fmt must be str")
fmt_lc = fmt.lower()
if fmt_lc == "cyclonedx":
return json.dumps(build_cyclonedx_bom(entry), indent=2, sort_keys=True)
if fmt_lc == "spdx":
return json.dumps(build_spdx_bom(entry), indent=2, sort_keys=True)
raise ValueError(f"Unsupported BOM format: {fmt!r} (use one of {_VALID_FORMATS})")
def write_bom(entry: BomEntry, fmt: str, output_path: str) -> str:
"""Atomically write a BOM to ``output_path`` (must stay under cwd)."""
text = render_bom(entry, fmt)
return atomic_write_text(text, output_path, prefix=".bom.", suffix=".json.tmp")
def _read_soup_version() -> str:
try:
from soup_cli import __version__
return __version__
except ImportError:
return "unknown"

170
soup_cli/utils/energy.py Normal file
View File

@ -0,0 +1,170 @@
"""CodeCarbon + electricityMap energy/CO2 capture (v0.59.0 Part F).
Lazy-imports ``codecarbon`` so the module loads cleanly without it. When
codecarbon is absent the public API returns ``None`` from ``measure_run_energy``
so callers can fall back gracefully.
The electricityMap endpoint is SSRF-validated with full parity to v0.51.0
``utils/hubs.validate_hub_endpoint``: scheme allowlist + loopback-only HTTP
+ RFC1918 / link-local / cloud-metadata rejection + null-byte / control
char / oversize rejection.
"""
from __future__ import annotations
import ipaddress
import math
import re
from dataclasses import dataclass
from typing import Optional
from urllib.parse import urlsplit
_MAX_ENDPOINT_LEN = 2048
_CTRL_RE = re.compile(r"[\x00-\x1f\x7f]")
_LOOPBACK = frozenset({"localhost", "127.0.0.1", "::1"})
_SCHEMES = frozenset({"http", "https"})
@dataclass(frozen=True)
class EnergyMeasurement:
"""One per-run energy + CO2 reading."""
energy_kwh: float
co2_kg: float
pue: float
grid_intensity_g_per_kwh: float
source: str
def __post_init__(self) -> None:
for value, name in (
(self.energy_kwh, "energy_kwh"),
(self.co2_kg, "co2_kg"),
(self.pue, "pue"),
(self.grid_intensity_g_per_kwh, "grid_intensity_g_per_kwh"),
):
if isinstance(value, bool):
raise ValueError(f"{name} must not be bool")
if not isinstance(value, (int, float)):
raise ValueError(f"{name} must be a number")
f = float(value)
if not math.isfinite(f):
raise ValueError(f"{name} must be finite")
if f < 0:
raise ValueError(f"{name} must be >= 0")
if self.pue < 1.0:
raise ValueError("pue must be >= 1.0")
if not isinstance(self.source, str) or "\x00" in self.source:
raise ValueError("source must be a null-byte-free str")
def validate_electricity_map_endpoint(endpoint: str) -> str:
"""SSRF-harden the electricityMap query endpoint.
Mirrors v0.51.0 ``validate_hub_endpoint``: scheme allowlist (http/https),
loopback-only HTTP, private-IP rejection, no control chars / null bytes.
"""
if not isinstance(endpoint, str):
raise ValueError("endpoint must be str")
if not endpoint:
raise ValueError("endpoint must be non-empty")
if "\x00" in endpoint:
raise ValueError("endpoint must not contain null bytes")
if len(endpoint) > _MAX_ENDPOINT_LEN:
raise ValueError(f"endpoint too long (> {_MAX_ENDPOINT_LEN})")
if _CTRL_RE.search(endpoint):
raise ValueError("endpoint must not contain control chars")
try:
parts = urlsplit(endpoint)
except ValueError as exc:
raise ValueError(f"endpoint unparseable: {exc}") from exc
scheme = parts.scheme.lower()
if scheme not in _SCHEMES:
raise ValueError(
f"endpoint scheme must be http or https, got {scheme!r}"
)
host = (parts.hostname or "").lower()
if not host:
raise ValueError("endpoint must have a host")
if host == "0.0.0.0":
raise ValueError("0.0.0.0 endpoints are rejected")
is_loopback = host in _LOOPBACK
if scheme == "http" and not is_loopback:
# Reject plain HTTP except for loopback.
raise ValueError(
"http:// only permitted for loopback hosts; use https:// for remote"
)
# Reject private / link-local / cloud-metadata IPs explicitly.
# ``parts.hostname`` already strips IPv6 brackets, so feed it directly.
try:
ip = ipaddress.ip_address(host)
except ValueError:
ip = None
if ip is not None and not is_loopback:
if ip.is_private or ip.is_link_local or ip.is_reserved or ip.is_multicast:
raise ValueError(
f"endpoint host {host!r} resolves to a private/link-local IP"
)
return endpoint
def adjust_for_pue(energy_kwh: float, pue: float) -> float:
"""Multiply raw energy by PUE (Power Usage Effectiveness).
PUE must be >= 1.0 (a data centre that does no overhead-cooling at all has
PUE == 1.0; typical hyperscale is 1.11.5).
"""
if isinstance(energy_kwh, bool) or isinstance(pue, bool):
raise ValueError("inputs must not be bool")
if not isinstance(energy_kwh, (int, float)) or not isinstance(pue, (int, float)):
raise ValueError("inputs must be numeric")
if not math.isfinite(float(energy_kwh)) or not math.isfinite(float(pue)):
raise ValueError("inputs must be finite")
if energy_kwh < 0:
raise ValueError("energy_kwh must be >= 0")
if pue < 1.0:
raise ValueError("pue must be >= 1.0")
return float(energy_kwh) * float(pue)
def measure_run_energy(
duration_seconds: float = 0.0,
*,
grid_intensity_g_per_kwh: float = 400.0,
pue: float = 1.1,
) -> Optional[EnergyMeasurement]:
"""Best-effort capture of a single run's energy + CO2.
Returns ``None`` when ``codecarbon`` is not installed AND ``duration_seconds``
is ``<= 0`` (degenerate inputs surface as None rather than a fake zero).
Live CodeCarbon hook wiring into trainer wrappers lands in v0.59.1.
"""
if isinstance(duration_seconds, bool) or isinstance(grid_intensity_g_per_kwh, bool):
raise ValueError("numeric inputs must not be bool")
if not isinstance(duration_seconds, (int, float)):
raise ValueError("duration_seconds must be numeric")
if not math.isfinite(float(duration_seconds)) or duration_seconds < 0:
raise ValueError("duration_seconds must be a finite non-negative number")
if not isinstance(grid_intensity_g_per_kwh, (int, float)):
raise ValueError("grid_intensity_g_per_kwh must be numeric")
if (
not math.isfinite(float(grid_intensity_g_per_kwh))
or grid_intensity_g_per_kwh < 0
):
raise ValueError("grid_intensity_g_per_kwh must be a finite non-negative number")
try:
adjust_for_pue(1.0, pue)
except ValueError as exc:
raise ValueError(f"invalid pue: {exc}") from exc
try:
import codecarbon # noqa: F401, PLC0415
except ImportError:
# No live codecarbon — return None so the caller (typically the BOM
# builder) can decide whether to omit the energy properties.
return None
# Live wiring deferred to v0.59.1; return None until the codecarbon
# `EmissionsTracker` hook lands. The schema + endpoint validator + PUE
# math are live; the actual measurement is the v0.59.1 deliverable.
return None

View File

@ -14,6 +14,7 @@ from __future__ import annotations
import os
import stat
import tempfile
from pathlib import Path
from typing import Union
@ -69,3 +70,39 @@ def enforce_under_cwd_and_no_symlink(path: str, field: str) -> str:
f"{field} must not be a symlink (TOCTOU defence)"
)
return path
def atomic_write_text(
text: str,
output_path: str,
*,
prefix: str = ".soup.",
suffix: str = ".tmp",
field: str = "output",
) -> str:
"""Atomically write ``text`` to ``output_path`` under cwd containment.
Pipeline: ``enforce_under_cwd_and_no_symlink`` -> ``mkstemp`` in the
parent dir -> write -> ``os.replace`` -> best-effort cleanup of the
tmp file on failure. Returns the realpath of the written file.
Centralised in v0.59.0 from four separate copies in
``bom.py`` / ``attest.py`` / ``annex_xi.py`` / ``repro_receipt.py``
so the TOCTOU defence stays single-source-of-truth (code-review
HIGH fix mirrors v0.40.6 / v0.53.5 peft_wiring centralisation policy).
"""
enforce_under_cwd_and_no_symlink(output_path, field)
parent = os.path.dirname(os.path.abspath(output_path)) or "."
os.makedirs(parent, exist_ok=True)
fd, tmp_path = tempfile.mkstemp(prefix=prefix, suffix=suffix, dir=parent)
try:
with os.fdopen(fd, "w", encoding="utf-8") as fh:
fh.write(text)
os.replace(tmp_path, output_path)
finally:
if os.path.exists(tmp_path):
try:
os.unlink(tmp_path)
except OSError:
pass
return os.path.realpath(output_path)

View File

@ -0,0 +1,182 @@
"""SR 11-7-style reproducibility receipt (v0.59.0 Part E).
Captures the minimum environment fingerprint needed for a regulated-org
audit: seeds (torch + numpy + python), Python interpreter version, OS +
arch, soup_cli version, kernel versions (CUDA / cuDNN / NCCL best-effort
from torch when available), GPU model + driver (best-effort from
``torch.cuda.get_device_name`` + ``nvidia-smi`` proxy).
Pure-stdlib at module top; ``torch`` is lazy-imported so this module
loads in <50 ms on CPU-only hosts.
"""
from __future__ import annotations
import json
import logging
import platform
import re
from dataclasses import dataclass
from datetime import datetime, timezone
from typing import Mapping, Optional, Tuple
from soup_cli.utils.paths import atomic_write_text
_LOG = logging.getLogger(__name__)
_MAX_RUN_ID = 128
_MAX_VERSION = 64
_RUN_ID_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._\-]{0,127}$")
@dataclass(frozen=True)
class ReproReceipt:
"""SR 11-7-style reproducibility receipt."""
run_id: str
soup_version: str
python_version: str
os: str
arch: str
seeds: Mapping[str, int]
torch_version: Optional[str]
cuda_version: Optional[str]
cudnn_version: Optional[str]
nccl_version: Optional[str]
gpu_models: Tuple[str, ...]
driver_version: Optional[str]
created_at: str
def __post_init__(self) -> None:
if not isinstance(self.run_id, str) or not _RUN_ID_RE.match(self.run_id):
raise ValueError(
f"run_id must match [A-Za-z0-9._-]+ (1-128 chars), got {self.run_id!r}"
)
for value, name in (
(self.soup_version, "soup_version"),
(self.python_version, "python_version"),
(self.os, "os"),
(self.arch, "arch"),
(self.created_at, "created_at"),
):
if not isinstance(value, str) or "\x00" in value:
raise ValueError(f"{name} must be a null-byte-free str")
if not isinstance(self.seeds, Mapping):
raise ValueError("seeds must be a mapping")
for key, val in self.seeds.items():
if not isinstance(key, str):
raise ValueError("seeds keys must be str")
if isinstance(val, bool) or not isinstance(val, int):
raise ValueError(f"seeds[{key}] must be int (got {type(val).__name__})")
if not isinstance(self.gpu_models, tuple):
raise ValueError("gpu_models must be a tuple")
def _detect_torch_kernel_versions() -> dict:
"""Best-effort torch / CUDA / cuDNN / NCCL detection.
Returns a dict with optional ``torch_version`` / ``cuda_version`` /
``cudnn_version`` / ``nccl_version`` / ``gpu_models`` / ``driver_version``
keys. Each is ``None`` when not detected. Lazy-imports torch.
"""
out = {
"torch_version": None,
"cuda_version": None,
"cudnn_version": None,
"nccl_version": None,
"gpu_models": (),
"driver_version": None,
}
try:
import torch # noqa: PLC0415
except ImportError:
return out
try:
out["torch_version"] = str(torch.__version__)
except Exception as exc: # noqa: BLE001
_LOG.debug("repro_receipt torch probe failed: %s", exc)
try:
if torch.version.cuda is not None:
out["cuda_version"] = str(torch.version.cuda)
except Exception as exc: # noqa: BLE001
_LOG.debug("repro_receipt torch probe failed: %s", exc)
try:
if torch.backends.cudnn.is_available():
out["cudnn_version"] = str(torch.backends.cudnn.version())
except Exception as exc: # noqa: BLE001
_LOG.debug("repro_receipt torch probe failed: %s", exc)
try:
nccl_v = getattr(torch.cuda, "nccl", None)
if nccl_v is not None and hasattr(nccl_v, "version"):
v = nccl_v.version()
out["nccl_version"] = ".".join(str(x) for x in v) if isinstance(v, tuple) else str(v)
except Exception as exc: # noqa: BLE001
_LOG.debug("repro_receipt torch probe failed: %s", exc)
try:
if torch.cuda.is_available():
names = []
for i in range(torch.cuda.device_count()):
names.append(str(torch.cuda.get_device_name(i)))
out["gpu_models"] = tuple(names)
except Exception as exc: # noqa: BLE001
_LOG.debug("repro_receipt torch probe failed: %s", exc)
return out
def build_repro_receipt(
seeds: Mapping[str, int],
run_id: str,
*,
created_at: Optional[str] = None,
) -> ReproReceipt:
"""Build a reproducibility receipt from seeds + run_id + autodetected env.
``ReproReceipt.__post_init__`` performs the canonical seeds validation;
we no longer duplicate it here (python-review HIGH fix).
"""
if created_at is None:
created_at = datetime.now(tz=timezone.utc).isoformat()
from soup_cli import __version__ as _soup_version
kernel = _detect_torch_kernel_versions()
return ReproReceipt(
run_id=run_id,
soup_version=str(_soup_version)[:_MAX_VERSION],
python_version=platform.python_version(),
os=f"{platform.system()} {platform.release()}",
arch=platform.machine() or "unknown",
seeds=dict(seeds),
torch_version=kernel.get("torch_version"),
cuda_version=kernel.get("cuda_version"),
cudnn_version=kernel.get("cudnn_version"),
nccl_version=kernel.get("nccl_version"),
gpu_models=tuple(kernel.get("gpu_models", ())),
driver_version=kernel.get("driver_version"),
created_at=created_at,
)
def receipt_to_dict(r: ReproReceipt) -> dict:
return {
"run_id": r.run_id,
"soup_version": r.soup_version,
"python_version": r.python_version,
"os": r.os,
"arch": r.arch,
"seeds": dict(r.seeds),
"torch_version": r.torch_version,
"cuda_version": r.cuda_version,
"cudnn_version": r.cudnn_version,
"nccl_version": r.nccl_version,
"gpu_models": list(r.gpu_models),
"driver_version": r.driver_version,
"created_at": r.created_at,
}
def write_repro_receipt(r: ReproReceipt, output_path: str) -> str:
"""Atomic write of the receipt to ``output_path`` (cwd-contained)."""
text = json.dumps(receipt_to_dict(r), indent=2, sort_keys=True)
return atomic_write_text(
text, output_path, prefix=".repro.", suffix=".json.tmp",
)

View File

@ -14,6 +14,7 @@ from __future__ import annotations
import dataclasses
import json
import os
import re
from datetime import datetime, timezone
from pathlib import Path
@ -1166,8 +1167,13 @@ class TestSourceWiring:
assert 'name="loop"' in cli_src
def test_version_bumped_to_0_58_0(self):
# Widened from exact-match to floor-check to match the v0.51.0 / v0.54.0
# / v0.56.0 idiom — v0.58.0 was the floor when these tests landed.
init = (_REPO_ROOT / "soup_cli" / "__init__.py").read_text(encoding="utf-8")
assert '__version__ = "0.58.0"' in init
match = re.search(r'__version__ = "(\d+)\.(\d+)\.(\d+)"', init)
assert match is not None, init
major, minor, patch = (int(g) for g in match.groups())
assert (major, minor, patch) >= (0, 58, 0)
def test_no_top_level_torch_import_in_loop_modules(self):
for name in [
@ -1197,7 +1203,11 @@ class TestSourceWiring:
def test_version_string():
from soup_cli import __version__
assert __version__ == "0.58.0"
# Widened from exact-match to floor-check (v0.58.0 was the floor here).
match = re.match(r"^(\d+)\.(\d+)\.(\d+)$", __version__)
assert match is not None, __version__
major, minor, patch = (int(g) for g in match.groups())
assert (major, minor, patch) >= (0, 58, 0)
# ---------------------------------------------------------------------------

1393
tests/test_v0590.py Normal file

File diff suppressed because it is too large Load Diff