diff --git a/.claude/agents/cold-read.md b/.claude/agents/cold-read.md new file mode 100644 index 0000000..42db1e6 --- /dev/null +++ b/.claude/agents/cold-read.md @@ -0,0 +1,57 @@ +--- +name: cold-read +description: Fresh-context reviewer for a finished PR in this repo — reads only the diff, the repo's docs, and the stated acceptance criteria, and reports gaps that affect correctness or the stated requirements. Fixes nothing. +effort: high +--- + +You are a cold reader. Your value is that you did not watch the work happen. + +**What you read:** the PR diff, the repo's own documentation (`CLAUDE.md`, `README.md`, +`CHANGELOG.md`, `tests/fixtures/README.md`), and the acceptance criteria you were given. +That is the whole inventory. + +**What you must not read:** the implementation chat, the ORCH transcript, the phase +contract's reasoning, or any account of how the change came to be. If someone offers +you that context, decline it. A verdict coloured by the author's assumptions is the +one thing a cold read cannot produce. + +**What you may run:** this repository's checks, through `uv` only (the system +`python3` may be older than the package's floor): `uv sync --locked`, then +`uv run pytest` (with `uv run --python 3.11|3.12|3.14` for the matrix's Pythons, and +Node 24 or 22 first on `PATH`); `uv run ruff check .`; `uv run ruff format --check .`; +and the wheel job — `uv build` from a clean checkout, the wheel installed into a fresh +environment, `scripts/smoke_wheel.py` run there with a throwaway seed from +`openssl rand -base64 32`. + +**What you report:** gaps that affect **correctness** or **the stated requirements**. +Specifically: + +- a stated acceptance criterion the diff does not actually meet; +- a defect in the changed code — wrong behaviour, an unhandled case, a broken + invariant; +- a breach of the rule that the wrapper holds no key, reads no seed and computes + none of the format's hashes, or a guard test that can no longer fail; +- a claim in the diff (a comment, a doc line, a commit message, a PR-body assertion) + that is false against the code at this revision; +- a check the criteria required that the evidence does not show being run; +- a fixture whose stated provenance does not match what the fixture contains, or a + byte-equal assertion that is not actually byte-equal. + +**What you leave alone:** style, naming, structure you would have done differently, +refactors the criteria did not ask for, and anything outside the diff. Preference is +not a finding. + +**You fix nothing.** No edits, no commits, no pushes, no suggested patches applied. +Your output is a report. + +Your report: + +1. **What I ran** — the exact commands and their results, or an explicit statement + that you ran nothing and reviewed by reading only. +2. **Findings** — most severe first. Each one: file and line, what is wrong, and the + concrete scenario in which it is wrong. If a finding is a suspicion rather than a + confirmation, label it as such. +3. **Criteria** — each stated acceptance criterion, marked met / not met / cannot tell + from the diff, with one line of reasoning. +4. **Nothing found** is a complete and useful report. Say it plainly; do not + manufacture findings to justify the pass. diff --git a/.claude/agents/impl.md b/.claude/agents/impl.md new file mode 100644 index 0000000..2aa3a86 --- /dev/null +++ b/.claude/agents/impl.md @@ -0,0 +1,55 @@ +--- +name: impl +description: IMPL agent for one gated-sprint phase in this repo — implements the phase on its own branch and reports evidence per CLAUDE.md. Spawned by an ORCH session with a phase contract from a sprint anchor issue. +effort: high +--- + +You are the IMPL agent for exactly one phase of a gated sprint in `typedstandards-python`. + +Your phase contract arrives from the ORCH session: task, context, non-goals, binary +acceptance criteria with runnable checks, blast zone, riders. This file is the +standing part — what is true of every phase here regardless of what the contract says. + +Ground rules: + +- **Read before porting — verify, don't trust.** Read the sprint contract (anchor + issue) and your phase definition, then the referenced source material itself. A + premise in the contract that does not match the repo at HEAD gets flagged, not + silently resolved. Paths, commands, and line references in a contract are claims to + check, not facts to act on. +- **One branch per phase**, named as the phase plan specifies; PR to `main`. You do + not merge, do not push rollback tags, and never publish to PyPI — ORCH handles merge + and tags on evidence-pass. Never push to `main`. +- **Stay inside the declared blast zone.** Keep the diff confined to the paths the + phase names; repos and paths the contract marks read-only stay untouched (the CLI's + repository and the host template are read-only from here). Out-of-scope findings go + in the phase report as flags for later phases — do not fix them. +- **Follow CLAUDE.md**: the rule that the wrapper holds no key, reads no seed and + computes none of the format's hashes; the stakeholder boundary (neutral phrasing in + every artifact that lands in this public repo); and the push guard. `git commit -s` + on every commit — the `Signed-off-by:` email must match the commit author email + exactly. +- **Never bypass a guard.** If a hook or the pre-push guard blocks, resolve the cause + and rebuild the branch history so the flagged bytes never land in outgoing commits. + Surface the block in your report; escalate to the owner rather than working around it. + +Phase report (your final message, mirrored into the PR body) — the evidence protocol +in CLAUDE.md, concretely: + +- branch, head SHA and `git diff --numstat main...HEAD`, with an explicit blast-zone + statement; +- full output of every check CI gates on, pasted rather than summarized: `uv sync --locked` + then `uv run pytest` on Python 3.11, 3.12 and 3.14 with Node 24, and on 3.12 with + Node 22; `uv run ruff check .`; `uv run ruff format --check .`; and the wheel job + (`uv build` from a clean checkout, the wheel installed into a fresh environment, + `scripts/smoke_wheel.py` run there). Use `uv` for every Python run; +- each acceptance criterion's red, then its green; +- gitleaks over the outgoing range: `gitleaks git --log-opts="main..HEAD" --no-banner`; +- fixture provenance — which source each fixture derives from, at which commit, with + its SHA-256, and the byte-equal assertions called out explicitly; +- the model you ran on; +- everything flagged-not-fixed, and every contract premise that did not survive the + check. + +Report outcomes faithfully — a red test, a skipped step, or a partial phase is +reported as such, never smoothed over. diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..9616cbd --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,96 @@ +# On every push and pull request: +# - test: the suite on Python 3.11, 3.12 and 3.14, on Ubuntu 24.04 and macOS 15, on Node 24, +# plus one cell on Ubuntu 24.04, Python 3.12, Node 22 (G0 D2 = A). `uv sync --locked` builds +# the editable install, whose hatchling hook vendors @typedstandards/cli from package-lock.json, +# so the tests drive the tree a wheel ships. +# - lint: ruff check and ruff format --check. +# - wheel: builds the sdist and, from it, the wheel, installs the wheel into a fresh environment, +# and runs scripts/smoke_wheel.py there with a throwaway seed. +# Each job name is stable: the ruleset on main requires these checks by name. +# The workflow holds no key and reads no repository secret. +# Each action is pinned to a full commit SHA, measured with git ls-remote, with its tag in a comment. +name: ci + +on: + push: + pull_request: + +permissions: + contents: read + +jobs: + test: + name: test (py${{ matrix.python }}, ${{ matrix.os }}, node ${{ matrix.node }}) + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + os: [ubuntu-24.04, macos-15] + python: ["3.11", "3.12", "3.14"] + node: ["24"] + include: + - os: ubuntu-24.04 + python: "3.12" + node: "22" + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version: ${{ matrix.node }} + package-manager-cache: false + - uses: astral-sh/setup-uv@c18668ad3cf93ea998bef934396af7bb5c839dc7 # v10.2.0 + with: + version: "0.11.25" + python-version: ${{ matrix.python }} + enable-cache: false + - run: node --version && npm --version && uv --version + - run: uv sync --locked + - run: uv run python --version + - run: uv run pytest + + lint: + name: lint + runs-on: ubuntu-24.04 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version: "24" + package-manager-cache: false + - uses: astral-sh/setup-uv@c18668ad3cf93ea998bef934396af7bb5c839dc7 # v10.2.0 + with: + version: "0.11.25" + python-version: "3.12" + enable-cache: false + - run: uv sync --locked + - run: uv run ruff check . + - run: uv run ruff format --check . + + wheel: + name: wheel (build, install, smoke) + runs-on: ubuntu-24.04 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version: "24" + package-manager-cache: false + - uses: astral-sh/setup-uv@c18668ad3cf93ea998bef934396af7bb5c839dc7 # v10.2.0 + with: + version: "0.11.25" + python-version: "3.12" + enable-cache: false + # uv build makes the sdist, then the wheel from the unpacked sdist. + - run: uv build + - run: ls -l dist && unzip -l dist/*.whl | grep -c '_vendor/node_modules/' + - run: uv venv "$RUNNER_TEMP/smoke" + - run: uv pip install --python "$RUNNER_TEMP/smoke" dist/*.whl + - name: smoke check in the fresh environment, with a throwaway seed + run: | + TYPEDSTANDARDS_SIGNING_SEED_B64="$(openssl rand -base64 32)" "$RUNNER_TEMP/smoke/bin/python" scripts/smoke_wheel.py diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..cd28c74 --- /dev/null +++ b/.gitignore @@ -0,0 +1,10 @@ +# The vendored CLI, written by hatch_build.py at every wheel or editable build. +/src/typedstandards/_vendor/ +/node_modules/ +/dist/ +/build/ +.venv/ +__pycache__/ +*.py[cod] +.pytest_cache/ +.ruff_cache/ diff --git a/.gitleaks.toml b/.gitleaks.toml new file mode 100644 index 0000000..b253f4c --- /dev/null +++ b/.gitleaks.toml @@ -0,0 +1,7 @@ +[extend] +useDefault = true + +[[allowlists]] +description = "Ed25519 did:key identifiers (base58btc, multicodec 0xed01) encode a public key; they are not secrets (hub ADR-0030)" +regexTarget = "match" +regexes = ['''key:z6Mk[1-9A-HJ-NP-Za-km-z]{44}'''] diff --git a/.python-version b/.python-version new file mode 100644 index 0000000..e4fba21 --- /dev/null +++ b/.python-version @@ -0,0 +1 @@ +3.12 diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..fda57f9 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,12 @@ +# Changelog + +## Unreleased + +- `sign`, `withdraw`, `attest`, `view` and `verify`: pass-throughs to `@typedstandards/cli` 0.2.0, + vendored into the wheel at build time and run as a child process with the inherited environment. + Each returns the CLI's stdout parsed as JSON. +- A Node locator: `TYPEDSTANDARDS_NODE`, then `node` on `PATH`; floor 20.19.0. +- Exit codes 1 to 4 raise `VerificationError`, `UsageError`, `SeedError` and `InternalError`, under + `CliError`; a missing or old Node raises `NodeLocatorError`. +- `verify` drops a bundle's top-level `trustRegistry` before the CLI sees it (typedstandards#136). +- `CLI_VERSION = "0.2.0"` and `cli_version()`. diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..2ef5aa8 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,97 @@ +# CLAUDE.md + +`typedstandards` on PyPI: a thin Python wrapper that runs `@typedstandards/cli` (vendored, pinned +exactly in `package.json` and `package-lock.json`) as a child process. Python `>=3.11`; hatchling +with a build hook (`hatch_build.py`) that vendors the CLI; `uv` for everything. + +## Development loop + +The system `python3` may be older than 3.11: run Python only through `uv`. Under a Node version +manager a non-interactive shell may have no `node` on `PATH`; load it first +(`eval "$(fnm env)" && fnm use 24`, or your equivalent). + +- `uv sync` — creates `.venv` and installs the package editable. The build hook runs + `npm ci --omit=dev --ignore-scripts` into `src/typedstandards/_vendor/` (git-ignored), so tests + drive the same tree a wheel ships, never a global CLI or `npx`. It re-runs when `pyproject.toml`, + `package.json`, `package-lock.json` or `hatch_build.py` change; force it with + `uv sync --reinstall-package typedstandards`. +- Another Python: `uv run --python 3.11 pytest`. Another Node: put it first on `PATH`, or set + `TYPEDSTANDARDS_NODE`. + +The checks CI runs (`.github/workflows/ci.yml`): + +- `test (py<3.11|3.12|3.14>, , node 24)` and + `test (py3.12, ubuntu-24.04, node 22)` — `uv sync --locked`, then `uv run pytest`; the gate is + `0 failed`. +- `lint` — `uv run ruff check .` and `uv run ruff format --check .`. +- `wheel (build, install, smoke)` — `uv build` (the sdist, then the wheel from it), the wheel + installed into a fresh environment, and `scripts/smoke_wheel.py` run there with a throwaway seed. + +## Node floors + +The wrapper and the CLI need Node 20.19 or later (the CLI's `engines.node`). A +`@typedstandards/host-core` site build needs Node 22 or later. The README states both. + +## The wrapper holds no key + +It holds no key, reads no signing seed, and computes none of the format's hashes (content hash, +envelope hash, node id): the CLI reads `TYPEDSTANDARDS_SIGNING_SEED_B64` from the environment it +inherits and does all of the format's work. Guard tests, which must keep failing on an offender: + +- `tests/test_guards.py` with `tests/guards.py`: no module under `src/typedstandards` names the + seed variable; no call passes `env=` or changes the process environment; no module imports + `hashlib` (or `hmac`, or hashlib's underscore modules) except P2's `pin.py`, whose digest is a + signed assertion; at run time, every child inherits the environment unchanged and the wrapper + never reads the seed variable. Each scanner is also driven over a tree of offenders. + +Test code may generate a random seed and set it with `monkeypatch.setenv`; package code never +touches one. A failing test never prints environment values. + +## Secret hygiene + +Never `cat`/`head`/`tail`/dump `.env*`, `auth.json`, `credentials*`, `*.pem`, `*.key`, `~/.ssh`, +`~/.aws`. Read only by key **name** (`grep`/`jq` a field, never a value) or a command the tool +exposes; never load-and-print a credentials file, even redacted. + +## Evidence protocol (gated sprint phases) + +Every phase report (PR body and anchor-issue comment) carries: + +- the phase **branch**, head SHA and `git diff --numstat main...HEAD`, with the blast zone stated; +- each acceptance criterion's **red** (the failing assertion's output) and its **green**, pasted; +- the **full suite output** on every Python and Node the matrix names, the clean-checkout wheel + build and its smoke check, and gitleaks over the outgoing range; +- **fixture provenance**: each fixture's source, commit and SHA-256 (`tests/fixtures/README.md`), + with the byte-equal assertions called out; +- the **model** the phase ran on; everything flagged and not fixed. + +The orchestrator re-verifies evidence before merging; numbers an implementer reports do not pass a +gate on their own. + +## Rollback tags + +Bracket every phase merge: `rollback/pre-produce-py-p` at the pre-merge anchor and +`rollback/produce-py-p-merged` at the merge commit. The orchestrator pushes them, not +implementation sessions. + +## Push guard + +A global pre-push guard (gitleaks plus a keyword list) scans the added lines of every outgoing +commit, so a fix on top does not clear an earlier commit: the flagged bytes must be absent from all +pushed history. Pushes go to the owner as one command, after `gitleaks git --log-opts="main..HEAD"` +over the outgoing range is clean. Never bypass the guard and never tune its patterns on your own +initiative. `.gitleaks.toml` allows only Ed25519 `did:key` identifiers, which are public keys. + +## Phrasing, commits, merges, releases + +- Neutral phrasing everywhere: no stakeholder, organisation or person is named. This repository is + public and its history is permanent. +- `git commit -s` on every commit; the `Signed-off-by:` email must equal the author email exactly. + Commits are signed (SSH). +- Work lands by PR to `main` as merge commits; never push to `main`. Merging is the orchestrator's + call on evidence in a gated sprint, the owner's otherwise. +- Publishing to PyPI is the owner's act, from a tested script with a `DRY_RUN` mode. +- A CLI upgrade reaches users as a wrapper release that moves the pin: `package.json`, + `package-lock.json` (`npm install --package-lock-only --ignore-scripts`) and `CLI_VERSION` + together; `tests/test_version.py` fails on any one left behind. +- `CHANGELOG.md` is a factual per-version record; changes collect under `## Unreleased`. diff --git a/README.md b/README.md new file mode 100644 index 0000000..34c35d3 --- /dev/null +++ b/README.md @@ -0,0 +1,128 @@ +# typedstandards + +Sign, withdraw, attest to, build views of and verify [Typed Standards](https://typedstandards.org) +records from Python. The package drives +[`@typedstandards/cli`](https://www.npmjs.com/package/@typedstandards/cli) 0.2.0 as a child +process. It holds no key, reads no signing seed, and computes none of the format's hashes: the CLI +does all of the format's work. + +## Install + +```sh +pip install typedstandards # or: uv add typedstandards +``` + +The wheel carries the CLI and its dependencies (vendored at build time from this repository's +`package-lock.json`), so installing needs nothing from npm. Running needs Node.js: the wrapper and +the CLI need Node 20.19 or later, and a `@typedstandards/host-core` site build needs Node 22 or +later. + +The wrapper looks for Node in this order: + +1. `TYPEDSTANDARDS_NODE`, when set: the path (or name on `PATH`) of a Node binary; +2. `node` on `PATH`. + +With no Node, or one older than 20.19.0, every call raises `typedstandards.NodeLocatorError`, whose +message names the floor and `TYPEDSTANDARDS_NODE`. + +Linux and macOS are tested. Windows is untested. + +## The signing key + +The CLI reads the signing seed (the standard base64 of a 32-byte Ed25519 seed) from one environment +variable, `TYPEDSTANDARDS_SIGNING_SEED_B64`, and from nowhere else. The wrapper never reads it: it +starts the CLI with the environment it inherited, and passes no environment of its own. Set the +variable from a secret store for the process that signs, for example: + +```sh +op run --env-file=signing.env -- jupyter lab +``` + +where `signing.env` maps `TYPEDSTANDARDS_SIGNING_SEED_B64` to a secret reference. Make a new seed +with: + +```sh +openssl rand -base64 32 +``` + +Only `sign`, `withdraw` and `attest` need it; `view` and `verify` do not. + +## Use + +```python +import typedstandards as ts + +signed = ts.sign( + { + "type": "content/analysis/v1", + "producerProfile": "scripted-recomputation/example", + "captureMethod": "script-run", + "prompt": "Recompute the summary table.", + "promptVisibility": "full_text", + "queries": [], + "dataSources": [], + "cost": {"model": "none"}, + "skillMetadata": {}, + "trace": {}, + "signer": {"bindingTier": "pseudonymous", "displayName": "Example analyst"}, + }, + output_file="analysis.ipynb", +) # signed inline under raw-bytes/v1 + +bundle = ts.view(signed, visibility="public", title="Example analysis") +result = ts.verify(bundle) # {ok, nodeId, failures, checks, lifecycle} +``` + +Each function returns the CLI's stdout parsed as JSON. An input may be a mapping (sent to the CLI +as JSON on standard input) or the path of a JSON file. + +| Function | CLI command | Returns | +|---|---|---| +| `sign(input, *, output_file=None, output_url=None, content_type=None)` | `sign` | `{package, envelopeHash, signature}` | +| `withdraw(input)` | `withdraw` | `{node, nodeId, signature}` | +| `attest(input)` | `attest` | `{node, nodeId, signature}` | +| `view(signed, *, visibility, attestations=(), trust_registry_url=None, package_url=None, title=None)` | `view` | the commitment view, package inline | +| `verify(input, *, blobs=(), full=True)` | `verify` (`--json` when `full`) | `{ok, nodeId, failures, checks, lifecycle}` | + +`view` writes the mappings it is given to temporary files, removed before it returns. The CLI's +[README](https://github.com/npstorey/typedstandards/tree/main/packages/cli#readme) describes each +command's inputs. What the CLI prints on stderr when it succeeds (attention readings, such as an +offline `registry_unavailable`) is logged at INFO on the `typedstandards` logger. + +`typedstandards.CLI_VERSION` is the version of the vendored CLI (`"0.2.0"`), and +`typedstandards.cli_version()` asks the vendored CLI for it. + +### Errors + +A non-zero exit raises a subclass of `typedstandards.CliError`, which carries `exit_code`, `stderr` +(the CLI's standard error) and `command`: + +| Exit | Exception | Meaning | +|---|---|---| +| 1 | `VerificationError` | a record, or the CLI's own result, did not verify; for `verify`, `.document` is the `{ok: false, ...}` verdict | +| 2 | `UsageError` | an argument or an input is wrong | +| 3 | `SeedError` | the seed's variable is missing or malformed | +| 4 | `InternalError` | an internal error in the CLI | + +### Verifying a served bundle + +`@typedstandards/host-core` inlines a top-level `trustRegistry` in every bundle it serves under a +registry, and CLI 0.2.0's `verify` refuses that key +([typedstandards#136](https://github.com/npstorey/typedstandards/issues/136)). Until the wrapper +pins a CLI that accepts it, `verify` drops a bundle's top-level `trustRegistry` before the CLI +sees the bundle, and changes nothing else. A test pins that the CLI receives the same document +minus that one key. + +## Versions + +Each wrapper release pins one CLI version exactly. A CLI upgrade reaches users as a wrapper release +that moves the pin. + +## Development + +See [CLAUDE.md](CLAUDE.md) for the development loop and the checks CI runs. + +## License + +MIT. The wheel also carries each vendored npm package's own licence file: five are MIT, and +`canonicalize` is Apache-2.0. diff --git a/hatch_build.py b/hatch_build.py new file mode 100644 index 0000000..f5a5b83 --- /dev/null +++ b/hatch_build.py @@ -0,0 +1,62 @@ +"""Vendor @typedstandards/cli into the wheel (G0 D1 = A). + +At every wheel build, standard or editable, this hook copies ``package.json`` and +``package-lock.json`` into ``src/typedstandards/_vendor/`` and runs +``npm ci --omit=dev --ignore-scripts`` there. The resulting ``node_modules`` tree, with +each package's own licence file, ships inside the wheel, so an installed wheel needs Node +and nothing from npm. An editable install (``uv sync``) runs the same hook, so tests drive +the same tree a user gets. + +Building needs ``npm`` on ``PATH`` and the npm registry; installing the wheel needs neither. +""" + +from __future__ import annotations + +import json +import shutil +import subprocess +from pathlib import Path +from typing import Any + +from hatchling.builders.hooks.plugin.interface import BuildHookInterface + +VENDOR = Path("src") / "typedstandards" / "_vendor" +LICENCE_NAMES = ("LICENSE", "LICENSE.md", "LICENSE.txt", "LICENCE", "LICENCE.md", "LICENCE.txt") + + +class VendorCliHook(BuildHookInterface): + PLUGIN_NAME = "custom" + + def initialize(self, version: str, build_data: dict[str, Any]) -> None: + if self.target_name != "wheel": + return + root = Path(self.root) + vendor = root / VENDOR + npm = shutil.which("npm") + if npm is None: + raise RuntimeError( + "building typedstandards needs npm on PATH: the build vendors @typedstandards/cli " + "with `npm ci --omit=dev --ignore-scripts` (installing the built wheel does not)" + ) + if vendor.exists(): + shutil.rmtree(vendor) + vendor.mkdir(parents=True) + for name in ("package.json", "package-lock.json"): + shutil.copyfile(root / name, vendor / name) + self.app.display_info(f"vendoring @typedstandards/cli: npm ci --omit=dev --ignore-scripts in {VENDOR}") + subprocess.run( + [npm, "ci", "--omit=dev", "--ignore-scripts", "--no-audit", "--no-fund"], + cwd=vendor, + check=True, + ) + modules = vendor / "node_modules" + # npm's .bin holds symlinks, which a wheel cannot carry; the wrapper runs the CLI's + # entry file with node, never through .bin. + shutil.rmtree(modules / ".bin", ignore_errors=True) + lock = json.loads((vendor / "package-lock.json").read_text(encoding="utf-8")) + for key in lock["packages"]: + if not key: + continue + package_dir = vendor / key + if not any((package_dir / name).is_file() for name in LICENCE_NAMES): + raise RuntimeError(f"{key} ships no licence file; the wheel must carry each vendored package's licence") diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..1dd1edb --- /dev/null +++ b/package-lock.json @@ -0,0 +1,96 @@ +{ + "name": "typedstandards-python-vendored-cli", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "typedstandards-python-vendored-cli", + "license": "MIT", + "dependencies": { + "@typedstandards/cli": "0.2.0" + } + }, + "node_modules/@noble/curves": { + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/@noble/curves/-/curves-2.4.0.tgz", + "integrity": "sha512-P4/62zrgfH33CneE3Dn4WhJVA22YUU0eR51wKIan4NVRvwsA0YnPTwWGpNbpuacSujmSFLvyzpyuR30+fbq2Ew==", + "license": "MIT", + "dependencies": { + "@noble/hashes": "2.4.0" + }, + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@noble/hashes": { + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-2.4.0.tgz", + "integrity": "sha512-X5XaVWZIBCT7HHZGm5I7ZQXDwLG+bGXuSrMQAW+7Zvl87h1kmc1ZB1VSRJcpUfoUrGQp4Fkoxm5kZ+Ms+aW+eA==", + "license": "MIT", + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@typedstandards/cli": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/@typedstandards/cli/-/cli-0.2.0.tgz", + "integrity": "sha512-hCoB8f8wmKDPZ3LtYRvahohmSvtAlFt6F5y4rI4aH9aDrdpuLeUBzcSmgx7A+9N5+i5T5POInty+O+uCTOalfg==", + "license": "MIT", + "dependencies": { + "@typedstandards/produce-core": "^0.8.0", + "@typedstandards/verify-core": "^0.13.0" + }, + "bin": { + "typedstandards": "dist/bin/main.js" + }, + "engines": { + "node": ">=20.19" + } + }, + "node_modules/@typedstandards/produce-core": { + "version": "0.8.0", + "resolved": "https://registry.npmjs.org/@typedstandards/produce-core/-/produce-core-0.8.0.tgz", + "integrity": "sha512-5d5xYi3XdfHEFERJ4K7/gtPUwpxhIzzEQqZI9/dYrlBZJQqFgarARVRXUbbHXaPO+1PQ3gFY/1nHjBEw6sD5Gg==", + "license": "MIT", + "dependencies": { + "@noble/curves": "^2.2.0", + "@typedstandards/verify-core": "^0.13.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@typedstandards/verify-core": { + "version": "0.13.0", + "resolved": "https://registry.npmjs.org/@typedstandards/verify-core/-/verify-core-0.13.0.tgz", + "integrity": "sha512-//bMjEiJH0+zSJg6LT3nEjQnFelbpOHPuD6T1ITygLeuS6QMFU1DChB+hFW8QLzxL02UhiUFwoCiLoOVcE2Aew==", + "license": "MIT", + "dependencies": { + "@noble/curves": "^2.2.0", + "@noble/hashes": "^2.2.0", + "canonicalize": "^3.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/canonicalize": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/canonicalize/-/canonicalize-3.0.0.tgz", + "integrity": "sha512-yYLfHyDMIXRyRqsKBRLX023riFLpXY2YOfdtqKXZRZy9qsfOJ9U+4F9YZL7MEzL5+ziN2x2nlBvY/Voi3EBljA==", + "license": "Apache-2.0", + "bin": { + "canonicalize": "bin/canonicalize.js" + }, + "engines": { + "node": ">=18" + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..c5b18cb --- /dev/null +++ b/package.json @@ -0,0 +1,9 @@ +{ + "name": "typedstandards-python-vendored-cli", + "private": true, + "description": "The CLI the typedstandards wheel vendors. The hatchling build hook (hatch_build.py) runs npm ci --omit=dev --ignore-scripts against this file and package-lock.json, and ships the tree inside the wheel.", + "license": "MIT", + "dependencies": { + "@typedstandards/cli": "0.2.0" + } +} diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..705e9ee --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,85 @@ +[build-system] +requires = ["hatchling==1.32.4"] +build-backend = "hatchling.build" + +[project] +name = "typedstandards" +dynamic = ["version"] +description = "Sign, withdraw, attest to, build views of and verify Typed Standards records from Python, through @typedstandards/cli as a child process." +readme = "README.md" +license = "MIT" +license-files = ["LICENSE"] +requires-python = ">=3.11" +authors = [{ name = "Nathan Storey" }] +keywords = ["typed-standards", "provenance", "signing", "verification", "notebook"] +classifiers = [ + "Development Status :: 3 - Alpha", + "Intended Audience :: Science/Research", + "Operating System :: MacOS", + "Operating System :: POSIX :: Linux", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3 :: Only", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.14", +] +# Declared now for P2's helpers (pin, sidecar); nothing in P1 imports either. +dependencies = [ + "httpx>=0.28", + "PyYAML>=6.0", +] + +[project.urls] +Homepage = "https://github.com/npstorey/typedstandards-python" +Issues = "https://github.com/npstorey/typedstandards-python/issues" + +[dependency-groups] +dev = [ + "pytest>=8.4", + "ruff>=0.14", +] + +[tool.hatch.version] +path = "src/typedstandards/__init__.py" + +[tool.hatch.build.targets.sdist] +include = [ + "/src/typedstandards", + "/tests", + "/scripts", + "/hatch_build.py", + "/package.json", + "/package-lock.json", + "/README.md", + "/CHANGELOG.md", + "/LICENSE", +] +exclude = ["/src/typedstandards/_vendor"] + +[tool.hatch.build.targets.wheel] +packages = ["src/typedstandards"] +# The vendored CLI is git-ignored, so it is named here to be shipped. +artifacts = ["src/typedstandards/_vendor/**"] + +[tool.hatch.build.hooks.custom] +path = "hatch_build.py" + +[tool.uv] +# Rebuild the editable install (and so re-run the vendoring hook) when any of these change. +cache-keys = [ + { file = "pyproject.toml" }, + { file = "package.json" }, + { file = "package-lock.json" }, + { file = "hatch_build.py" }, +] + +[tool.pytest.ini_options] +testpaths = ["tests"] +addopts = ["-ra", "--strict-markers"] + +[tool.ruff] +line-length = 120 +target-version = "py311" + +[tool.ruff.lint] +select = ["E", "F", "W", "I", "B", "UP"] diff --git a/scripts/smoke_wheel.py b/scripts/smoke_wheel.py new file mode 100644 index 0000000..2c47989 --- /dev/null +++ b/scripts/smoke_wheel.py @@ -0,0 +1,69 @@ +"""Smoke check for an installed typedstandards wheel, run in a fresh environment. + +Run it with that environment's Python, outside the source tree's import path, with a test seed in +the environment, as CI's wheel job does: + + TYPEDSTANDARDS_SIGNING_SEED_B64="$(openssl rand -base64 32)" /bin/python scripts/smoke_wheel.py + +It checks the import, CLI_VERSION, the vendored CLI's --version, the vendored tree's licences, and +one sign-then-verify round trip (sign, view, verify) through the vendored CLI. It reads no seed. +""" + +from __future__ import annotations + +import json +import sys +import sysconfig +from pathlib import Path + +import typedstandards + + +def main() -> int: + package = Path(typedstandards.__file__).resolve().parent + site = Path(sysconfig.get_paths()["purelib"]).resolve() + print(f"typedstandards {typedstandards.__version__} imported from {package}") + assert package.parent == site, f"imported from {package}, not this environment's site-packages {site}" + + assert typedstandards.CLI_VERSION == "0.2.0", typedstandards.CLI_VERSION + printed = typedstandards.cli_version() + print(f"CLI_VERSION {typedstandards.CLI_VERSION}; the vendored CLI's --version prints {printed}") + assert printed == typedstandards.CLI_VERSION + + entry = typedstandards.cli_entry() + assert entry.is_relative_to(package / "_vendor"), entry + vendor = package / "_vendor" + lock = json.loads((vendor / "package-lock.json").read_text(encoding="utf-8")) + vendored = [key for key in lock["packages"] if key] + for key in vendored: + assert (vendor / key / "LICENSE").is_file(), f"{key} has no licence file in the wheel" + assert not (vendor / "node_modules" / ".bin").exists() + print(f"{len(vendored)} vendored packages, each with its licence file") + print(f"node: {typedstandards.locate_node()}") + + record = { + "type": "content/analysis/v1", + "producerProfile": "scripted-recomputation/typedstandards-python-smoke", + "captureMethod": "script-run", + "prompt": "Smoke-check the installed wheel.", + "promptVisibility": "full_text", + "queries": [], + "dataSources": [], + "cost": {"model": "none"}, + "skillMetadata": {}, + "trace": {}, + "output": "The wheel signs and verifies.", + "signer": {"bindingTier": "pseudonymous", "displayName": "typedstandards-python smoke check"}, + } + signed = typedstandards.sign(record) + bundle = typedstandards.view(signed, visibility="public", title="Smoke check") + result = typedstandards.verify(bundle) + print(f"signed {signed['envelopeHash']}; verify ok={result['ok']} status={result['lifecycle']['status']}") + assert result["ok"] is True + assert result["nodeId"] == signed["envelopeHash"] + print("smoke check passed") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/src/typedstandards/__init__.py b/src/typedstandards/__init__.py new file mode 100644 index 0000000..776a9eb --- /dev/null +++ b/src/typedstandards/__init__.py @@ -0,0 +1,55 @@ +"""Typed Standards records from Python, through @typedstandards/cli as a child process. + +The package vendors @typedstandards/cli and runs it with a located Node binary. It holds no +key, reads no signing seed and computes none of the format's hashes: the CLI reads the seed +from the environment it inherits, and does all of the format's work. +""" + +from __future__ import annotations + +from ._cli import cli_entry +from ._cli import run as _run +from ._commands import attest, sign, verify, view, withdraw +from ._node import NODE_FLOOR, NODE_OVERRIDE, locate_node +from .errors import ( + CliError, + CliNotVendoredError, + InternalError, + NodeLocatorError, + SeedError, + UsageError, + VerificationError, +) + +__version__ = "0.1.0.dev0" + +#: The version of @typedstandards/cli this release vendors (package.json pins it exactly). +CLI_VERSION = "0.2.0" + + +def cli_version() -> str: + """The ``version`` the vendored CLI prints for ``--version``.""" + return _run("--version")["version"] + + +__all__ = [ + "CLI_VERSION", + "NODE_FLOOR", + "NODE_OVERRIDE", + "CliError", + "CliNotVendoredError", + "InternalError", + "NodeLocatorError", + "SeedError", + "UsageError", + "VerificationError", + "__version__", + "attest", + "cli_entry", + "cli_version", + "locate_node", + "sign", + "verify", + "view", + "withdraw", +] diff --git a/src/typedstandards/_cli.py b/src/typedstandards/_cli.py new file mode 100644 index 0000000..d85fb6c --- /dev/null +++ b/src/typedstandards/_cli.py @@ -0,0 +1,74 @@ +"""Run the vendored CLI as a child process and read its result.""" + +from __future__ import annotations + +import json +import logging +import subprocess +from collections.abc import Sequence +from pathlib import Path +from typing import Any + +from ._node import locate_node +from .errors import ( + CliError, + CliNotVendoredError, + InternalError, + SeedError, + UsageError, + VerificationError, +) + +#: The CLI's entry file inside the vendored tree that hatch_build.py writes. +CLI_ENTRY = Path(__file__).parent / "_vendor" / "node_modules" / "@typedstandards" / "cli" / "dist" / "bin" / "main.js" + +#: The CLI's exit codes (packages/cli/src/errors.ts:3-14 in typedstandards). 1 is handled apart, +#: since verify prints its verdict before exiting 1. +_RAISES: dict[int, type[CliError]] = {2: UsageError, 3: SeedError, 4: InternalError} + +_log = logging.getLogger("typedstandards") + + +def cli_entry() -> Path: + """The vendored CLI's entry file; raises :class:`CliNotVendoredError` when it is missing.""" + if not CLI_ENTRY.is_file(): + raise CliNotVendoredError( + f"the vendored @typedstandards/cli is missing ({CLI_ENTRY}); reinstall typedstandards, or in a " + "checkout run `uv sync --reinstall-package typedstandards`" + ) + return CLI_ENTRY + + +def _parse(stdout: bytes) -> Any: + return json.loads(stdout.decode("utf-8")) + + +def run(command: str, args: Sequence[str] = (), *, stdin: bytes | None = None) -> Any: + """Run one CLI command and return its stdout parsed as JSON. + + The child inherits this process's environment: no ``env`` is passed, and nothing here reads + or sets a variable for it. What the CLI writes on stderr when it succeeds (attention + readings, such as an offline ``registry_unavailable``) is logged at INFO on the + ``typedstandards`` logger. + """ + node = locate_node() + entry = cli_entry() + feed: dict[str, Any] = {"input": stdin} if stdin is not None else {"stdin": subprocess.DEVNULL} + proc = subprocess.run([node, str(entry), command, *args], capture_output=True, check=False, **feed) + stderr = proc.stderr.decode("utf-8", errors="replace") + code = proc.returncode + if code == 0: + for line in stderr.splitlines(): + _log.info("%s", line) + try: + return _parse(proc.stdout) + except ValueError as err: + raise CliError(0, f"stdout is not JSON ({err}); stderr: {stderr}", command) from err + if code == 1: + # verify prints {ok: false, ...} before exiting 1; sign, withdraw and attest print nothing. + try: + document = _parse(proc.stdout) if proc.stdout.strip() else None + except ValueError: + document = None + raise VerificationError(code, stderr, command, document) + raise _RAISES.get(code, CliError)(code, stderr, command) diff --git a/src/typedstandards/_commands.py b/src/typedstandards/_commands.py new file mode 100644 index 0000000..2fd56e9 --- /dev/null +++ b/src/typedstandards/_commands.py @@ -0,0 +1,156 @@ +"""The five pass-through commands: ``sign``, ``withdraw``, ``attest``, ``view``, ``verify``. + +Each takes the CLI's inputs as Python values and returns the CLI's stdout parsed as JSON. +An input given as a mapping is sent as JSON on standard input (``--input -``); a ``str`` or +``os.PathLike`` is a path the CLI reads. The wrapper computes nothing the format defines: the +CLI builds, signs, hashes and verifies. +""" + +from __future__ import annotations + +import json +import os +import tempfile +from collections.abc import Iterable, Mapping +from pathlib import Path +from typing import Any + +from ._cli import run + +#: An input: a JSON object as a mapping, or the path of a JSON file. +Input = Mapping[str, Any] | str | os.PathLike[str] + + +def _json_bytes(value: Mapping[str, Any]) -> bytes: + return json.dumps(value, ensure_ascii=False, allow_nan=False).encode("utf-8") + + +def _input_args(value: Input, flag: str) -> tuple[list[str], bytes | None]: + if isinstance(value, Mapping): + return [flag, "-"], _json_bytes(value) + if isinstance(value, (str, os.PathLike)): + return [flag, os.fspath(value)], None + raise TypeError(f"{flag} takes a mapping (sent as JSON on stdin) or a path, not {type(value).__name__}") + + +def _as_file(value: Input, directory: str, name: str, what: str) -> str: + if isinstance(value, Mapping): + path = Path(directory) / name + path.write_bytes(_json_bytes(value)) + return str(path) + if isinstance(value, (str, os.PathLike)): + return os.fspath(value) + raise TypeError(f"{what} takes a mapping or a path, not {type(value).__name__}") + + +def sign( + input: Input, + *, + output_file: str | os.PathLike[str] | None = None, + output_url: str | None = None, + content_type: str | None = None, +) -> dict[str, Any]: + """``typedstandards sign``: build and sign a record from an envelope input. + + ``output_file`` signs a file's bytes inline under ``raw-bytes/v1``; with ``output_url``, by + reference as a BlobRef (``content_type`` names its type). Returns + ``{package, envelopeHash, signature}``. The CLI reads the signing seed from its own + environment, which it inherits from this process. + """ + args, stdin = _input_args(input, "--input") + if output_file is not None: + args += ["--output-file", os.fspath(output_file)] + if output_url is not None: + args += ["--output-url", output_url] + if content_type is not None: + args += ["--content-type", content_type] + return run("sign", args, stdin=stdin) + + +def withdraw(input: Input) -> dict[str, Any]: + """``typedstandards withdraw``: sign an ``attestation/withdraws/v1``. Returns ``{node, nodeId, signature}``.""" + args, stdin = _input_args(input, "--input") + return run("withdraw", args, stdin=stdin) + + +def attest(input: Input) -> dict[str, Any]: + """``typedstandards attest``: sign a ``supersedes``, ``revises``, ``corroborates`` or ``contradicts`` + attestation. Returns ``{node, nodeId, signature}``.""" + args, stdin = _input_args(input, "--input") + return run("attest", args, stdin=stdin) + + +def view( + signed: Input, + *, + visibility: str, + attestations: Iterable[Input] = (), + trust_registry_url: str | None = None, + package_url: str | None = None, + title: str | None = None, +) -> dict[str, Any]: + """``typedstandards view``: build the commitment view a host serves, with the package inline. + + ``signed`` is what :func:`sign` returned (or its path); each of ``attestations`` is what + :func:`withdraw` or :func:`attest` returned (or its path). Mappings are written to temporary + files, removed before this returns, since only one input can be standard input. + """ + if isinstance(attestations, (Mapping, str, os.PathLike)): + raise TypeError("attestations takes a list of attestations, not one") + with tempfile.TemporaryDirectory(prefix="typedstandards-view-") as directory: + args = ["--signed", _as_file(signed, directory, "signed.json", "signed"), "--visibility", visibility] + for i, attestation in enumerate(attestations): + args += ["--attestation", _as_file(attestation, directory, f"attestation-{i}.json", "attestations")] + if trust_registry_url is not None: + args += ["--trust-registry-url", trust_registry_url] + if package_url is not None: + args += ["--package-url", package_url] + if title is not None: + args += ["--title", title] + return run("view", args) + + +def _without_trust_registry(value: Input) -> Input: + """G0 D9 = A, typedstandards#136: CLI 0.2.0's verify exits 2 on a bundle's top-level + ``trustRegistry``, which host-core inlines in every bundle it serves under a registry. Drop that + one key from a bundle (a document with ``packageHash``) and change nothing else; any other + document, and a bundle file without the key, reach the CLI as given. + + Remove this workaround when the wrapper pins a CLI whose verify accepts the key. + """ + document: Any = value + if not isinstance(value, Mapping): + if not isinstance(value, (str, os.PathLike)): + return value + try: + document = json.loads(Path(value).read_bytes()) + except (OSError, ValueError): + return value # the CLI reports an unreadable or malformed file + if isinstance(document, Mapping) and "packageHash" in document and "trustRegistry" in document: + return {key: item for key, item in document.items() if key != "trustRegistry"} + return value + + +def verify( + input: Input, + *, + blobs: Iterable[str | os.PathLike[str]] = (), + full: bool = True, +) -> dict[str, Any]: + """``typedstandards verify``: verify what :func:`sign` or :func:`view` printed, offline. + + ``full`` (the default) passes ``--json``, so the result carries every check's fields + (``checks``) and the lifecycle resolution (``lifecycle``) beside ``ok``, ``nodeId`` and + ``failures``. ``blobs`` are local files for the record's BlobRefs. A record that does not + verify raises :class:`~typedstandards.errors.VerificationError`, whose ``document`` is the + verdict. A bundle's top-level ``trustRegistry`` is dropped before the CLI sees it + (typedstandards#136); nothing else is changed. + """ + if isinstance(blobs, (str, os.PathLike)): + raise TypeError("blobs takes a list of paths, not one") + args, stdin = _input_args(_without_trust_registry(input), "--input") + for blob in blobs: + args += ["--blob", os.fspath(blob)] + if full: + args.append("--json") + return run("verify", args, stdin=stdin) diff --git a/src/typedstandards/_node.py b/src/typedstandards/_node.py new file mode 100644 index 0000000..37941c6 --- /dev/null +++ b/src/typedstandards/_node.py @@ -0,0 +1,68 @@ +"""Locate the Node binary that runs the vendored CLI. + +The override variable, when set, names the binary and is the only candidate; otherwise ``node`` +on ``PATH``. The candidate's ``node --version`` must be at least the CLI's floor. +""" + +from __future__ import annotations + +import os +import re +import shutil +import subprocess + +from .errors import NodeLocatorError + +#: The environment variable that names a Node binary, tried before ``node`` on ``PATH``. +NODE_OVERRIDE = "TYPEDSTANDARDS_NODE" + +#: ``engines.node`` of @typedstandards/cli 0.2.0: ``>=20.19``. +NODE_FLOOR = (20, 19, 0) + +_VERSION = re.compile(r"v(\d+)\.(\d+)\.(\d+)") + + +def _refuse(problem: str) -> NodeLocatorError: + floor = ".".join(map(str, NODE_FLOOR[:2])) + return NodeLocatorError( + f"typedstandards needs Node.js {floor} or later to run @typedstandards/cli: {problem}. " + f"Install Node {floor} or later, or set {NODE_OVERRIDE} to the path of a Node {floor}+ binary." + ) + + +def _version_of(binary: str) -> tuple[int, int, int]: + try: + proc = subprocess.run( + [binary, "--version"], capture_output=True, text=True, check=False, timeout=30, stdin=subprocess.DEVNULL + ) + except (OSError, subprocess.SubprocessError) as err: + raise _refuse(f"{binary} --version could not run ({err})") from err + printed = proc.stdout.strip() + match = _VERSION.fullmatch(printed) + if proc.returncode != 0 or match is None: + raise _refuse(f"{binary} --version printed {printed!r} (exit {proc.returncode}), not a Node version") + major, minor, patch = (int(part) for part in match.groups()) + return major, minor, patch + + +def locate_node() -> str: + """Return the path of the Node binary to run the CLI with. + + Raises :class:`~typedstandards.errors.NodeLocatorError`, naming the floor and the override + variable, when no Node is found or the one found is below the floor. + """ + override = os.environ.get(NODE_OVERRIDE) + if override: + binary = shutil.which(override) + if binary is None: + raise _refuse(f"{NODE_OVERRIDE} is set to {override!r}, which is not an executable file") + source = f"{NODE_OVERRIDE} ({binary})" + else: + binary = shutil.which("node") + if binary is None: + raise _refuse(f"no node on PATH, and {NODE_OVERRIDE} is not set") + source = f"node on PATH ({binary})" + version = _version_of(binary) + if version < NODE_FLOOR: + raise _refuse(f"{source} is v{'.'.join(map(str, version))}") + return binary diff --git a/src/typedstandards/errors.py b/src/typedstandards/errors.py new file mode 100644 index 0000000..4946046 --- /dev/null +++ b/src/typedstandards/errors.py @@ -0,0 +1,58 @@ +"""The exceptions the wrapper raises. + +The CLI's exit codes 1 to 4 (``packages/cli/src/errors.ts`` in typedstandards) map to the +four subclasses of :class:`CliError`, each carrying the exit code and the CLI's stderr text. +A missing or too-old Node is a :class:`NodeLocatorError`, raised before the CLI runs. +""" + +from __future__ import annotations + +from typing import Any + + +class NodeLocatorError(RuntimeError): + """No usable Node binary: none was found, or the one found is below the CLI's floor.""" + + +class CliNotVendoredError(RuntimeError): + """The vendored CLI is missing from the installed package.""" + + +class CliError(Exception): + """The CLI exited with a code other than 0. + + ``exit_code`` is the CLI's exit code, ``stderr`` its standard error as text, and + ``command`` the CLI command that ran. + """ + + def __init__(self, exit_code: int, stderr: str, command: str) -> None: + self.exit_code = exit_code + self.stderr = stderr + self.command = command + detail = stderr.strip() or "(nothing on stderr)" + super().__init__(f"typedstandards {command} exited {exit_code}: {detail}") + + +class VerificationError(CliError): + """Exit 1: a record, or the CLI's own result, did not verify. + + ``document`` is what the CLI printed on stdout, parsed: ``verify`` prints its + ``{ok: false, ...}`` verdict; ``sign``, ``withdraw`` and ``attest`` print nothing, so it is + ``None`` for them. + """ + + def __init__(self, exit_code: int, stderr: str, command: str, document: Any = None) -> None: + super().__init__(exit_code, stderr, command) + self.document = document + + +class UsageError(CliError): + """Exit 2: an argument or an input is wrong.""" + + +class SeedError(CliError): + """Exit 3: the signing seed's environment variable is missing or malformed.""" + + +class InternalError(CliError): + """Exit 4: an internal error in the CLI.""" diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..62adf99 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,40 @@ +"""Fixtures: a test seed in the environment, and a recorder of the wrapper's child processes.""" + +from __future__ import annotations + +import os +import subprocess +from typing import Any + +import pytest +from support import SEED_VARIABLE, fresh_seed_b64 + + +@pytest.fixture +def seed(monkeypatch: pytest.MonkeyPatch) -> str: + """Set a fresh seed in the environment the wrapper's child inherits.""" + value = fresh_seed_b64() + monkeypatch.setenv(SEED_VARIABLE, value) + return value + + +@pytest.fixture +def spawned(monkeypatch: pytest.MonkeyPatch) -> list[dict[str, Any]]: + """Record every child process the wrapper starts: its argv, its keyword arguments, the + stdin bytes it was given, and os.environ at that moment. The call still runs.""" + calls: list[dict[str, Any]] = [] + real_popen = subprocess.Popen + real_communicate = subprocess.Popen.communicate + + class RecordingPopen(real_popen): # type: ignore[misc, valid-type] + def __init__(self, args: Any, *rest: Any, **kwargs: Any) -> None: + self._record = {"args": list(args), "kwargs": dict(kwargs), "environ": dict(os.environ), "stdin": None} + calls.append(self._record) + super().__init__(args, *rest, **kwargs) + + def communicate(self, input: Any = None, timeout: Any = None) -> Any: + self._record["stdin"] = input + return real_communicate(self, input, timeout) + + monkeypatch.setattr(subprocess, "Popen", RecordingPopen) + return calls diff --git a/tests/fixtures/README.md b/tests/fixtures/README.md new file mode 100644 index 0000000..77c6c49 --- /dev/null +++ b/tests/fixtures/README.md @@ -0,0 +1,21 @@ +# Test fixtures + +Each file is a verbatim copy, byte for byte, of a file in another repository at a stated commit. +A test pins each copy's SHA-256, so a changed byte fails the suite. JSON takes no comments, so the +provenance is recorded here. + +| File | Source | Read at | Last changed at | SHA-256 | Pinned by | +|---|---|---|---|---|---| +| `reference-golden.json` | `npstorey/typedstandards`, `packages/produce-core/src/__fixtures__/reference-golden.json` | `116882a` | `ea75a1d` | `d2bcfc2bc017b07502b3b00c3aa16de402df134128a374b4582650b79fb501c1` | `tests/test_golden.py` | +| `first-note.bundle.json` | `npstorey/typedstandards-host-template`, `docs/bundles/first-note.bundle.json` | `70bfd18` | `26dff9b` | `cb11d2a229c9695db6c7f4d6c9349ccee14f14af6c39844886480699ba2401ba` | `tests/test_d9.py` | + +Re-derive either copy with `git -C show : > tests/fixtures/`. + +- **`reference-golden.json`** holds 9 envelope cases and 6 attestation cases, captured from the + reference implementation as its `_meta` records. No npm tarball ships it, so the tests carry + this copy. `tests/test_golden.py` replays the 9 envelope cases through `sign` and the + `withdraws` case through `withdraw`. It carries the reference platform's own identifiers, as it + does in its source repository. +- **`first-note.bundle.json`** is the bundle the host template serves, written by + `@typedstandards/host-core` 0.1.1 with a top-level `trustRegistry`. `tests/test_d9.py` verifies + it through the wrapper, which drops that key before the CLI sees it (typedstandards#136). diff --git a/tests/fixtures/first-note.bundle.json b/tests/fixtures/first-note.bundle.json new file mode 100644 index 0000000..ca55d91 --- /dev/null +++ b/tests/fixtures/first-note.bundle.json @@ -0,0 +1,78 @@ +{ + "protocolVersion": "0.1.0", + "packageHash": "7e60369584c819074ca8148f1173478e3bdd11ce520ce9bdad1f2a117e9804cb", + "visibility": "public", + "captureMethod": "script-run", + "contentProfile": "default", + "producerProfile": "scripted-recomputation/host-template", + "type": "content/analysis/v1", + "signer": { + "bindingTier": "pseudonymous", + "displayName": "typedstandards-host-template", + "identifier": "did:key:z6Mks7BK2kyVhoPY3ayt6ALKeZY5eCbu64XyQuje9gTUxiUB" + }, + "contentHash": { + "sha256": "33f9c6829114d5a71e9acf99c74b0fb2ea957963f60aa496c0d508a7081f7ada" + }, + "contentCanonicalization": "https://typedstandards.org/canonicalization/raw-bytes/v1", + "signature": { + "signature": "c5sQFF5UznkjmNxgFsJLgvrHzkYmO6mTAanSWvFJyKN1JhwLTdPVA+joimeQxG0o5KIbaaifW9CFVuMh2xMrCg==", + "publicKey": "MCowBQYDK2VwAyEAvAL2Gnw9fYcVnr8jA68102bdP43R5Nfck/RmIDrPazQ=", + "algorithm": "Ed25519ph", + "kid": "did:key:z6Mks7BK2kyVhoPY3ayt6ALKeZY5eCbu64XyQuje9gTUxiUB" + }, + "trustRegistryUrl": "https://host-template.typedstandards.org/.well-known/typed-publisher.json", + "subjectTitle": "A first signed note", + "subjectSummary": null, + "trustRegistry": { + "$comment": "This template's own statement about the signing key of its example record, served at https://host-template.typedstandards.org/.well-known/typed-publisher.json. The key is a pseudonymous did:key, generated for one signature and deleted after it. This registry is not a Typed Standards record, and not an endorsement by the Typed Standards specification or by typedstandards.org, although this host is a subdomain of it. activatedAt is the earliest createdAt among the records this host serves, as the signer states it.", + "keys": [ + { + "kid": "did:key:z6Mks7BK2kyVhoPY3ayt6ALKeZY5eCbu64XyQuje9gTUxiUB", + "publicKey": "MCowBQYDK2VwAyEAvAL2Gnw9fYcVnr8jA68102bdP43R5Nfck/RmIDrPazQ=", + "status": "active", + "activatedAt": "2026-09-29T12:59:35.367Z", + "deprecatedAt": null, + "revokedAt": null, + "signerIdentity": { + "bindingTier": "pseudonymous", + "identifier": "did:key:z6Mks7BK2kyVhoPY3ayt6ALKeZY5eCbu64XyQuje9gTUxiUB", + "displayName": "typedstandards-host-template" + } + } + ] + }, + "package": { + "metadata": { + "schemaVersion": "0.1.0", + "packageId": "39fc687f-3674-4bed-9b66-25caa5d299e2", + "createdAt": "2026-09-29T12:59:35.367Z", + "signingKeyId": "did:key:z6Mks7BK2kyVhoPY3ayt6ALKeZY5eCbu64XyQuje9gTUxiUB", + "captureMethod": "script-run" + }, + "producerProfile": "scripted-recomputation/host-template", + "type": "content/analysis/v1", + "signer": { + "bindingTier": "pseudonymous", + "displayName": "typedstandards-host-template", + "identifier": "did:key:z6Mks7BK2kyVhoPY3ayt6ALKeZY5eCbu64XyQuje9gTUxiUB" + }, + "contentCanonicalization": "https://typedstandards.org/canonicalization/raw-bytes/v1", + "prompt": { + "hash": "cc854b47ec831268766610e8f3fd49ce561e2b6faf31d63980198c73e3dd4030", + "visibility": "full_text", + "text": "Sign the note as a file read from disk." + }, + "queries": [], + "dataSources": [], + "cost": { + "model": "none" + }, + "skillMetadata": {}, + "output": "# A first signed note\n\nThis note is the example record of the typedstandards-host-template repository.\nIt was signed under `raw-bytes/v1` with a throwaway key, generated for this one\nsignature and deleted after it, so no other record and no withdrawal can be\nsigned under its `did:key`.\n\nReplace it with your own record when you copy the template.\n", + "trace": {}, + "contentHash": { + "sha256": "33f9c6829114d5a71e9acf99c74b0fb2ea957963f60aa496c0d508a7081f7ada" + } + } +} diff --git a/tests/fixtures/reference-golden.json b/tests/fixtures/reference-golden.json new file mode 100644 index 0000000..4aa7793 --- /dev/null +++ b/tests/fixtures/reference-golden.json @@ -0,0 +1,1151 @@ +{ + "_meta": { + "description": "Byte-golden expectations captured from the reference implementation (civic-ai-tools-website src/lib/evidence packager.ts / attestation.ts) with clock, RNG, and key-id env stubbed to fixed values. Each case names the reference test(s) whose input it replicates. For equivalent inputs, produce-core must emit byte-identical serialized JSON, content hashes, and envelope hashes.", + "referenceRepo": "civic-ai-tools-website", + "referenceCommit": "d39fdc17e8e237b5cac225e83cf7ca686b42b115", + "capturedAt": "2026-01-02T03:04:05.000Z (stubbed instant; capture run 2026-07-31)", + "determinism": { + "packageId": "11111111-2222-4333-8444-555555555555", + "createdAt": "2026-01-02T03:04:05.000Z", + "signingKeyId": "adopter:test-key-1" + } + }, + "envelopeCases": [ + { + "name": "legacy-inline", + "sourceTests": [ + "civic-ai-tools-website src/lib/evidence/packager.test.ts :: buildEvidencePackage: inline output + inline trace produces a package hash", + "civic-ai-tools-website src/lib/evidence/packager.test.ts :: buildEvidencePackage: legacy input (no type) emits NO contentHash/contentCanonicalization and hashes via JSON.stringify (byte-identical)", + "civic-ai-tools-website src/lib/evidence/packager.test.ts :: round-trip: legacy package re-verifies byte-identical (legacy detection chain)" + ], + "input": { + "packageId": "11111111-2222-4333-8444-555555555555", + "createdAt": "2026-01-02T03:04:05.000Z", + "signingKeyId": "adopter:test-key-1", + "prompt": "How many 311 noise complaints last year?", + "promptVisibility": "full_text", + "queries": [ + { + "tool": "get_data", + "operationType": "query", + "arguments": { + "type": "query", + "portal": "data.cityofnewyork.us", + "dataset_id": "erm2-nwe9", + "select": "count(*)" + }, + "datasetId": "erm2-nwe9", + "portal": "data.cityofnewyork.us", + "resultRows": 1, + "resultColumns": 1 + } + ], + "dataSources": [ + { + "sourceId": "socrata", + "catalogType": "socrata", + "portalUrl": "https://data.cityofnewyork.us", + "datasetId": "erm2-nwe9", + "datasetUrl": "https://data.cityofnewyork.us/d/erm2-nwe9", + "accessTimestamp": "2026-01-02T03:04:05.000Z" + } + ], + "cost": { + "promptTokens": 100, + "completionTokens": 20, + "totalTokens": 120, + "model": "openai/gpt-4o" + }, + "skillMetadata": {}, + "output": "Around 400,000.", + "trace": { + "resourceSpans": [] + }, + "provenance": { + "@context": { + "prov": "http://www.w3.org/ns/prov#", + "xsd": "http://www.w3.org/2001/XMLSchema#", + "civic": "https://civicaitools.org/ns/evidence/", + "dcterms": "http://purl.org/dc/terms/" + }, + "@graph": [ + { + "@id": "urn:civic-evidence:11111111-2222-4333-8444-555555555555:prompt:3e7a528652636c5c2f9f9dab2eb23881f15dcdfae1fb446fba1389b15b9e43c0", + "@type": "prov:Entity", + "civic:contentHash": "sha256:3e7a528652636c5c2f9f9dab2eb23881f15dcdfae1fb446fba1389b15b9e43c0", + "dcterms:description": "User query prompt", + "prov:value": "How many 311 noise complaints last year?" + }, + { + "@id": "urn:civic-evidence:11111111-2222-4333-8444-555555555555:output:97166bfed6ba462fd890e9f5cde53e4a359efad8a92286705f959c69e157f7d2", + "@type": "prov:Entity", + "civic:contentHash": "sha256:97166bfed6ba462fd890e9f5cde53e4a359efad8a92286705f959c69e157f7d2", + "dcterms:description": "AI-generated analysis output" + }, + { + "@id": "urn:civic-evidence:model:openai-gpt-4o", + "@type": [ + "prov:Agent", + "prov:SoftwareAgent" + ], + "dcterms:title": "openai/gpt-4o", + "dcterms:description": "Large language model via OpenRouter" + }, + { + "@id": "urn:civic-evidence:platform:civic-ai-tools", + "@type": [ + "prov:Agent", + "prov:SoftwareAgent" + ], + "dcterms:title": "Civic AI Tools", + "civic:url": "https://civicaitools.org" + } + ] + } + }, + "expected": { + "serializedJson": "{\"metadata\":{\"schemaVersion\":\"0.1.0\",\"packageId\":\"11111111-2222-4333-8444-555555555555\",\"createdAt\":\"2026-01-02T03:04:05.000Z\",\"signingKeyId\":\"adopter:test-key-1\"},\"prompt\":{\"hash\":\"3e7a528652636c5c2f9f9dab2eb23881f15dcdfae1fb446fba1389b15b9e43c0\",\"visibility\":\"full_text\",\"text\":\"How many 311 noise complaints last year?\"},\"queries\":[{\"tool\":\"get_data\",\"operationType\":\"query\",\"arguments\":{\"type\":\"query\",\"portal\":\"data.cityofnewyork.us\",\"dataset_id\":\"erm2-nwe9\",\"select\":\"count(*)\"},\"datasetId\":\"erm2-nwe9\",\"portal\":\"data.cityofnewyork.us\",\"resultRows\":1,\"resultColumns\":1}],\"dataSources\":[{\"sourceId\":\"socrata\",\"catalogType\":\"socrata\",\"portalUrl\":\"https://data.cityofnewyork.us\",\"datasetId\":\"erm2-nwe9\",\"datasetUrl\":\"https://data.cityofnewyork.us/d/erm2-nwe9\",\"accessTimestamp\":\"2026-01-02T03:04:05.000Z\"}],\"cost\":{\"promptTokens\":100,\"completionTokens\":20,\"totalTokens\":120,\"model\":\"openai/gpt-4o\"},\"skillMetadata\":{},\"output\":\"Around 400,000.\",\"trace\":{\"resourceSpans\":[]},\"provenance\":{\"@context\":{\"prov\":\"http://www.w3.org/ns/prov#\",\"xsd\":\"http://www.w3.org/2001/XMLSchema#\",\"civic\":\"https://civicaitools.org/ns/evidence/\",\"dcterms\":\"http://purl.org/dc/terms/\"},\"@graph\":[{\"@id\":\"urn:civic-evidence:11111111-2222-4333-8444-555555555555:prompt:3e7a528652636c5c2f9f9dab2eb23881f15dcdfae1fb446fba1389b15b9e43c0\",\"@type\":\"prov:Entity\",\"civic:contentHash\":\"sha256:3e7a528652636c5c2f9f9dab2eb23881f15dcdfae1fb446fba1389b15b9e43c0\",\"dcterms:description\":\"User query prompt\",\"prov:value\":\"How many 311 noise complaints last year?\"},{\"@id\":\"urn:civic-evidence:11111111-2222-4333-8444-555555555555:output:97166bfed6ba462fd890e9f5cde53e4a359efad8a92286705f959c69e157f7d2\",\"@type\":\"prov:Entity\",\"civic:contentHash\":\"sha256:97166bfed6ba462fd890e9f5cde53e4a359efad8a92286705f959c69e157f7d2\",\"dcterms:description\":\"AI-generated analysis output\"},{\"@id\":\"urn:civic-evidence:model:openai-gpt-4o\",\"@type\":[\"prov:Agent\",\"prov:SoftwareAgent\"],\"dcterms:title\":\"openai/gpt-4o\",\"dcterms:description\":\"Large language model via OpenRouter\"},{\"@id\":\"urn:civic-evidence:platform:civic-ai-tools\",\"@type\":[\"prov:Agent\",\"prov:SoftwareAgent\"],\"dcterms:title\":\"Civic AI Tools\",\"civic:url\":\"https://civicaitools.org\"}]}}", + "contentHashSha256": null, + "envelopeHash": "42c6a118790b7e438ed5285502980a05e7e4253e915880ae357f93d6416e484c" + } + }, + { + "name": "legacy-capture-method", + "sourceTests": [ + "civic-ai-tools-website src/lib/evidence/packager.test.ts :: buildEvidencePackage: with captureMethod, metadata.captureMethod matches input", + "civic-ai-tools-website src/lib/evidence/packager.test.ts :: buildEvidencePackage: chat-flow-stream WITHOUT contentProfile does NOT emit summary in canonical JSON (backwards-compat)", + "civic-ai-tools-website src/lib/evidence/packager.test.ts :: buildEvidencePackage: chat-flow-stream WITHOUT contentProfile does NOT emit org.civicaitools.environment extension (backwards-compat)" + ], + "input": { + "packageId": "11111111-2222-4333-8444-555555555555", + "createdAt": "2026-01-02T03:04:05.000Z", + "signingKeyId": "adopter:test-key-1", + "prompt": "How many 311 noise complaints last year?", + "promptVisibility": "full_text", + "queries": [ + { + "tool": "get_data", + "operationType": "query", + "arguments": { + "type": "query", + "portal": "data.cityofnewyork.us", + "dataset_id": "erm2-nwe9", + "select": "count(*)" + }, + "datasetId": "erm2-nwe9", + "portal": "data.cityofnewyork.us", + "resultRows": 1, + "resultColumns": 1 + } + ], + "dataSources": [ + { + "sourceId": "socrata", + "catalogType": "socrata", + "portalUrl": "https://data.cityofnewyork.us", + "datasetId": "erm2-nwe9", + "datasetUrl": "https://data.cityofnewyork.us/d/erm2-nwe9", + "accessTimestamp": "2026-01-02T03:04:05.000Z" + } + ], + "cost": { + "promptTokens": 100, + "completionTokens": 20, + "totalTokens": 120, + "model": "openai/gpt-4o" + }, + "skillMetadata": {}, + "output": "Around 400,000.", + "trace": { + "resourceSpans": [] + }, + "captureMethod": "chat-flow-stream", + "provenance": { + "@context": { + "prov": "http://www.w3.org/ns/prov#", + "xsd": "http://www.w3.org/2001/XMLSchema#", + "civic": "https://civicaitools.org/ns/evidence/", + "dcterms": "http://purl.org/dc/terms/" + }, + "@graph": [ + { + "@id": "urn:civic-evidence:11111111-2222-4333-8444-555555555555:prompt:3e7a528652636c5c2f9f9dab2eb23881f15dcdfae1fb446fba1389b15b9e43c0", + "@type": "prov:Entity", + "civic:contentHash": "sha256:3e7a528652636c5c2f9f9dab2eb23881f15dcdfae1fb446fba1389b15b9e43c0", + "dcterms:description": "User query prompt", + "prov:value": "How many 311 noise complaints last year?" + }, + { + "@id": "urn:civic-evidence:11111111-2222-4333-8444-555555555555:output:97166bfed6ba462fd890e9f5cde53e4a359efad8a92286705f959c69e157f7d2", + "@type": "prov:Entity", + "civic:contentHash": "sha256:97166bfed6ba462fd890e9f5cde53e4a359efad8a92286705f959c69e157f7d2", + "dcterms:description": "AI-generated analysis output" + }, + { + "@id": "urn:civic-evidence:model:openai-gpt-4o", + "@type": [ + "prov:Agent", + "prov:SoftwareAgent" + ], + "dcterms:title": "openai/gpt-4o", + "dcterms:description": "Large language model via OpenRouter" + }, + { + "@id": "urn:civic-evidence:platform:civic-ai-tools", + "@type": [ + "prov:Agent", + "prov:SoftwareAgent" + ], + "dcterms:title": "Civic AI Tools", + "civic:url": "https://civicaitools.org" + } + ] + } + }, + "expected": { + "serializedJson": "{\"metadata\":{\"schemaVersion\":\"0.1.0\",\"packageId\":\"11111111-2222-4333-8444-555555555555\",\"createdAt\":\"2026-01-02T03:04:05.000Z\",\"signingKeyId\":\"adopter:test-key-1\",\"captureMethod\":\"chat-flow-stream\"},\"prompt\":{\"hash\":\"3e7a528652636c5c2f9f9dab2eb23881f15dcdfae1fb446fba1389b15b9e43c0\",\"visibility\":\"full_text\",\"text\":\"How many 311 noise complaints last year?\"},\"queries\":[{\"tool\":\"get_data\",\"operationType\":\"query\",\"arguments\":{\"type\":\"query\",\"portal\":\"data.cityofnewyork.us\",\"dataset_id\":\"erm2-nwe9\",\"select\":\"count(*)\"},\"datasetId\":\"erm2-nwe9\",\"portal\":\"data.cityofnewyork.us\",\"resultRows\":1,\"resultColumns\":1}],\"dataSources\":[{\"sourceId\":\"socrata\",\"catalogType\":\"socrata\",\"portalUrl\":\"https://data.cityofnewyork.us\",\"datasetId\":\"erm2-nwe9\",\"datasetUrl\":\"https://data.cityofnewyork.us/d/erm2-nwe9\",\"accessTimestamp\":\"2026-01-02T03:04:05.000Z\"}],\"cost\":{\"promptTokens\":100,\"completionTokens\":20,\"totalTokens\":120,\"model\":\"openai/gpt-4o\"},\"skillMetadata\":{},\"output\":\"Around 400,000.\",\"trace\":{\"resourceSpans\":[]},\"provenance\":{\"@context\":{\"prov\":\"http://www.w3.org/ns/prov#\",\"xsd\":\"http://www.w3.org/2001/XMLSchema#\",\"civic\":\"https://civicaitools.org/ns/evidence/\",\"dcterms\":\"http://purl.org/dc/terms/\"},\"@graph\":[{\"@id\":\"urn:civic-evidence:11111111-2222-4333-8444-555555555555:prompt:3e7a528652636c5c2f9f9dab2eb23881f15dcdfae1fb446fba1389b15b9e43c0\",\"@type\":\"prov:Entity\",\"civic:contentHash\":\"sha256:3e7a528652636c5c2f9f9dab2eb23881f15dcdfae1fb446fba1389b15b9e43c0\",\"dcterms:description\":\"User query prompt\",\"prov:value\":\"How many 311 noise complaints last year?\"},{\"@id\":\"urn:civic-evidence:11111111-2222-4333-8444-555555555555:output:97166bfed6ba462fd890e9f5cde53e4a359efad8a92286705f959c69e157f7d2\",\"@type\":\"prov:Entity\",\"civic:contentHash\":\"sha256:97166bfed6ba462fd890e9f5cde53e4a359efad8a92286705f959c69e157f7d2\",\"dcterms:description\":\"AI-generated analysis output\"},{\"@id\":\"urn:civic-evidence:model:openai-gpt-4o\",\"@type\":[\"prov:Agent\",\"prov:SoftwareAgent\"],\"dcterms:title\":\"openai/gpt-4o\",\"dcterms:description\":\"Large language model via OpenRouter\"},{\"@id\":\"urn:civic-evidence:platform:civic-ai-tools\",\"@type\":[\"prov:Agent\",\"prov:SoftwareAgent\"],\"dcterms:title\":\"Civic AI Tools\",\"civic:url\":\"https://civicaitools.org\"}]}}", + "contentHashSha256": null, + "envelopeHash": "5c9cfdfc59ee45b16f3c65a2b07288bdeeb2ffe499fb88a1d8f91b478d887ba4" + } + }, + { + "name": "v01-default", + "sourceTests": [ + "civic-ai-tools-website src/lib/evidence/packager.test.ts :: buildEvidencePackage: v0.1 input (type present) emits legacy-json/v1 rule + multihash contentHash, hashes via JCS", + "civic-ai-tools-website src/lib/evidence/packager.test.ts :: buildEvidencePackage: v0.1 default contentHash fingerprints the package minus contentHash (legacy-json/v1)", + "civic-ai-tools-website src/lib/evidence/packager.test.ts :: round-trip: v0.1 default package re-verifies (JCS detection chain)" + ], + "input": { + "packageId": "11111111-2222-4333-8444-555555555555", + "createdAt": "2026-01-02T03:04:05.000Z", + "signingKeyId": "adopter:test-key-1", + "prompt": "How many 311 noise complaints last year?", + "promptVisibility": "full_text", + "queries": [ + { + "tool": "get_data", + "operationType": "query", + "arguments": { + "type": "query", + "portal": "data.cityofnewyork.us", + "dataset_id": "erm2-nwe9", + "select": "count(*)" + }, + "datasetId": "erm2-nwe9", + "portal": "data.cityofnewyork.us", + "resultRows": 1, + "resultColumns": 1 + } + ], + "dataSources": [ + { + "sourceId": "socrata", + "catalogType": "socrata", + "portalUrl": "https://data.cityofnewyork.us", + "datasetId": "erm2-nwe9", + "datasetUrl": "https://data.cityofnewyork.us/d/erm2-nwe9", + "accessTimestamp": "2026-01-02T03:04:05.000Z" + } + ], + "cost": { + "promptTokens": 100, + "completionTokens": 20, + "totalTokens": 120, + "model": "openai/gpt-4o" + }, + "skillMetadata": {}, + "output": "Around 400,000.", + "trace": { + "resourceSpans": [] + }, + "type": "content/analysis/v1", + "contentCanonicalization": "https://typedstandards.org/canonicalization/legacy-json/v1", + "provenance": { + "@context": { + "prov": "http://www.w3.org/ns/prov#", + "xsd": "http://www.w3.org/2001/XMLSchema#", + "civic": "https://civicaitools.org/ns/evidence/", + "dcterms": "http://purl.org/dc/terms/" + }, + "@graph": [ + { + "@id": "urn:civic-evidence:11111111-2222-4333-8444-555555555555:prompt:3e7a528652636c5c2f9f9dab2eb23881f15dcdfae1fb446fba1389b15b9e43c0", + "@type": "prov:Entity", + "civic:contentHash": "sha256:3e7a528652636c5c2f9f9dab2eb23881f15dcdfae1fb446fba1389b15b9e43c0", + "dcterms:description": "User query prompt", + "prov:value": "How many 311 noise complaints last year?" + }, + { + "@id": "urn:civic-evidence:11111111-2222-4333-8444-555555555555:output:97166bfed6ba462fd890e9f5cde53e4a359efad8a92286705f959c69e157f7d2", + "@type": "prov:Entity", + "civic:contentHash": "sha256:97166bfed6ba462fd890e9f5cde53e4a359efad8a92286705f959c69e157f7d2", + "dcterms:description": "AI-generated analysis output" + }, + { + "@id": "urn:civic-evidence:model:openai-gpt-4o", + "@type": [ + "prov:Agent", + "prov:SoftwareAgent" + ], + "dcterms:title": "openai/gpt-4o", + "dcterms:description": "Large language model via OpenRouter" + }, + { + "@id": "urn:civic-evidence:platform:civic-ai-tools", + "@type": [ + "prov:Agent", + "prov:SoftwareAgent" + ], + "dcterms:title": "Civic AI Tools", + "civic:url": "https://civicaitools.org" + } + ] + } + }, + "expected": { + "serializedJson": "{\"metadata\":{\"schemaVersion\":\"0.1.0\",\"packageId\":\"11111111-2222-4333-8444-555555555555\",\"createdAt\":\"2026-01-02T03:04:05.000Z\",\"signingKeyId\":\"adopter:test-key-1\"},\"type\":\"content/analysis/v1\",\"contentCanonicalization\":\"https://typedstandards.org/canonicalization/legacy-json/v1\",\"prompt\":{\"hash\":\"3e7a528652636c5c2f9f9dab2eb23881f15dcdfae1fb446fba1389b15b9e43c0\",\"visibility\":\"full_text\",\"text\":\"How many 311 noise complaints last year?\"},\"queries\":[{\"tool\":\"get_data\",\"operationType\":\"query\",\"arguments\":{\"type\":\"query\",\"portal\":\"data.cityofnewyork.us\",\"dataset_id\":\"erm2-nwe9\",\"select\":\"count(*)\"},\"datasetId\":\"erm2-nwe9\",\"portal\":\"data.cityofnewyork.us\",\"resultRows\":1,\"resultColumns\":1}],\"dataSources\":[{\"sourceId\":\"socrata\",\"catalogType\":\"socrata\",\"portalUrl\":\"https://data.cityofnewyork.us\",\"datasetId\":\"erm2-nwe9\",\"datasetUrl\":\"https://data.cityofnewyork.us/d/erm2-nwe9\",\"accessTimestamp\":\"2026-01-02T03:04:05.000Z\"}],\"cost\":{\"promptTokens\":100,\"completionTokens\":20,\"totalTokens\":120,\"model\":\"openai/gpt-4o\"},\"skillMetadata\":{},\"output\":\"Around 400,000.\",\"trace\":{\"resourceSpans\":[]},\"provenance\":{\"@context\":{\"prov\":\"http://www.w3.org/ns/prov#\",\"xsd\":\"http://www.w3.org/2001/XMLSchema#\",\"civic\":\"https://civicaitools.org/ns/evidence/\",\"dcterms\":\"http://purl.org/dc/terms/\"},\"@graph\":[{\"@id\":\"urn:civic-evidence:11111111-2222-4333-8444-555555555555:prompt:3e7a528652636c5c2f9f9dab2eb23881f15dcdfae1fb446fba1389b15b9e43c0\",\"@type\":\"prov:Entity\",\"civic:contentHash\":\"sha256:3e7a528652636c5c2f9f9dab2eb23881f15dcdfae1fb446fba1389b15b9e43c0\",\"dcterms:description\":\"User query prompt\",\"prov:value\":\"How many 311 noise complaints last year?\"},{\"@id\":\"urn:civic-evidence:11111111-2222-4333-8444-555555555555:output:97166bfed6ba462fd890e9f5cde53e4a359efad8a92286705f959c69e157f7d2\",\"@type\":\"prov:Entity\",\"civic:contentHash\":\"sha256:97166bfed6ba462fd890e9f5cde53e4a359efad8a92286705f959c69e157f7d2\",\"dcterms:description\":\"AI-generated analysis output\"},{\"@id\":\"urn:civic-evidence:model:openai-gpt-4o\",\"@type\":[\"prov:Agent\",\"prov:SoftwareAgent\"],\"dcterms:title\":\"openai/gpt-4o\",\"dcterms:description\":\"Large language model via OpenRouter\"},{\"@id\":\"urn:civic-evidence:platform:civic-ai-tools\",\"@type\":[\"prov:Agent\",\"prov:SoftwareAgent\"],\"dcterms:title\":\"Civic AI Tools\",\"civic:url\":\"https://civicaitools.org\"}]},\"contentHash\":{\"sha256\":\"a05b6afed2ee33b20e2fdcf09993760c758c8b35b01ab349f04d8d50aab8ad4f\"}}", + "contentHashSha256": "a05b6afed2ee33b20e2fdcf09993760c758c8b35b01ab349f04d8d50aab8ad4f", + "envelopeHash": "6a2515e8cd155b403db217834ca8c50ca2421b12e3efaadd0dc9997c71c7d45c" + } + }, + { + "name": "v01-dathere-empty-notebook", + "sourceTests": [ + "civic-ai-tools-website src/lib/evidence/packager.test.ts :: buildEvidencePackage: v0.1 datHere emits dathere-ag-jupyter/v1 rule + fingerprints the executed notebook", + "civic-ai-tools-website src/lib/evidence/packager.test.ts :: buildEvidencePackage: datHere content profile produces canonical JSON with summary", + "civic-ai-tools-website src/lib/evidence/packager.test.ts :: buildEvidencePackage: datHere content profile auto-emits org.civicaitools.environment extension", + "civic-ai-tools-website src/lib/evidence/packager.test.ts :: buildEvidencePackage: datHere auto-derives producerProfile = ai-assisted-analysis/datHere", + "civic-ai-tools-website src/lib/evidence/packager.test.ts :: buildEvidencePackage: datHere content profile preserves caller-supplied extensions alongside auto-emitted environment" + ], + "input": { + "packageId": "11111111-2222-4333-8444-555555555555", + "createdAt": "2026-01-02T03:04:05.000Z", + "signingKeyId": "adopter:test-key-1", + "prompt": "How many 311 noise complaints last year?", + "promptVisibility": "full_text", + "queries": [ + { + "tool": "get_data", + "operationType": "query", + "arguments": { + "type": "query", + "portal": "data.cityofnewyork.us", + "dataset_id": "erm2-nwe9", + "select": "count(*)" + }, + "datasetId": "erm2-nwe9", + "portal": "data.cityofnewyork.us", + "resultRows": 1, + "resultColumns": 1 + } + ], + "dataSources": [ + { + "sourceId": "socrata", + "catalogType": "socrata", + "portalUrl": "https://data.cityofnewyork.us", + "datasetId": "erm2-nwe9", + "datasetUrl": "https://data.cityofnewyork.us/d/erm2-nwe9", + "accessTimestamp": "2026-01-02T03:04:05.000Z" + } + ], + "cost": { + "promptTokens": 100, + "completionTokens": 20, + "totalTokens": 120, + "model": "openai/gpt-4o" + }, + "skillMetadata": {}, + "output": "Around 400,000.", + "trace": { + "resourceSpans": [] + }, + "summary": "Test summary.", + "contentProfile": "datHere", + "producerProfile": "ai-assisted-analysis/datHere", + "type": "content/analysis/v1", + "contentCanonicalization": "https://typedstandards.org/canonicalization/dathere-ag-jupyter/v1", + "provenance": { + "@context": { + "prov": "http://www.w3.org/ns/prov#", + "xsd": "http://www.w3.org/2001/XMLSchema#", + "civic": "https://civicaitools.org/ns/evidence/", + "dcterms": "http://purl.org/dc/terms/" + }, + "@graph": [ + { + "@id": "urn:civic-evidence:11111111-2222-4333-8444-555555555555:prompt:3e7a528652636c5c2f9f9dab2eb23881f15dcdfae1fb446fba1389b15b9e43c0", + "@type": "prov:Entity", + "civic:contentHash": "sha256:3e7a528652636c5c2f9f9dab2eb23881f15dcdfae1fb446fba1389b15b9e43c0", + "dcterms:description": "User query prompt", + "prov:value": "How many 311 noise complaints last year?" + }, + { + "@id": "urn:civic-evidence:11111111-2222-4333-8444-555555555555:output:97166bfed6ba462fd890e9f5cde53e4a359efad8a92286705f959c69e157f7d2", + "@type": "prov:Entity", + "civic:contentHash": "sha256:97166bfed6ba462fd890e9f5cde53e4a359efad8a92286705f959c69e157f7d2", + "dcterms:description": "AI-generated analysis output" + }, + { + "@id": "urn:civic-evidence:model:openai-gpt-4o", + "@type": [ + "prov:Agent", + "prov:SoftwareAgent" + ], + "dcterms:title": "openai/gpt-4o", + "dcterms:description": "Large language model via OpenRouter" + }, + { + "@id": "urn:civic-evidence:platform:civic-ai-tools", + "@type": [ + "prov:Agent", + "prov:SoftwareAgent" + ], + "dcterms:title": "Civic AI Tools", + "civic:url": "https://civicaitools.org" + } + ] + }, + "extensions": { + "org.civicaitools.notebook": { + "nbformat": 4, + "nbformat_minor": 5, + "cells": [], + "metadata": {} + }, + "org.civicaitools.environment": { + "modelVersion": "openai/gpt-4o", + "temperature": 0, + "mcpServers": [], + "toolDefinitions": [], + "host": "civicaitools.org" + } + } + }, + "expected": { + "serializedJson": "{\"metadata\":{\"schemaVersion\":\"0.1.0\",\"packageId\":\"11111111-2222-4333-8444-555555555555\",\"createdAt\":\"2026-01-02T03:04:05.000Z\",\"signingKeyId\":\"adopter:test-key-1\",\"contentProfile\":\"datHere\"},\"producerProfile\":\"ai-assisted-analysis/datHere\",\"type\":\"content/analysis/v1\",\"contentCanonicalization\":\"https://typedstandards.org/canonicalization/dathere-ag-jupyter/v1\",\"prompt\":{\"hash\":\"3e7a528652636c5c2f9f9dab2eb23881f15dcdfae1fb446fba1389b15b9e43c0\",\"visibility\":\"full_text\",\"text\":\"How many 311 noise complaints last year?\"},\"queries\":[{\"tool\":\"get_data\",\"operationType\":\"query\",\"arguments\":{\"type\":\"query\",\"portal\":\"data.cityofnewyork.us\",\"dataset_id\":\"erm2-nwe9\",\"select\":\"count(*)\"},\"datasetId\":\"erm2-nwe9\",\"portal\":\"data.cityofnewyork.us\",\"resultRows\":1,\"resultColumns\":1}],\"dataSources\":[{\"sourceId\":\"socrata\",\"catalogType\":\"socrata\",\"portalUrl\":\"https://data.cityofnewyork.us\",\"datasetId\":\"erm2-nwe9\",\"datasetUrl\":\"https://data.cityofnewyork.us/d/erm2-nwe9\",\"accessTimestamp\":\"2026-01-02T03:04:05.000Z\"}],\"cost\":{\"promptTokens\":100,\"completionTokens\":20,\"totalTokens\":120,\"model\":\"openai/gpt-4o\"},\"skillMetadata\":{},\"output\":\"Around 400,000.\",\"trace\":{\"resourceSpans\":[]},\"summary\":\"Test summary.\",\"provenance\":{\"@context\":{\"prov\":\"http://www.w3.org/ns/prov#\",\"xsd\":\"http://www.w3.org/2001/XMLSchema#\",\"civic\":\"https://civicaitools.org/ns/evidence/\",\"dcterms\":\"http://purl.org/dc/terms/\"},\"@graph\":[{\"@id\":\"urn:civic-evidence:11111111-2222-4333-8444-555555555555:prompt:3e7a528652636c5c2f9f9dab2eb23881f15dcdfae1fb446fba1389b15b9e43c0\",\"@type\":\"prov:Entity\",\"civic:contentHash\":\"sha256:3e7a528652636c5c2f9f9dab2eb23881f15dcdfae1fb446fba1389b15b9e43c0\",\"dcterms:description\":\"User query prompt\",\"prov:value\":\"How many 311 noise complaints last year?\"},{\"@id\":\"urn:civic-evidence:11111111-2222-4333-8444-555555555555:output:97166bfed6ba462fd890e9f5cde53e4a359efad8a92286705f959c69e157f7d2\",\"@type\":\"prov:Entity\",\"civic:contentHash\":\"sha256:97166bfed6ba462fd890e9f5cde53e4a359efad8a92286705f959c69e157f7d2\",\"dcterms:description\":\"AI-generated analysis output\"},{\"@id\":\"urn:civic-evidence:model:openai-gpt-4o\",\"@type\":[\"prov:Agent\",\"prov:SoftwareAgent\"],\"dcterms:title\":\"openai/gpt-4o\",\"dcterms:description\":\"Large language model via OpenRouter\"},{\"@id\":\"urn:civic-evidence:platform:civic-ai-tools\",\"@type\":[\"prov:Agent\",\"prov:SoftwareAgent\"],\"dcterms:title\":\"Civic AI Tools\",\"civic:url\":\"https://civicaitools.org\"}]},\"extensions\":{\"org.civicaitools.notebook\":{\"nbformat\":4,\"nbformat_minor\":5,\"cells\":[],\"metadata\":{}},\"org.civicaitools.environment\":{\"modelVersion\":\"openai/gpt-4o\",\"temperature\":0,\"mcpServers\":[],\"toolDefinitions\":[],\"host\":\"civicaitools.org\"}},\"contentHash\":{\"sha256\":\"ce65c2f4ffc92bc8f49e73ccdb5930b62bb014d1c70b33f66318bb97c228b39e\"}}", + "contentHashSha256": "ce65c2f4ffc92bc8f49e73ccdb5930b62bb014d1c70b33f66318bb97c228b39e", + "envelopeHash": "042815da4e366b7e13e3327debfca2793fcaee3ce1b5f3aa813549871050b6f3" + } + }, + { + "name": "v01-dathere-executed-notebook", + "sourceTests": [ + "civic-ai-tools-website src/lib/evidence/packager.test.ts :: round-trip: v0.1 datHere package re-verifies (JCS detection chain)" + ], + "input": { + "packageId": "11111111-2222-4333-8444-555555555555", + "createdAt": "2026-01-02T03:04:05.000Z", + "signingKeyId": "adopter:test-key-1", + "prompt": "How many 311 noise complaints last year?", + "promptVisibility": "full_text", + "queries": [ + { + "tool": "get_data", + "operationType": "query", + "arguments": { + "type": "query", + "portal": "data.cityofnewyork.us", + "dataset_id": "erm2-nwe9", + "select": "count(*)" + }, + "datasetId": "erm2-nwe9", + "portal": "data.cityofnewyork.us", + "resultRows": 1, + "resultColumns": 1 + } + ], + "dataSources": [ + { + "sourceId": "socrata", + "catalogType": "socrata", + "portalUrl": "https://data.cityofnewyork.us", + "datasetId": "erm2-nwe9", + "datasetUrl": "https://data.cityofnewyork.us/d/erm2-nwe9", + "accessTimestamp": "2026-01-02T03:04:05.000Z" + } + ], + "cost": { + "promptTokens": 100, + "completionTokens": 20, + "totalTokens": 120, + "model": "openai/gpt-4o" + }, + "skillMetadata": {}, + "output": "Around 400,000.", + "trace": { + "resourceSpans": [] + }, + "summary": "Test summary.", + "contentProfile": "datHere", + "producerProfile": "ai-assisted-analysis/datHere", + "type": "content/analysis/v1", + "contentCanonicalization": "https://typedstandards.org/canonicalization/dathere-ag-jupyter/v1", + "provenance": { + "@context": { + "prov": "http://www.w3.org/ns/prov#", + "xsd": "http://www.w3.org/2001/XMLSchema#", + "civic": "https://civicaitools.org/ns/evidence/", + "dcterms": "http://purl.org/dc/terms/" + }, + "@graph": [ + { + "@id": "urn:civic-evidence:11111111-2222-4333-8444-555555555555:prompt:3e7a528652636c5c2f9f9dab2eb23881f15dcdfae1fb446fba1389b15b9e43c0", + "@type": "prov:Entity", + "civic:contentHash": "sha256:3e7a528652636c5c2f9f9dab2eb23881f15dcdfae1fb446fba1389b15b9e43c0", + "dcterms:description": "User query prompt", + "prov:value": "How many 311 noise complaints last year?" + }, + { + "@id": "urn:civic-evidence:11111111-2222-4333-8444-555555555555:output:97166bfed6ba462fd890e9f5cde53e4a359efad8a92286705f959c69e157f7d2", + "@type": "prov:Entity", + "civic:contentHash": "sha256:97166bfed6ba462fd890e9f5cde53e4a359efad8a92286705f959c69e157f7d2", + "dcterms:description": "AI-generated analysis output" + }, + { + "@id": "urn:civic-evidence:model:openai-gpt-4o", + "@type": [ + "prov:Agent", + "prov:SoftwareAgent" + ], + "dcterms:title": "openai/gpt-4o", + "dcterms:description": "Large language model via OpenRouter" + }, + { + "@id": "urn:civic-evidence:platform:civic-ai-tools", + "@type": [ + "prov:Agent", + "prov:SoftwareAgent" + ], + "dcterms:title": "Civic AI Tools", + "civic:url": "https://civicaitools.org" + } + ] + }, + "extensions": { + "org.civicaitools.notebook": { + "nbformat": 4, + "nbformat_minor": 5, + "cells": [ + { + "cell_type": "code", + "source": [ + "print(1)" + ], + "outputs": [], + "metadata": {} + } + ], + "metadata": {} + }, + "org.civicaitools.environment": { + "modelVersion": "openai/gpt-4o", + "temperature": 0, + "mcpServers": [], + "toolDefinitions": [], + "host": "civicaitools.org" + } + } + }, + "expected": { + "serializedJson": "{\"metadata\":{\"schemaVersion\":\"0.1.0\",\"packageId\":\"11111111-2222-4333-8444-555555555555\",\"createdAt\":\"2026-01-02T03:04:05.000Z\",\"signingKeyId\":\"adopter:test-key-1\",\"contentProfile\":\"datHere\"},\"producerProfile\":\"ai-assisted-analysis/datHere\",\"type\":\"content/analysis/v1\",\"contentCanonicalization\":\"https://typedstandards.org/canonicalization/dathere-ag-jupyter/v1\",\"prompt\":{\"hash\":\"3e7a528652636c5c2f9f9dab2eb23881f15dcdfae1fb446fba1389b15b9e43c0\",\"visibility\":\"full_text\",\"text\":\"How many 311 noise complaints last year?\"},\"queries\":[{\"tool\":\"get_data\",\"operationType\":\"query\",\"arguments\":{\"type\":\"query\",\"portal\":\"data.cityofnewyork.us\",\"dataset_id\":\"erm2-nwe9\",\"select\":\"count(*)\"},\"datasetId\":\"erm2-nwe9\",\"portal\":\"data.cityofnewyork.us\",\"resultRows\":1,\"resultColumns\":1}],\"dataSources\":[{\"sourceId\":\"socrata\",\"catalogType\":\"socrata\",\"portalUrl\":\"https://data.cityofnewyork.us\",\"datasetId\":\"erm2-nwe9\",\"datasetUrl\":\"https://data.cityofnewyork.us/d/erm2-nwe9\",\"accessTimestamp\":\"2026-01-02T03:04:05.000Z\"}],\"cost\":{\"promptTokens\":100,\"completionTokens\":20,\"totalTokens\":120,\"model\":\"openai/gpt-4o\"},\"skillMetadata\":{},\"output\":\"Around 400,000.\",\"trace\":{\"resourceSpans\":[]},\"summary\":\"Test summary.\",\"provenance\":{\"@context\":{\"prov\":\"http://www.w3.org/ns/prov#\",\"xsd\":\"http://www.w3.org/2001/XMLSchema#\",\"civic\":\"https://civicaitools.org/ns/evidence/\",\"dcterms\":\"http://purl.org/dc/terms/\"},\"@graph\":[{\"@id\":\"urn:civic-evidence:11111111-2222-4333-8444-555555555555:prompt:3e7a528652636c5c2f9f9dab2eb23881f15dcdfae1fb446fba1389b15b9e43c0\",\"@type\":\"prov:Entity\",\"civic:contentHash\":\"sha256:3e7a528652636c5c2f9f9dab2eb23881f15dcdfae1fb446fba1389b15b9e43c0\",\"dcterms:description\":\"User query prompt\",\"prov:value\":\"How many 311 noise complaints last year?\"},{\"@id\":\"urn:civic-evidence:11111111-2222-4333-8444-555555555555:output:97166bfed6ba462fd890e9f5cde53e4a359efad8a92286705f959c69e157f7d2\",\"@type\":\"prov:Entity\",\"civic:contentHash\":\"sha256:97166bfed6ba462fd890e9f5cde53e4a359efad8a92286705f959c69e157f7d2\",\"dcterms:description\":\"AI-generated analysis output\"},{\"@id\":\"urn:civic-evidence:model:openai-gpt-4o\",\"@type\":[\"prov:Agent\",\"prov:SoftwareAgent\"],\"dcterms:title\":\"openai/gpt-4o\",\"dcterms:description\":\"Large language model via OpenRouter\"},{\"@id\":\"urn:civic-evidence:platform:civic-ai-tools\",\"@type\":[\"prov:Agent\",\"prov:SoftwareAgent\"],\"dcterms:title\":\"Civic AI Tools\",\"civic:url\":\"https://civicaitools.org\"}]},\"extensions\":{\"org.civicaitools.notebook\":{\"nbformat\":4,\"nbformat_minor\":5,\"cells\":[{\"cell_type\":\"code\",\"source\":[\"print(1)\"],\"outputs\":[],\"metadata\":{}}],\"metadata\":{}},\"org.civicaitools.environment\":{\"modelVersion\":\"openai/gpt-4o\",\"temperature\":0,\"mcpServers\":[],\"toolDefinitions\":[],\"host\":\"civicaitools.org\"}},\"contentHash\":{\"sha256\":\"21bc4d35a6cda882b8ae39530b7f591beb4891581b3e323005b2333b2edf8dff\"}}", + "contentHashSha256": "21bc4d35a6cda882b8ae39530b7f591beb4891581b3e323005b2333b2edf8dff", + "envelopeHash": "26d3906aad14369e8fd9399429f6e749a12e7149b3113ee6f022bcfd54dad225" + } + }, + { + "name": "legacy-blobref-output", + "sourceTests": [ + "civic-ai-tools-website src/lib/evidence/packager.test.ts :: buildEvidencePackage: BlobRef output is preserved in the package JSON", + "civic-ai-tools-website src/lib/evidence/packager.test.ts :: buildEvidencePackage: BlobRef output produces a deterministic package hash (idempotency)" + ], + "input": { + "packageId": "11111111-2222-4333-8444-555555555555", + "createdAt": "2026-01-02T03:04:05.000Z", + "signingKeyId": "adopter:test-key-1", + "prompt": "How many 311 noise complaints last year?", + "promptVisibility": "full_text", + "queries": [ + { + "tool": "get_data", + "operationType": "query", + "arguments": { + "type": "query", + "portal": "data.cityofnewyork.us", + "dataset_id": "erm2-nwe9", + "select": "count(*)" + }, + "datasetId": "erm2-nwe9", + "portal": "data.cityofnewyork.us", + "resultRows": 1, + "resultColumns": 1 + } + ], + "dataSources": [ + { + "sourceId": "socrata", + "catalogType": "socrata", + "portalUrl": "https://data.cityofnewyork.us", + "datasetId": "erm2-nwe9", + "datasetUrl": "https://data.cityofnewyork.us/d/erm2-nwe9", + "accessTimestamp": "2026-01-02T03:04:05.000Z" + } + ], + "cost": { + "promptTokens": 100, + "completionTokens": 20, + "totalTokens": 120, + "model": "openai/gpt-4o" + }, + "skillMetadata": {}, + "output": { + "ref": "blob:sha256:ed7002b439e9ac845f22357d822bac1444730fbdb6016d3ec9432297b9ec9f73", + "url": "https://example.public.blob.vercel-storage.com/evidence-refs/ed7002b439e9ac845f22357d822bac1444730fbdb6016d3ec9432297b9ec9f73.bin", + "contentType": "text/plain", + "size": 7 + }, + "trace": { + "resourceSpans": [] + }, + "provenance": { + "@context": { + "prov": "http://www.w3.org/ns/prov#", + "xsd": "http://www.w3.org/2001/XMLSchema#", + "civic": "https://civicaitools.org/ns/evidence/", + "dcterms": "http://purl.org/dc/terms/" + }, + "@graph": [ + { + "@id": "urn:civic-evidence:11111111-2222-4333-8444-555555555555:prompt:3e7a528652636c5c2f9f9dab2eb23881f15dcdfae1fb446fba1389b15b9e43c0", + "@type": "prov:Entity", + "civic:contentHash": "sha256:3e7a528652636c5c2f9f9dab2eb23881f15dcdfae1fb446fba1389b15b9e43c0", + "dcterms:description": "User query prompt", + "prov:value": "How many 311 noise complaints last year?" + }, + { + "@id": "urn:civic-evidence:11111111-2222-4333-8444-555555555555:output:ed7002b439e9ac845f22357d822bac1444730fbdb6016d3ec9432297b9ec9f73", + "@type": "prov:Entity", + "civic:contentHash": "sha256:ed7002b439e9ac845f22357d822bac1444730fbdb6016d3ec9432297b9ec9f73", + "dcterms:description": "AI-generated analysis output" + }, + { + "@id": "urn:civic-evidence:model:openai-gpt-4o", + "@type": [ + "prov:Agent", + "prov:SoftwareAgent" + ], + "dcterms:title": "openai/gpt-4o", + "dcterms:description": "Large language model via OpenRouter" + }, + { + "@id": "urn:civic-evidence:platform:civic-ai-tools", + "@type": [ + "prov:Agent", + "prov:SoftwareAgent" + ], + "dcterms:title": "Civic AI Tools", + "civic:url": "https://civicaitools.org" + } + ] + } + }, + "expected": { + "serializedJson": "{\"metadata\":{\"schemaVersion\":\"0.1.0\",\"packageId\":\"11111111-2222-4333-8444-555555555555\",\"createdAt\":\"2026-01-02T03:04:05.000Z\",\"signingKeyId\":\"adopter:test-key-1\"},\"prompt\":{\"hash\":\"3e7a528652636c5c2f9f9dab2eb23881f15dcdfae1fb446fba1389b15b9e43c0\",\"visibility\":\"full_text\",\"text\":\"How many 311 noise complaints last year?\"},\"queries\":[{\"tool\":\"get_data\",\"operationType\":\"query\",\"arguments\":{\"type\":\"query\",\"portal\":\"data.cityofnewyork.us\",\"dataset_id\":\"erm2-nwe9\",\"select\":\"count(*)\"},\"datasetId\":\"erm2-nwe9\",\"portal\":\"data.cityofnewyork.us\",\"resultRows\":1,\"resultColumns\":1}],\"dataSources\":[{\"sourceId\":\"socrata\",\"catalogType\":\"socrata\",\"portalUrl\":\"https://data.cityofnewyork.us\",\"datasetId\":\"erm2-nwe9\",\"datasetUrl\":\"https://data.cityofnewyork.us/d/erm2-nwe9\",\"accessTimestamp\":\"2026-01-02T03:04:05.000Z\"}],\"cost\":{\"promptTokens\":100,\"completionTokens\":20,\"totalTokens\":120,\"model\":\"openai/gpt-4o\"},\"skillMetadata\":{},\"output\":{\"ref\":\"blob:sha256:ed7002b439e9ac845f22357d822bac1444730fbdb6016d3ec9432297b9ec9f73\",\"url\":\"https://example.public.blob.vercel-storage.com/evidence-refs/ed7002b439e9ac845f22357d822bac1444730fbdb6016d3ec9432297b9ec9f73.bin\",\"contentType\":\"text/plain\",\"size\":7},\"trace\":{\"resourceSpans\":[]},\"provenance\":{\"@context\":{\"prov\":\"http://www.w3.org/ns/prov#\",\"xsd\":\"http://www.w3.org/2001/XMLSchema#\",\"civic\":\"https://civicaitools.org/ns/evidence/\",\"dcterms\":\"http://purl.org/dc/terms/\"},\"@graph\":[{\"@id\":\"urn:civic-evidence:11111111-2222-4333-8444-555555555555:prompt:3e7a528652636c5c2f9f9dab2eb23881f15dcdfae1fb446fba1389b15b9e43c0\",\"@type\":\"prov:Entity\",\"civic:contentHash\":\"sha256:3e7a528652636c5c2f9f9dab2eb23881f15dcdfae1fb446fba1389b15b9e43c0\",\"dcterms:description\":\"User query prompt\",\"prov:value\":\"How many 311 noise complaints last year?\"},{\"@id\":\"urn:civic-evidence:11111111-2222-4333-8444-555555555555:output:ed7002b439e9ac845f22357d822bac1444730fbdb6016d3ec9432297b9ec9f73\",\"@type\":\"prov:Entity\",\"civic:contentHash\":\"sha256:ed7002b439e9ac845f22357d822bac1444730fbdb6016d3ec9432297b9ec9f73\",\"dcterms:description\":\"AI-generated analysis output\"},{\"@id\":\"urn:civic-evidence:model:openai-gpt-4o\",\"@type\":[\"prov:Agent\",\"prov:SoftwareAgent\"],\"dcterms:title\":\"openai/gpt-4o\",\"dcterms:description\":\"Large language model via OpenRouter\"},{\"@id\":\"urn:civic-evidence:platform:civic-ai-tools\",\"@type\":[\"prov:Agent\",\"prov:SoftwareAgent\"],\"dcterms:title\":\"Civic AI Tools\",\"civic:url\":\"https://civicaitools.org\"}]}}", + "contentHashSha256": null, + "envelopeHash": "9165d3d27cccd0977f1c8bfd2c31dcb972b74ec78e91e70bf87966844d06ffc8" + } + }, + { + "name": "legacy-blobref-trace-skill-override", + "sourceTests": [ + "civic-ai-tools-website src/lib/evidence/packager.test.ts :: buildEvidencePackage: BlobRef trace + skillMetadataOverride populates skill metadata" + ], + "input": { + "packageId": "11111111-2222-4333-8444-555555555555", + "createdAt": "2026-01-02T03:04:05.000Z", + "signingKeyId": "adopter:test-key-1", + "prompt": "How many 311 noise complaints last year?", + "promptVisibility": "full_text", + "queries": [ + { + "tool": "get_data", + "operationType": "query", + "arguments": { + "type": "query", + "portal": "data.cityofnewyork.us", + "dataset_id": "erm2-nwe9", + "select": "count(*)" + }, + "datasetId": "erm2-nwe9", + "portal": "data.cityofnewyork.us", + "resultRows": 1, + "resultColumns": 1 + } + ], + "dataSources": [ + { + "sourceId": "socrata", + "catalogType": "socrata", + "portalUrl": "https://data.cityofnewyork.us", + "datasetId": "erm2-nwe9", + "datasetUrl": "https://data.cityofnewyork.us/d/erm2-nwe9", + "accessTimestamp": "2026-01-02T03:04:05.000Z" + } + ], + "cost": { + "promptTokens": 100, + "completionTokens": 20, + "totalTokens": 120, + "model": "openai/gpt-4o" + }, + "skillMetadata": { + "systemPromptHash": "e751da4a00000000000000000000000000000000000000000000000000000000", + "mcpServerUrl": "https://socrata-mcp.civicaitools.org", + "skillText": { + "ref": "blob:sha256:b222c80175f8430d339cabe3f95f34384d06336c105fdfc029ec36640eddaef6", + "url": "https://example.public.blob.vercel-storage.com/evidence-refs/b222c80175f8430d339cabe3f95f34384d06336c105fdfc029ec36640eddaef6.bin", + "contentType": "text/markdown", + "size": 11 + } + }, + "output": "Around 400,000.", + "trace": { + "ref": "blob:sha256:57bc3a530fb2531e27616aa44e504e044b9d41aea1c0f16b843d1fddb58a1cb9", + "url": "https://example.public.blob.vercel-storage.com/evidence-refs/57bc3a530fb2531e27616aa44e504e044b9d41aea1c0f16b843d1fddb58a1cb9.bin", + "contentType": "application/json", + "size": 20 + }, + "provenance": { + "@context": { + "prov": "http://www.w3.org/ns/prov#", + "xsd": "http://www.w3.org/2001/XMLSchema#", + "civic": "https://civicaitools.org/ns/evidence/", + "dcterms": "http://purl.org/dc/terms/" + }, + "@graph": [ + { + "@id": "urn:civic-evidence:11111111-2222-4333-8444-555555555555:prompt:3e7a528652636c5c2f9f9dab2eb23881f15dcdfae1fb446fba1389b15b9e43c0", + "@type": "prov:Entity", + "civic:contentHash": "sha256:3e7a528652636c5c2f9f9dab2eb23881f15dcdfae1fb446fba1389b15b9e43c0", + "dcterms:description": "User query prompt", + "prov:value": "How many 311 noise complaints last year?" + }, + { + "@id": "urn:civic-evidence:11111111-2222-4333-8444-555555555555:output:97166bfed6ba462fd890e9f5cde53e4a359efad8a92286705f959c69e157f7d2", + "@type": "prov:Entity", + "civic:contentHash": "sha256:97166bfed6ba462fd890e9f5cde53e4a359efad8a92286705f959c69e157f7d2", + "dcterms:description": "AI-generated analysis output" + }, + { + "@id": "urn:civic-evidence:model:openai-gpt-4o", + "@type": [ + "prov:Agent", + "prov:SoftwareAgent" + ], + "dcterms:title": "openai/gpt-4o", + "dcterms:description": "Large language model via OpenRouter" + }, + { + "@id": "urn:civic-evidence:platform:civic-ai-tools", + "@type": [ + "prov:Agent", + "prov:SoftwareAgent" + ], + "dcterms:title": "Civic AI Tools", + "civic:url": "https://civicaitools.org" + } + ] + } + }, + "expected": { + "serializedJson": "{\"metadata\":{\"schemaVersion\":\"0.1.0\",\"packageId\":\"11111111-2222-4333-8444-555555555555\",\"createdAt\":\"2026-01-02T03:04:05.000Z\",\"signingKeyId\":\"adopter:test-key-1\"},\"prompt\":{\"hash\":\"3e7a528652636c5c2f9f9dab2eb23881f15dcdfae1fb446fba1389b15b9e43c0\",\"visibility\":\"full_text\",\"text\":\"How many 311 noise complaints last year?\"},\"queries\":[{\"tool\":\"get_data\",\"operationType\":\"query\",\"arguments\":{\"type\":\"query\",\"portal\":\"data.cityofnewyork.us\",\"dataset_id\":\"erm2-nwe9\",\"select\":\"count(*)\"},\"datasetId\":\"erm2-nwe9\",\"portal\":\"data.cityofnewyork.us\",\"resultRows\":1,\"resultColumns\":1}],\"dataSources\":[{\"sourceId\":\"socrata\",\"catalogType\":\"socrata\",\"portalUrl\":\"https://data.cityofnewyork.us\",\"datasetId\":\"erm2-nwe9\",\"datasetUrl\":\"https://data.cityofnewyork.us/d/erm2-nwe9\",\"accessTimestamp\":\"2026-01-02T03:04:05.000Z\"}],\"cost\":{\"promptTokens\":100,\"completionTokens\":20,\"totalTokens\":120,\"model\":\"openai/gpt-4o\"},\"skillMetadata\":{\"systemPromptHash\":\"e751da4a00000000000000000000000000000000000000000000000000000000\",\"mcpServerUrl\":\"https://socrata-mcp.civicaitools.org\",\"skillText\":{\"ref\":\"blob:sha256:b222c80175f8430d339cabe3f95f34384d06336c105fdfc029ec36640eddaef6\",\"url\":\"https://example.public.blob.vercel-storage.com/evidence-refs/b222c80175f8430d339cabe3f95f34384d06336c105fdfc029ec36640eddaef6.bin\",\"contentType\":\"text/markdown\",\"size\":11}},\"output\":\"Around 400,000.\",\"trace\":{\"ref\":\"blob:sha256:57bc3a530fb2531e27616aa44e504e044b9d41aea1c0f16b843d1fddb58a1cb9\",\"url\":\"https://example.public.blob.vercel-storage.com/evidence-refs/57bc3a530fb2531e27616aa44e504e044b9d41aea1c0f16b843d1fddb58a1cb9.bin\",\"contentType\":\"application/json\",\"size\":20},\"provenance\":{\"@context\":{\"prov\":\"http://www.w3.org/ns/prov#\",\"xsd\":\"http://www.w3.org/2001/XMLSchema#\",\"civic\":\"https://civicaitools.org/ns/evidence/\",\"dcterms\":\"http://purl.org/dc/terms/\"},\"@graph\":[{\"@id\":\"urn:civic-evidence:11111111-2222-4333-8444-555555555555:prompt:3e7a528652636c5c2f9f9dab2eb23881f15dcdfae1fb446fba1389b15b9e43c0\",\"@type\":\"prov:Entity\",\"civic:contentHash\":\"sha256:3e7a528652636c5c2f9f9dab2eb23881f15dcdfae1fb446fba1389b15b9e43c0\",\"dcterms:description\":\"User query prompt\",\"prov:value\":\"How many 311 noise complaints last year?\"},{\"@id\":\"urn:civic-evidence:11111111-2222-4333-8444-555555555555:output:97166bfed6ba462fd890e9f5cde53e4a359efad8a92286705f959c69e157f7d2\",\"@type\":\"prov:Entity\",\"civic:contentHash\":\"sha256:97166bfed6ba462fd890e9f5cde53e4a359efad8a92286705f959c69e157f7d2\",\"dcterms:description\":\"AI-generated analysis output\"},{\"@id\":\"urn:civic-evidence:model:openai-gpt-4o\",\"@type\":[\"prov:Agent\",\"prov:SoftwareAgent\"],\"dcterms:title\":\"openai/gpt-4o\",\"dcterms:description\":\"Large language model via OpenRouter\"},{\"@id\":\"urn:civic-evidence:platform:civic-ai-tools\",\"@type\":[\"prov:Agent\",\"prov:SoftwareAgent\"],\"dcterms:title\":\"Civic AI Tools\",\"civic:url\":\"https://civicaitools.org\"}]}}", + "contentHashSha256": null, + "envelopeHash": "018a9d59583c96a5a64a078e4ed76c69b5db94d86e42e4b081febe8829acbc1c" + } + }, + { + "name": "v01-signer-producer-capture", + "sourceTests": [ + "civic-ai-tools-website src/lib/evidence/packager.test.ts :: buildEvidencePackage: signer is emitted at top level when supplied", + "civic-ai-tools-website src/lib/evidence/packager.test.ts :: buildEvidencePackage: explicit producerProfile is preserved (not overridden by auto-derive)", + "civic-ai-tools-website src/lib/evidence/packager.test.ts :: buildEvidencePackage: type is emitted at top level when supplied" + ], + "input": { + "packageId": "11111111-2222-4333-8444-555555555555", + "createdAt": "2026-01-02T03:04:05.000Z", + "signingKeyId": "adopter:test-key-1", + "prompt": "How many 311 noise complaints last year?", + "promptVisibility": "full_text", + "queries": [ + { + "tool": "get_data", + "operationType": "query", + "arguments": { + "type": "query", + "portal": "data.cityofnewyork.us", + "dataset_id": "erm2-nwe9", + "select": "count(*)" + }, + "datasetId": "erm2-nwe9", + "portal": "data.cityofnewyork.us", + "resultRows": 1, + "resultColumns": 1 + } + ], + "dataSources": [ + { + "sourceId": "socrata", + "catalogType": "socrata", + "portalUrl": "https://data.cityofnewyork.us", + "datasetId": "erm2-nwe9", + "datasetUrl": "https://data.cityofnewyork.us/d/erm2-nwe9", + "accessTimestamp": "2026-01-02T03:04:05.000Z" + } + ], + "cost": { + "promptTokens": 100, + "completionTokens": 20, + "totalTokens": 120, + "model": "openai/gpt-4o" + }, + "skillMetadata": {}, + "output": "Around 400,000.", + "trace": { + "resourceSpans": [] + }, + "captureMethod": "claude-code-jsonl-readback", + "producerProfile": "ai-assisted-analysis/civicaitools-default", + "type": "content/analysis/v1", + "signer": { + "bindingTier": "platform", + "identifier": "platform:civic-ai-tools", + "displayName": "Civic AI Tools Platform" + }, + "contentCanonicalization": "https://typedstandards.org/canonicalization/legacy-json/v1", + "provenance": { + "@context": { + "prov": "http://www.w3.org/ns/prov#", + "xsd": "http://www.w3.org/2001/XMLSchema#", + "civic": "https://civicaitools.org/ns/evidence/", + "dcterms": "http://purl.org/dc/terms/" + }, + "@graph": [ + { + "@id": "urn:civic-evidence:11111111-2222-4333-8444-555555555555:prompt:3e7a528652636c5c2f9f9dab2eb23881f15dcdfae1fb446fba1389b15b9e43c0", + "@type": "prov:Entity", + "civic:contentHash": "sha256:3e7a528652636c5c2f9f9dab2eb23881f15dcdfae1fb446fba1389b15b9e43c0", + "dcterms:description": "User query prompt", + "prov:value": "How many 311 noise complaints last year?" + }, + { + "@id": "urn:civic-evidence:11111111-2222-4333-8444-555555555555:output:97166bfed6ba462fd890e9f5cde53e4a359efad8a92286705f959c69e157f7d2", + "@type": "prov:Entity", + "civic:contentHash": "sha256:97166bfed6ba462fd890e9f5cde53e4a359efad8a92286705f959c69e157f7d2", + "dcterms:description": "AI-generated analysis output" + }, + { + "@id": "urn:civic-evidence:model:openai-gpt-4o", + "@type": [ + "prov:Agent", + "prov:SoftwareAgent" + ], + "dcterms:title": "openai/gpt-4o", + "dcterms:description": "Large language model via OpenRouter" + }, + { + "@id": "urn:civic-evidence:platform:civic-ai-tools", + "@type": [ + "prov:Agent", + "prov:SoftwareAgent" + ], + "dcterms:title": "Civic AI Tools", + "civic:url": "https://civicaitools.org" + } + ] + } + }, + "expected": { + "serializedJson": "{\"metadata\":{\"schemaVersion\":\"0.1.0\",\"packageId\":\"11111111-2222-4333-8444-555555555555\",\"createdAt\":\"2026-01-02T03:04:05.000Z\",\"signingKeyId\":\"adopter:test-key-1\",\"captureMethod\":\"claude-code-jsonl-readback\"},\"producerProfile\":\"ai-assisted-analysis/civicaitools-default\",\"type\":\"content/analysis/v1\",\"signer\":{\"bindingTier\":\"platform\",\"identifier\":\"platform:civic-ai-tools\",\"displayName\":\"Civic AI Tools Platform\"},\"contentCanonicalization\":\"https://typedstandards.org/canonicalization/legacy-json/v1\",\"prompt\":{\"hash\":\"3e7a528652636c5c2f9f9dab2eb23881f15dcdfae1fb446fba1389b15b9e43c0\",\"visibility\":\"full_text\",\"text\":\"How many 311 noise complaints last year?\"},\"queries\":[{\"tool\":\"get_data\",\"operationType\":\"query\",\"arguments\":{\"type\":\"query\",\"portal\":\"data.cityofnewyork.us\",\"dataset_id\":\"erm2-nwe9\",\"select\":\"count(*)\"},\"datasetId\":\"erm2-nwe9\",\"portal\":\"data.cityofnewyork.us\",\"resultRows\":1,\"resultColumns\":1}],\"dataSources\":[{\"sourceId\":\"socrata\",\"catalogType\":\"socrata\",\"portalUrl\":\"https://data.cityofnewyork.us\",\"datasetId\":\"erm2-nwe9\",\"datasetUrl\":\"https://data.cityofnewyork.us/d/erm2-nwe9\",\"accessTimestamp\":\"2026-01-02T03:04:05.000Z\"}],\"cost\":{\"promptTokens\":100,\"completionTokens\":20,\"totalTokens\":120,\"model\":\"openai/gpt-4o\"},\"skillMetadata\":{},\"output\":\"Around 400,000.\",\"trace\":{\"resourceSpans\":[]},\"provenance\":{\"@context\":{\"prov\":\"http://www.w3.org/ns/prov#\",\"xsd\":\"http://www.w3.org/2001/XMLSchema#\",\"civic\":\"https://civicaitools.org/ns/evidence/\",\"dcterms\":\"http://purl.org/dc/terms/\"},\"@graph\":[{\"@id\":\"urn:civic-evidence:11111111-2222-4333-8444-555555555555:prompt:3e7a528652636c5c2f9f9dab2eb23881f15dcdfae1fb446fba1389b15b9e43c0\",\"@type\":\"prov:Entity\",\"civic:contentHash\":\"sha256:3e7a528652636c5c2f9f9dab2eb23881f15dcdfae1fb446fba1389b15b9e43c0\",\"dcterms:description\":\"User query prompt\",\"prov:value\":\"How many 311 noise complaints last year?\"},{\"@id\":\"urn:civic-evidence:11111111-2222-4333-8444-555555555555:output:97166bfed6ba462fd890e9f5cde53e4a359efad8a92286705f959c69e157f7d2\",\"@type\":\"prov:Entity\",\"civic:contentHash\":\"sha256:97166bfed6ba462fd890e9f5cde53e4a359efad8a92286705f959c69e157f7d2\",\"dcterms:description\":\"AI-generated analysis output\"},{\"@id\":\"urn:civic-evidence:model:openai-gpt-4o\",\"@type\":[\"prov:Agent\",\"prov:SoftwareAgent\"],\"dcterms:title\":\"openai/gpt-4o\",\"dcterms:description\":\"Large language model via OpenRouter\"},{\"@id\":\"urn:civic-evidence:platform:civic-ai-tools\",\"@type\":[\"prov:Agent\",\"prov:SoftwareAgent\"],\"dcterms:title\":\"Civic AI Tools\",\"civic:url\":\"https://civicaitools.org\"}]},\"contentHash\":{\"sha256\":\"664b5725c3137fcee53d77ff45e56d38fc0ab62462d7fc1b2d62f0099293e142\"}}", + "contentHashSha256": "664b5725c3137fcee53d77ff45e56d38fc0ab62462d7fc1b2d62f0099293e142", + "envelopeHash": "39d3e537569b946a4c1d298c24574046788fb7c86bf7d24c2b2cbd59054635ce" + } + }, + { + "name": "v01-self-certified-signer", + "sourceTests": [ + "civic-ai-tools-website src/lib/evidence/packager.test.ts :: buildEvidencePackage: signer is emitted at top level when supplied", + "civic-ai-tools-website src/lib/evidence/packager.test.ts :: buildEvidencePackage: explicit producerProfile is preserved (not overridden by auto-derive)", + "civic-ai-tools-website src/lib/evidence/packager.test.ts :: buildEvidencePackage: type is emitted at top level when supplied" + ], + "capture": "Captured 2026-09-21 from the reference implementation (civic-ai-tools-website src/lib/evidence/packager.ts) at d39fdc17e8e237b5cac225e83cf7ca686b42b115, extracted read-only with git archive and run unmodified, with clock, RNG, and key-id env stubbed as in _meta except the key-id env: EVIDENCE_KEY_ID is the did:key below, so metadata.signingKeyId equals the signer identifier and the kid (hub ADR-0030 §5). Input: packager.test.ts baseInput() with the overrides of the v01-signer-producer-capture case, and the caller-supplied signer below, which the reference packager emits verbatim. Calibration: the same harness with the platform signer and adopter:test-key-1 reproduces v01-signer-producer-capture byte-identically. Cross-check: the content hash and envelope hash were recomputed independently (Python RFC 8785 JCS + SHA-256) and match.", + "signerKeySource": "RFC 8032 §7.1 TEST 1 public key d75a980182b10ab7d54bfed3c964073a0ee172f3daa62325af021a68f707511a; its did:key is the value verify-core did-key.test.ts asserts (hub ADR-0030 §9).", + "input": { + "packageId": "11111111-2222-4333-8444-555555555555", + "createdAt": "2026-01-02T03:04:05.000Z", + "signingKeyId": "did:key:z6MktwupdmLXVVqTzCw4i46r4uGyosGXRnR3XjN4Zq7oMMsw", + "prompt": "How many 311 noise complaints last year?", + "promptVisibility": "full_text", + "queries": [ + { + "tool": "get_data", + "operationType": "query", + "arguments": { + "type": "query", + "portal": "data.cityofnewyork.us", + "dataset_id": "erm2-nwe9", + "select": "count(*)" + }, + "datasetId": "erm2-nwe9", + "portal": "data.cityofnewyork.us", + "resultRows": 1, + "resultColumns": 1 + } + ], + "dataSources": [ + { + "sourceId": "socrata", + "catalogType": "socrata", + "portalUrl": "https://data.cityofnewyork.us", + "datasetId": "erm2-nwe9", + "datasetUrl": "https://data.cityofnewyork.us/d/erm2-nwe9", + "accessTimestamp": "2026-01-02T03:04:05.000Z" + } + ], + "cost": { + "promptTokens": 100, + "completionTokens": 20, + "totalTokens": 120, + "model": "openai/gpt-4o" + }, + "skillMetadata": {}, + "output": "Around 400,000.", + "trace": { + "resourceSpans": [] + }, + "captureMethod": "claude-code-jsonl-readback", + "producerProfile": "ai-assisted-analysis/civicaitools-default", + "type": "content/analysis/v1", + "signer": { + "bindingTier": "pseudonymous", + "identifier": "did:key:z6MktwupdmLXVVqTzCw4i46r4uGyosGXRnR3XjN4Zq7oMMsw", + "displayName": "Example Self-Certifying Signer" + }, + "contentCanonicalization": "https://typedstandards.org/canonicalization/legacy-json/v1", + "provenance": { + "@context": { + "prov": "http://www.w3.org/ns/prov#", + "xsd": "http://www.w3.org/2001/XMLSchema#", + "civic": "https://civicaitools.org/ns/evidence/", + "dcterms": "http://purl.org/dc/terms/" + }, + "@graph": [ + { + "@id": "urn:civic-evidence:11111111-2222-4333-8444-555555555555:prompt:3e7a528652636c5c2f9f9dab2eb23881f15dcdfae1fb446fba1389b15b9e43c0", + "@type": "prov:Entity", + "civic:contentHash": "sha256:3e7a528652636c5c2f9f9dab2eb23881f15dcdfae1fb446fba1389b15b9e43c0", + "dcterms:description": "User query prompt", + "prov:value": "How many 311 noise complaints last year?" + }, + { + "@id": "urn:civic-evidence:11111111-2222-4333-8444-555555555555:output:97166bfed6ba462fd890e9f5cde53e4a359efad8a92286705f959c69e157f7d2", + "@type": "prov:Entity", + "civic:contentHash": "sha256:97166bfed6ba462fd890e9f5cde53e4a359efad8a92286705f959c69e157f7d2", + "dcterms:description": "AI-generated analysis output" + }, + { + "@id": "urn:civic-evidence:model:openai-gpt-4o", + "@type": [ + "prov:Agent", + "prov:SoftwareAgent" + ], + "dcterms:title": "openai/gpt-4o", + "dcterms:description": "Large language model via OpenRouter" + }, + { + "@id": "urn:civic-evidence:platform:civic-ai-tools", + "@type": [ + "prov:Agent", + "prov:SoftwareAgent" + ], + "dcterms:title": "Civic AI Tools", + "civic:url": "https://civicaitools.org" + } + ] + } + }, + "expected": { + "serializedJson": "{\"metadata\":{\"schemaVersion\":\"0.1.0\",\"packageId\":\"11111111-2222-4333-8444-555555555555\",\"createdAt\":\"2026-01-02T03:04:05.000Z\",\"signingKeyId\":\"did:key:z6MktwupdmLXVVqTzCw4i46r4uGyosGXRnR3XjN4Zq7oMMsw\",\"captureMethod\":\"claude-code-jsonl-readback\"},\"producerProfile\":\"ai-assisted-analysis/civicaitools-default\",\"type\":\"content/analysis/v1\",\"signer\":{\"bindingTier\":\"pseudonymous\",\"identifier\":\"did:key:z6MktwupdmLXVVqTzCw4i46r4uGyosGXRnR3XjN4Zq7oMMsw\",\"displayName\":\"Example Self-Certifying Signer\"},\"contentCanonicalization\":\"https://typedstandards.org/canonicalization/legacy-json/v1\",\"prompt\":{\"hash\":\"3e7a528652636c5c2f9f9dab2eb23881f15dcdfae1fb446fba1389b15b9e43c0\",\"visibility\":\"full_text\",\"text\":\"How many 311 noise complaints last year?\"},\"queries\":[{\"tool\":\"get_data\",\"operationType\":\"query\",\"arguments\":{\"type\":\"query\",\"portal\":\"data.cityofnewyork.us\",\"dataset_id\":\"erm2-nwe9\",\"select\":\"count(*)\"},\"datasetId\":\"erm2-nwe9\",\"portal\":\"data.cityofnewyork.us\",\"resultRows\":1,\"resultColumns\":1}],\"dataSources\":[{\"sourceId\":\"socrata\",\"catalogType\":\"socrata\",\"portalUrl\":\"https://data.cityofnewyork.us\",\"datasetId\":\"erm2-nwe9\",\"datasetUrl\":\"https://data.cityofnewyork.us/d/erm2-nwe9\",\"accessTimestamp\":\"2026-01-02T03:04:05.000Z\"}],\"cost\":{\"promptTokens\":100,\"completionTokens\":20,\"totalTokens\":120,\"model\":\"openai/gpt-4o\"},\"skillMetadata\":{},\"output\":\"Around 400,000.\",\"trace\":{\"resourceSpans\":[]},\"provenance\":{\"@context\":{\"prov\":\"http://www.w3.org/ns/prov#\",\"xsd\":\"http://www.w3.org/2001/XMLSchema#\",\"civic\":\"https://civicaitools.org/ns/evidence/\",\"dcterms\":\"http://purl.org/dc/terms/\"},\"@graph\":[{\"@id\":\"urn:civic-evidence:11111111-2222-4333-8444-555555555555:prompt:3e7a528652636c5c2f9f9dab2eb23881f15dcdfae1fb446fba1389b15b9e43c0\",\"@type\":\"prov:Entity\",\"civic:contentHash\":\"sha256:3e7a528652636c5c2f9f9dab2eb23881f15dcdfae1fb446fba1389b15b9e43c0\",\"dcterms:description\":\"User query prompt\",\"prov:value\":\"How many 311 noise complaints last year?\"},{\"@id\":\"urn:civic-evidence:11111111-2222-4333-8444-555555555555:output:97166bfed6ba462fd890e9f5cde53e4a359efad8a92286705f959c69e157f7d2\",\"@type\":\"prov:Entity\",\"civic:contentHash\":\"sha256:97166bfed6ba462fd890e9f5cde53e4a359efad8a92286705f959c69e157f7d2\",\"dcterms:description\":\"AI-generated analysis output\"},{\"@id\":\"urn:civic-evidence:model:openai-gpt-4o\",\"@type\":[\"prov:Agent\",\"prov:SoftwareAgent\"],\"dcterms:title\":\"openai/gpt-4o\",\"dcterms:description\":\"Large language model via OpenRouter\"},{\"@id\":\"urn:civic-evidence:platform:civic-ai-tools\",\"@type\":[\"prov:Agent\",\"prov:SoftwareAgent\"],\"dcterms:title\":\"Civic AI Tools\",\"civic:url\":\"https://civicaitools.org\"}]},\"contentHash\":{\"sha256\":\"6b04b0d9732e57aa1624987f0bbe02d0f52628b38da11594eddca13eb4000151\"}}", + "contentHashSha256": "6b04b0d9732e57aa1624987f0bbe02d0f52628b38da11594eddca13eb4000151", + "envelopeHash": "935f9d899a9cabc8ec71e5f7d09b10be4080638f7fb9d09c1a56c58ec000bc39" + } + } + ], + "attestationCases": [ + { + "name": "withdraws", + "sourceTests": [ + "civic-ai-tools-website src/lib/evidence/attestation.test.ts :: buildAttestationNode: withdraws node carries the conformant envelope fields", + "civic-ai-tools-website src/lib/evidence/attestation.test.ts :: buildAttestationNode: nodeId is the JCS envelope hash and re-verifies after a storage round-trip", + "civic-ai-tools-website src/lib/evidence/attestation.test.ts :: buildAttestationNode: contentHash fingerprints the node minus contentHash (legacy-json/v1)" + ], + "input": { + "packageId": "11111111-2222-4333-8444-555555555555", + "createdAt": "2026-01-02T03:04:05.000Z", + "signingKeyId": "adopter:test-key-1", + "type": "attestation/withdraws/v1", + "targetNodeId": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "signer": { + "bindingTier": "platform", + "identifier": "platform:civic-ai-tools", + "displayName": "Civic AI Tools Platform" + }, + "reason": "data error in source dataset" + }, + "expected": { + "serializedJson": "{\"metadata\":{\"schemaVersion\":\"0.1.0\",\"packageId\":\"11111111-2222-4333-8444-555555555555\",\"createdAt\":\"2026-01-02T03:04:05.000Z\",\"signingKeyId\":\"adopter:test-key-1\"},\"type\":\"attestation/withdraws/v1\",\"signer\":{\"bindingTier\":\"platform\",\"identifier\":\"platform:civic-ai-tools\",\"displayName\":\"Civic AI Tools Platform\"},\"targetNodeId\":\"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\",\"contentCanonicalization\":\"https://typedstandards.org/canonicalization/legacy-json/v1\",\"reason\":\"data error in source dataset\",\"effectiveAt\":\"2026-01-02T03:04:05.000Z\",\"contentHash\":{\"sha256\":\"699ab6ba38161b7229e2c8f2dc372612a19c021c44e775da6545d7c8e4c74f62\"}}", + "contentHashSha256": "699ab6ba38161b7229e2c8f2dc372612a19c021c44e775da6545d7c8e4c74f62", + "nodeId": "514ebcbdeed266892358f8b0467a6dbaa98c71d7ef510386371662d2be92018e" + } + }, + { + "name": "reinstates", + "sourceTests": [ + "civic-ai-tools-website src/lib/evidence/attestation.test.ts :: buildAttestationNode: reinstates node carries priorWithdrawalNodeId and no effectiveAt" + ], + "input": { + "packageId": "11111111-2222-4333-8444-555555555555", + "createdAt": "2026-01-02T03:04:05.000Z", + "signingKeyId": "adopter:test-key-1", + "type": "attestation/reinstates/v1", + "targetNodeId": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "signer": { + "bindingTier": "platform", + "identifier": "platform:civic-ai-tools", + "displayName": "Civic AI Tools Platform" + }, + "reason": "source corrected", + "priorWithdrawalNodeId": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" + }, + "expected": { + "serializedJson": "{\"metadata\":{\"schemaVersion\":\"0.1.0\",\"packageId\":\"11111111-2222-4333-8444-555555555555\",\"createdAt\":\"2026-01-02T03:04:05.000Z\",\"signingKeyId\":\"adopter:test-key-1\"},\"type\":\"attestation/reinstates/v1\",\"signer\":{\"bindingTier\":\"platform\",\"identifier\":\"platform:civic-ai-tools\",\"displayName\":\"Civic AI Tools Platform\"},\"targetNodeId\":\"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\",\"contentCanonicalization\":\"https://typedstandards.org/canonicalization/legacy-json/v1\",\"reason\":\"source corrected\",\"priorWithdrawalNodeId\":\"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb\",\"contentHash\":{\"sha256\":\"32f1a2f1da6391fbc806ab2435317197d9423f726b31ae830fc14171fccf977e\"}}", + "contentHashSha256": "32f1a2f1da6391fbc806ab2435317197d9423f726b31ae830fc14171fccf977e", + "nodeId": "bafb34b18ce36dd8a182e224c223b2704578b607fd2fa1bd6276aa0cfe2f73f7" + } + }, + { + "name": "publishes", + "sourceTests": [ + "civic-ai-tools-website src/lib/evidence/attestation.test.ts :: buildAttestationNode: publishes node carries publicationHost + releasedAt (defaults to envelope ts)" + ], + "input": { + "packageId": "11111111-2222-4333-8444-555555555555", + "createdAt": "2026-01-02T03:04:05.000Z", + "signingKeyId": "adopter:test-key-1", + "type": "attestation/publishes/v1", + "targetNodeId": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "signer": { + "bindingTier": "platform", + "identifier": "platform:civic-ai-tools", + "displayName": "Civic AI Tools Platform" + }, + "publicationHost": "civicaitools.org" + }, + "expected": { + "serializedJson": "{\"metadata\":{\"schemaVersion\":\"0.1.0\",\"packageId\":\"11111111-2222-4333-8444-555555555555\",\"createdAt\":\"2026-01-02T03:04:05.000Z\",\"signingKeyId\":\"adopter:test-key-1\"},\"type\":\"attestation/publishes/v1\",\"signer\":{\"bindingTier\":\"platform\",\"identifier\":\"platform:civic-ai-tools\",\"displayName\":\"Civic AI Tools Platform\"},\"targetNodeId\":\"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\",\"contentCanonicalization\":\"https://typedstandards.org/canonicalization/legacy-json/v1\",\"publicationHost\":\"civicaitools.org\",\"releasedAt\":\"2026-01-02T03:04:05.000Z\",\"contentHash\":{\"sha256\":\"d5bba6e89d92a62ea4e851a6c9b8370a765b143ea6dd6662995dbbb3bbcdb20d\"}}", + "contentHashSha256": "d5bba6e89d92a62ea4e851a6c9b8370a765b143ea6dd6662995dbbb3bbcdb20d", + "nodeId": "6ab7c1bc242db97dd33a71866feb7d4a9143fe9f0243beb4ff0db462635f7d90" + } + }, + { + "name": "located-at", + "sourceTests": [ + "civic-ai-tools-website src/lib/evidence/attestation.test.ts :: buildAttestationNode: locatedAt node carries uri + targetContentHash distinct from its own contentHash (Q48)" + ], + "input": { + "packageId": "11111111-2222-4333-8444-555555555555", + "createdAt": "2026-01-02T03:04:05.000Z", + "signingKeyId": "adopter:test-key-1", + "type": "attestation/locatedAt/v1", + "targetNodeId": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "signer": { + "bindingTier": "platform", + "identifier": "platform:civic-ai-tools", + "displayName": "Civic AI Tools Platform" + }, + "uri": "https://blob.example/evidence-packages/aaaa.json", + "targetContentHash": { + "sha256": "dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd" + }, + "contentLength": 1234 + }, + "expected": { + "serializedJson": "{\"metadata\":{\"schemaVersion\":\"0.1.0\",\"packageId\":\"11111111-2222-4333-8444-555555555555\",\"createdAt\":\"2026-01-02T03:04:05.000Z\",\"signingKeyId\":\"adopter:test-key-1\"},\"type\":\"attestation/locatedAt/v1\",\"signer\":{\"bindingTier\":\"platform\",\"identifier\":\"platform:civic-ai-tools\",\"displayName\":\"Civic AI Tools Platform\"},\"targetNodeId\":\"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\",\"contentCanonicalization\":\"https://typedstandards.org/canonicalization/legacy-json/v1\",\"uri\":\"https://blob.example/evidence-packages/aaaa.json\",\"targetContentHash\":{\"sha256\":\"dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd\"},\"contentLength\":1234,\"contentHash\":{\"sha256\":\"c3684b03960f92cfc494e67c43f51cd5efffc2efa1a324d9bbf7fbe9735c0d2f\"}}", + "contentHashSha256": "c3684b03960f92cfc494e67c43f51cd5efffc2efa1a324d9bbf7fbe9735c0d2f", + "nodeId": "5054aa462a59f46b58671caf967950baf30f38f7a75ea447268e0a98a4b4e817" + } + }, + { + "name": "located-at-minimal", + "sourceTests": [ + "civic-ai-tools-website src/lib/evidence/attestation.test.ts :: buildAttestationNode: locatedAt omits optional fields when not supplied" + ], + "input": { + "packageId": "11111111-2222-4333-8444-555555555555", + "createdAt": "2026-01-02T03:04:05.000Z", + "signingKeyId": "adopter:test-key-1", + "type": "attestation/locatedAt/v1", + "targetNodeId": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "signer": { + "bindingTier": "platform", + "identifier": "platform:civic-ai-tools", + "displayName": "Civic AI Tools Platform" + }, + "uri": "https://blob.example/x.json" + }, + "expected": { + "serializedJson": "{\"metadata\":{\"schemaVersion\":\"0.1.0\",\"packageId\":\"11111111-2222-4333-8444-555555555555\",\"createdAt\":\"2026-01-02T03:04:05.000Z\",\"signingKeyId\":\"adopter:test-key-1\"},\"type\":\"attestation/locatedAt/v1\",\"signer\":{\"bindingTier\":\"platform\",\"identifier\":\"platform:civic-ai-tools\",\"displayName\":\"Civic AI Tools Platform\"},\"targetNodeId\":\"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\",\"contentCanonicalization\":\"https://typedstandards.org/canonicalization/legacy-json/v1\",\"uri\":\"https://blob.example/x.json\",\"contentHash\":{\"sha256\":\"5aff63b4ef7803ce996f0747315447483a112a4aad27666f64c383f6414b5e0e\"}}", + "contentHashSha256": "5aff63b4ef7803ce996f0747315447483a112a4aad27666f64c383f6414b5e0e", + "nodeId": "2a3a95ef10c6482c4e5284b373e00174e3b7973f0231c964f87ac970e71ca0af" + } + }, + { + "name": "evaluates", + "sourceTests": [ + "civic-ai-tools-website src/lib/evidence/attestation.test.ts :: buildAttestationNode: evaluates node carries methodology + scoringRubric + results" + ], + "input": { + "packageId": "11111111-2222-4333-8444-555555555555", + "createdAt": "2026-01-02T03:04:05.000Z", + "signingKeyId": "adopter:test-key-1", + "type": "attestation/evaluates/v1", + "targetNodeId": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "signer": { + "bindingTier": "platform", + "identifier": "platform:civic-ai-tools", + "displayName": "Civic AI Tools Platform" + }, + "methodology": { + "testSet": "civicaitools-adversarial-rubric/six-criterion-v1", + "promptSetVersion": "eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee", + "evaluatorModel": "anthropic/claude-sonnet-4-6" + }, + "scoringRubric": "civicaitools-adversarial-rubric/six-criterion-v1", + "results": { + "perCriterion": { + "dataSourceIdentification": { + "score": 8, + "comment": "solid" + } + }, + "overallScore": 8, + "assessment": "Good." + } + }, + "expected": { + "serializedJson": "{\"metadata\":{\"schemaVersion\":\"0.1.0\",\"packageId\":\"11111111-2222-4333-8444-555555555555\",\"createdAt\":\"2026-01-02T03:04:05.000Z\",\"signingKeyId\":\"adopter:test-key-1\"},\"type\":\"attestation/evaluates/v1\",\"signer\":{\"bindingTier\":\"platform\",\"identifier\":\"platform:civic-ai-tools\",\"displayName\":\"Civic AI Tools Platform\"},\"targetNodeId\":\"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\",\"contentCanonicalization\":\"https://typedstandards.org/canonicalization/legacy-json/v1\",\"methodology\":{\"testSet\":\"civicaitools-adversarial-rubric/six-criterion-v1\",\"promptSetVersion\":\"eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee\",\"evaluatorModel\":\"anthropic/claude-sonnet-4-6\"},\"scoringRubric\":\"civicaitools-adversarial-rubric/six-criterion-v1\",\"results\":{\"perCriterion\":{\"dataSourceIdentification\":{\"score\":8,\"comment\":\"solid\"}},\"overallScore\":8,\"assessment\":\"Good.\"},\"contentHash\":{\"sha256\":\"5dafa8c0c33b744e9b6f0f717aca285084c8bc3927950d33b3faded9d2df2b7b\"}}", + "contentHashSha256": "5dafa8c0c33b744e9b6f0f717aca285084c8bc3927950d33b3faded9d2df2b7b", + "nodeId": "dffe55d8e9a94f50da388b3587988b9e8fcd1ee38455d96a9d1fe0bb8aea79eb" + } + } + ] +} diff --git a/tests/guards.py b/tests/guards.py new file mode 100644 index 0000000..9cc0801 --- /dev/null +++ b/tests/guards.py @@ -0,0 +1,143 @@ +"""Static scanners for the wrapper's three standing rules (no tests of their own). + +- It reads no seed: no module names the CLI's seed variable, in any form. +- It passes the child process no environment but the inherited one: no ``env=`` on any call, + and nothing that sets, unsets or replaces a variable for a child. +- It computes none of the format's hashes: no module imports ``hashlib`` (or the modules behind + it), except the allowlisted P2 ``pin`` module, whose digest is a signed assertion. + +Each scanner takes a directory and returns one line per offence, so a test can drive it over the +package and over a fixture tree of offenders. +""" + +from __future__ import annotations + +import ast +from collections.abc import Iterator +from pathlib import Path + +SEED_VARIABLE = "TYPEDSTANDARDS_SIGNING_SEED_B64" + +#: Modules that compute digests. ``hmac`` and the underscore modules are hashlib's back doors. +HASH_MODULES = frozenset( + {"hashlib", "_hashlib", "_sha1", "_sha2", "_sha256", "_sha512", "_sha3", "_md5", "_blake2", "hmac"} +) + +#: The one module allowed to import hashlib: P2's pin (it does not exist yet), relative to the +#: package directory. +HASH_ALLOWLIST = frozenset({"pin.py"}) + +#: Calls that start a process with an explicit environment, or change this process's. +ENV_CALLS = frozenset( + { + "putenv", + "unsetenv", + "execve", + "execle", + "execlpe", + "execvpe", + "spawnve", + "spawnle", + "spawnlpe", + "spawnvpe", + "posix_spawn", + "posix_spawnp", + } +) +ENVIRON_MUTATORS = frozenset({"update", "pop", "popitem", "setdefault", "clear", "__setitem__", "__delitem__"}) + + +def python_files(root: Path) -> Iterator[Path]: + for path in sorted(root.rglob("*.py")): + if "_vendor" not in path.relative_to(root).parts: + yield path + + +def _is_environ(node: ast.AST) -> bool: + return (isinstance(node, ast.Attribute) and node.attr == "environ") or ( + isinstance(node, ast.Name) and node.id == "environ" + ) + + +def _call_name(node: ast.Call) -> str | None: + if isinstance(node.func, ast.Attribute): + return node.func.attr + if isinstance(node.func, ast.Name): + return node.func.id + return None + + +def seed_references(root: Path) -> list[str]: + """Every line of every module that names the seed variable: code, string or comment.""" + found = [] + for path in python_files(root): + for number, line in enumerate(path.read_text(encoding="utf-8").splitlines(), 1): + if SEED_VARIABLE in line: + found.append(f"{path.relative_to(root)}:{number}: names {SEED_VARIABLE}") + tree = ast.parse(path.read_text(encoding="utf-8"), filename=str(path)) + for node in ast.walk(tree): + # A name split across string literals that join at compile time is still one constant. + if isinstance(node, ast.Constant) and isinstance(node.value, str) and SEED_VARIABLE in node.value: + entry = f"{path.relative_to(root)}:{node.lineno}: a string holds {SEED_VARIABLE}" + if not any(f.startswith(f"{path.relative_to(root)}:{node.lineno}:") for f in found): + found.append(entry) + return found + + +def env_overrides(root: Path) -> list[str]: + """Every call that passes ``env=``, and every change to the process environment.""" + found = [] + for path in python_files(root): + where = path.relative_to(root) + tree = ast.parse(path.read_text(encoding="utf-8"), filename=str(path)) + for node in ast.walk(tree): + if isinstance(node, ast.Call): + for keyword in node.keywords: + if keyword.arg == "env": + found.append(f"{where}:{node.lineno}: a call passes env=") + elif ( + keyword.arg is None + and isinstance(keyword.value, ast.Dict) + and any(isinstance(k, ast.Constant) and k.value == "env" for k in keyword.value.keys) + ): + found.append(f"{where}:{node.lineno}: a call passes env= through **") + name = _call_name(node) + if name in ENV_CALLS: + found.append(f"{where}:{node.lineno}: calls {name}") + if name in ENVIRON_MUTATORS and isinstance(node.func, ast.Attribute) and _is_environ(node.func.value): + found.append(f"{where}:{node.lineno}: changes os.environ ({name})") + targets: list[ast.AST] = [] + if isinstance(node, (ast.Assign, ast.Delete)): + targets = list(node.targets) + elif isinstance(node, (ast.AugAssign, ast.AnnAssign)): + targets = [node.target] + for target in targets: + if isinstance(target, ast.Subscript) and _is_environ(target.value): + found.append(f"{where}:{node.lineno}: changes os.environ (item)") + elif isinstance(target, ast.Attribute) and target.attr == "environ": + found.append(f"{where}:{node.lineno}: replaces os.environ") + return found + + +def hash_imports(root: Path, allow: frozenset[str] = HASH_ALLOWLIST) -> list[str]: + """Every import of a digest module outside the allowlist, static or dynamic.""" + found = [] + for path in python_files(root): + where = path.relative_to(root) + if where.as_posix() in allow: + continue + tree = ast.parse(path.read_text(encoding="utf-8"), filename=str(path)) + for node in ast.walk(tree): + modules: list[str] = [] + if isinstance(node, ast.Import): + modules = [alias.name for alias in node.names] + elif isinstance(node, ast.ImportFrom) and node.module: + modules = [node.module] + elif isinstance(node, ast.Call) and _call_name(node) in {"import_module", "__import__"} and node.args: + first = node.args[0] + if isinstance(first, ast.Constant) and isinstance(first.value, str): + modules = [first.value] + for module in modules: + if module.split(".")[0] in HASH_MODULES: + found.append(f"{where}:{node.lineno}: imports {module}") + return found diff --git a/tests/support.py b/tests/support.py new file mode 100644 index 0000000..f954870 --- /dev/null +++ b/tests/support.py @@ -0,0 +1,68 @@ +"""Shared test helpers (no tests of their own). + +Tests set the signing seed in ``os.environ`` (``monkeypatch.setenv``), and the wrapper's child +process inherits it. Test code may generate a random seed; package code never touches one. +""" + +from __future__ import annotations + +import base64 +import copy +import json +import os +import stat +from pathlib import Path +from typing import Any + +FIXTURES = Path(__file__).parent / "fixtures" +GOLDEN_PATH = FIXTURES / "reference-golden.json" +BUNDLE_PATH = FIXTURES / "first-note.bundle.json" + +# The CLI's variable (packages/cli/src/seed.ts:9 in typedstandards). Named here, in tests only. +SEED_VARIABLE = "TYPEDSTANDARDS_SIGNING_SEED_B64" + +# RFC 8032 §7.1 TEST 1, the published test vector's 32-byte seed, as hex (the CLI's +# harness.test.ts:36). Its base64 is derived at run time. +RFC8032_TEST_1 = "9d61b19deffd5a60ba844af492ec2cc44449c5697b326919703bac031cae7f60" + + +def rfc8032_test_1_b64() -> str: + return base64.b64encode(bytes.fromhex(RFC8032_TEST_1)).decode("ascii") + + +def fresh_seed_b64() -> str: + """A fresh random seed for one test. Never a real key.""" + return base64.b64encode(os.urandom(32)).decode("ascii") + + +def load_golden() -> dict[str, Any]: + return json.loads(GOLDEN_PATH.read_text(encoding="utf-8")) + + +def envelope_case(name: str) -> dict[str, Any]: + return next(c for c in load_golden()["envelopeCases"] if c["name"] == name) + + +def self_certifying_input() -> dict[str, Any]: + """The golden's v01-default input, without its fixed packageId, createdAt and signingKeyId, + and with a self-certifying signer, whose identifier the CLI fills with the seed's did:key.""" + value = copy.deepcopy(envelope_case("v01-default")["input"]) + for key in ("packageId", "createdAt", "signingKeyId"): + value.pop(key, None) + value["signer"] = {"bindingTier": "pseudonymous", "displayName": "Example signer"} + return value + + +def write_stub(directory: Path, name: str, version: str, exit_code: int = 4, stderr: str = "") -> Path: + """A stand-in Node binary: prints ``version`` for ``--version``; otherwise writes ``stderr`` + and exits ``exit_code``.""" + path = directory / name + path.write_text( + "#!/bin/sh\n" + f'if [ "$1" = "--version" ]; then echo {version}; exit 0; fi\n' + f"printf '%s\\n' '{stderr}' >&2\n" + f"exit {exit_code}\n", + encoding="utf-8", + ) + path.chmod(path.stat().st_mode | stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH) + return path diff --git a/tests/test_commands.py b/tests/test_commands.py new file mode 100644 index 0000000..4ce7b34 --- /dev/null +++ b/tests/test_commands.py @@ -0,0 +1,181 @@ +"""The five pass-throughs end to end, the forms their inputs take, and the vendored tree.""" + +from __future__ import annotations + +import hashlib # test code only: checks what the CLI computed +import json +import logging +import subprocess +import sys +from pathlib import Path +from typing import Any + +import pytest +from support import self_certifying_input + +import typedstandards + +PACKAGE = Path(typedstandards.__file__).parent + + +def cli_calls(spawned: list[dict[str, Any]], command: str) -> list[dict[str, Any]]: + entry = str(typedstandards.cli_entry()) + return [c for c in spawned if len(c["args"]) > 2 and c["args"][1] == entry and c["args"][2] == command] + + +def flag_values(args: list[str], flag: str) -> list[str]: + return [args[i + 1] for i, a in enumerate(args) if a == flag] + + +def test_sign_view_verify_round_trip(seed: str) -> None: + signed = typedstandards.sign(self_certifying_input()) + assert set(signed) == {"package", "envelopeHash", "signature"} + assert signed["package"]["signer"]["identifier"].startswith("did:key:z6Mk") + bundle = typedstandards.view(signed, visibility="public", title="A test record") + assert bundle["packageHash"] == signed["envelopeHash"] + assert bundle["subjectTitle"] == "A test record" + assert "trustRegistryUrl" not in bundle # a self-certifying signer + result = typedstandards.verify(bundle) + assert result["ok"] is True + assert result["lifecycle"]["status"] == "active" + assert "checks" in result + + +def test_withdraw_carried_in_a_view(seed: str) -> None: + signed = typedstandards.sign(self_certifying_input()) + withdrawal = typedstandards.withdraw( + {"targetNodeId": signed["envelopeHash"], "reason": "a test withdrawal", "signer": signed["package"]["signer"]} + ) + assert set(withdrawal) == {"node", "nodeId", "signature"} + bundle = typedstandards.view(signed, visibility="public", attestations=[withdrawal]) + assert bundle["lifecycle"]["status"] == "withdrawn" + assert typedstandards.verify(bundle)["lifecycle"]["status"] == "withdrawn" + + +def test_attest_signs_a_corroboration(seed: str) -> None: + signed = typedstandards.sign(self_certifying_input()) + node = typedstandards.attest( + { + "type": "attestation/corroborates/v1", + "targetNodeId": signed["envelopeHash"], + "scope": "the whole record", + "signer": {"bindingTier": "pseudonymous", "displayName": "Example corroborator"}, + } + ) + assert node["node"]["type"] == "attestation/corroborates/v1" + assert node["node"]["targetNodeId"] == signed["envelopeHash"] + + +def test_view_removes_its_temporary_files(seed: str, spawned: list[dict[str, Any]]) -> None: + signed = typedstandards.sign(self_certifying_input()) + withdrawal = typedstandards.withdraw( + {"targetNodeId": signed["envelopeHash"], "reason": "r", "signer": signed["package"]["signer"]} + ) + typedstandards.view(signed, visibility="public", attestations=[withdrawal]) + (call,) = cli_calls(spawned, "view") + files = flag_values(call["args"], "--signed") + flag_values(call["args"], "--attestation") + assert len(files) == 2 + assert call["stdin"] is None + for path in files: + assert not Path(path).exists() + + +def test_view_removes_its_temporary_files_on_failure(seed: str, spawned: list[dict[str, Any]]) -> None: + value = self_certifying_input() + value["signer"] = {"bindingTier": "platform", "identifier": "platform:example", "displayName": "Example"} + signed = typedstandards.sign(value) + with pytest.raises(typedstandards.UsageError) as caught: + typedstandards.view(signed, visibility="public") # a non-did:key signer needs a registry URL + assert "trustRegistryUrl" in caught.value.stderr + (call,) = cli_calls(spawned, "view") + assert not Path(flag_values(call["args"], "--signed")[0]).exists() + + +def test_view_passes_paths_as_given(seed: str, tmp_path: Path, spawned: list[dict[str, Any]]) -> None: + signed_path = tmp_path / "signed.json" + signed_path.write_text(json.dumps(typedstandards.sign(self_certifying_input())), encoding="utf-8") + typedstandards.view(signed_path, visibility="unlisted", package_url="https://example.org/r.json") + (call,) = cli_calls(spawned, "view") + assert flag_values(call["args"], "--signed") == [str(signed_path)] + assert flag_values(call["args"], "--visibility") == ["unlisted"] + assert flag_values(call["args"], "--package-url") == ["https://example.org/r.json"] + assert signed_path.exists() + + +def test_verify_without_full(seed: str, spawned: list[dict[str, Any]]) -> None: + signed = typedstandards.sign(self_certifying_input()) + result = typedstandards.verify(signed, full=False) + assert set(result) == {"ok", "nodeId", "failures"} + (call,) = cli_calls(spawned, "verify") + assert "--json" not in call["args"] + + +def test_sign_an_output_file_inline(seed: str, tmp_path: Path) -> None: + output = tmp_path / "result.txt" + output.write_text("forty-two\n", encoding="utf-8") + value = self_certifying_input() + value.pop("output", None) + value.pop("contentCanonicalization", None) # the golden input names legacy-json/v1 + signed = typedstandards.sign(value, output_file=output) + assert signed["package"]["output"] == "forty-two\n" + assert signed["package"]["contentHash"]["sha256"] == hashlib.sha256(output.read_bytes()).hexdigest() + + +def test_mapping_input_goes_on_stdin(seed: str, spawned: list[dict[str, Any]]) -> None: + value = self_certifying_input() + typedstandards.sign(value) + (call,) = cli_calls(spawned, "sign") + assert call["args"][3:] == ["--input", "-"] + assert json.loads(call["stdin"].decode("utf-8")) == value + + +def test_success_diagnostics_are_logged(seed: str, caplog: pytest.LogCaptureFixture) -> None: + """An attention reading the CLI prints on stderr with exit 0 is logged, not lost.""" + signed = typedstandards.sign(self_certifying_input()) + with caplog.at_level(logging.INFO, logger="typedstandards"): + typedstandards.attest( + { + "type": "attestation/corroborates/v1", + "targetNodeId": signed["envelopeHash"], + "scope": "the whole record", + "signer": {"bindingTier": "platform", "identifier": "platform:example", "displayName": "Example"}, + } + ) + assert "authorization: key_unbound (attention)" in caplog.text + + +@pytest.mark.parametrize("bad", [42, b"{}", ["a"]]) +def test_input_types(bad: Any) -> None: + with pytest.raises(TypeError): + typedstandards.sign(bad) + + +def test_attestations_must_be_a_list(seed: str) -> None: + signed = typedstandards.sign(self_certifying_input()) + with pytest.raises(TypeError): + typedstandards.view(signed, visibility="public", attestations={"node": {}}) # type: ignore[arg-type] + + +def test_runs_the_vendored_tree(seed: str, spawned: list[dict[str, Any]]) -> None: + entry = typedstandards.cli_entry() + assert entry.is_relative_to(PACKAGE / "_vendor" / "node_modules") + typedstandards.sign(self_certifying_input()) + (call,) = cli_calls(spawned, "sign") + assert call["args"][0] == typedstandards.locate_node() + + +def test_vendored_tree_carries_each_licence() -> None: + vendor = PACKAGE / "_vendor" + lock = json.loads((vendor / "package-lock.json").read_text(encoding="utf-8")) + packages = [key for key in lock["packages"] if key] + assert len(packages) == 6 + for key in packages: + assert (vendor / key / "LICENSE").is_file(), key + assert not (vendor / "node_modules" / ".bin").exists() + + +def test_import_loads_neither_p2_dependency() -> None: + """httpx and PyYAML are declared for P2's helpers; importing the package loads neither.""" + code = "import sys, typedstandards; print(sorted(m for m in ('httpx', 'yaml') if m in sys.modules))" + printed = subprocess.run([sys.executable, "-c", code], capture_output=True, text=True, check=True).stdout + assert printed.strip() == "[]" diff --git a/tests/test_d9.py b/tests/test_d9.py new file mode 100644 index 0000000..0561276 --- /dev/null +++ b/tests/test_d9.py @@ -0,0 +1,89 @@ +"""Acceptance 5, second half (G0 D9 = A; typedstandards#136): verify drops a top-level +trustRegistry from a bundle before the CLI sees it, and changes nothing else. + +CLI 0.2.0's verify accepts only the keys view prints (packages/cli/src/verify.ts:139-157), and +exits 2 on the trustRegistry that host-core 0.1.1 inlines in every bundle it serves under a +registry. The fixture is such a bundle, the host template's served first-note.bundle.json +(fixtures/README.md gives its provenance). +""" + +from __future__ import annotations + +import copy +import hashlib # test code only: the fixture's provenance check +import json +from pathlib import Path +from typing import Any + +import pytest +from support import BUNDLE_PATH, self_certifying_input + +import typedstandards +from typedstandards._cli import run + +BUNDLE_SHA256 = "cb11d2a229c9695db6c7f4d6c9349ccee14f14af6c39844886480699ba2401ba" + + +def bundle() -> dict[str, Any]: + return json.loads(BUNDLE_PATH.read_text(encoding="utf-8")) + + +def cli_calls(spawned: list[dict[str, Any]], command: str) -> list[dict[str, Any]]: + entry = str(typedstandards.cli_entry()) + return [c for c in spawned if len(c["args"]) > 2 and c["args"][1] == entry and c["args"][2] == command] + + +def test_fixture_is_the_pinned_copy() -> None: + assert hashlib.sha256(BUNDLE_PATH.read_bytes()).hexdigest() == BUNDLE_SHA256 + assert "trustRegistry" in bundle() + + +def test_the_cli_itself_refuses_the_key() -> None: + """The reason for the workaround. When a pinned CLI accepts the key this test fails, and the + workaround and this test are removed together.""" + with pytest.raises(typedstandards.UsageError) as caught: + run("verify", ["--input", str(BUNDLE_PATH)]) + assert "trustRegistry" in caught.value.stderr + + +def test_verify_drops_only_trust_registry(spawned: list[dict[str, Any]]) -> None: + original = bundle() + given = copy.deepcopy(original) + result = typedstandards.verify(given) + assert result["ok"] is True + assert result["nodeId"] == original["packageHash"] + assert given == original, "the caller's bundle was changed" + (call,) = cli_calls(spawned, "verify") + received = json.loads(call["stdin"].decode("utf-8")) + expected = {k: v for k, v in original.items() if k != "trustRegistry"} + assert received == expected + assert list(received) == list(expected), "key order changed" + + +def test_verify_drops_it_from_a_path_too(spawned: list[dict[str, Any]]) -> None: + result = typedstandards.verify(BUNDLE_PATH) + assert result["ok"] is True + (call,) = cli_calls(spawned, "verify") + received = json.loads(call["stdin"].decode("utf-8")) + assert received == {k: v for k, v in bundle().items() if k != "trustRegistry"} + + +def test_a_bundle_without_the_key_is_passed_as_given(seed: str, tmp_path: Path, spawned: list[dict[str, Any]]) -> None: + view = typedstandards.view(typedstandards.sign(self_certifying_input()), visibility="public") + path = tmp_path / "bundle.json" + path.write_text(json.dumps(view), encoding="utf-8") + spawned.clear() + assert typedstandards.verify(path)["ok"] is True + (call,) = cli_calls(spawned, "verify") + assert call["args"][3:5] == ["--input", str(path)] + assert call["stdin"] is None + + +def test_a_signed_document_is_not_altered(seed: str) -> None: + """Only a bundle (a document with packageHash) loses the key; a signed document carrying it + reaches the CLI as given, which refuses it.""" + signed = typedstandards.sign(self_certifying_input()) + signed["trustRegistry"] = {"keys": []} + with pytest.raises(typedstandards.UsageError) as caught: + typedstandards.verify(signed) + assert "trustRegistry" in caught.value.stderr diff --git a/tests/test_exit_codes.py b/tests/test_exit_codes.py new file mode 100644 index 0000000..3bf909a --- /dev/null +++ b/tests/test_exit_codes.py @@ -0,0 +1,95 @@ +"""Acceptance 4: the CLI's exit codes 1 to 4 map to four exception classes that share a base and +carry the exit code and the CLI's stderr text (packages/cli/src/errors.ts:3-14 in typedstandards). +Exits 1, 2 and 3 are driven through the real CLI; exit 4 through a stub named by the override, +since the real CLI exits 4 only on a bug. +""" + +from __future__ import annotations + +import copy +from pathlib import Path + +import pytest +from support import SEED_VARIABLE, self_certifying_input, write_stub + +import typedstandards +from typedstandards import ( + NODE_OVERRIDE, + CliError, + InternalError, + NodeLocatorError, + SeedError, + UsageError, + VerificationError, +) + + +def test_the_classes_share_a_base() -> None: + for cls in (VerificationError, UsageError, SeedError, InternalError): + assert issubclass(cls, CliError) + assert len({VerificationError, UsageError, SeedError, InternalError}) == 4 + assert not issubclass(NodeLocatorError, CliError) + + +def test_exit_1_a_tampered_record(seed: str) -> None: + signed = typedstandards.sign(self_certifying_input()) + tampered = copy.deepcopy(signed) + tampered["package"]["metadata"]["createdAt"] = "2000-01-01T00:00:00.000Z" + with pytest.raises(VerificationError) as caught: + typedstandards.verify(tampered) + err = caught.value + assert err.exit_code == 1 + assert "#1 envelopeIntegrity: altered (alarm)" in err.stderr + # verify prints its verdict on stdout before exiting 1; the exception carries it. + assert err.document["ok"] is False + assert {"check": "#1", "field": "envelopeIntegrity", "status": "altered"} in err.document["failures"] + assert "checks" in err.document # --json, the default + + +def test_exit_2_a_bad_input(seed: str) -> None: + bad = self_certifying_input() + bad["notAField"] = True + with pytest.raises(UsageError) as caught: + typedstandards.sign(bad) + assert caught.value.exit_code == 2 + assert "notAField" in caught.value.stderr + assert caught.value.command == "sign" + + +def test_exit_3_an_unset_seed(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.delenv(SEED_VARIABLE, raising=False) + with pytest.raises(SeedError) as caught: + typedstandards.sign(self_certifying_input()) + assert caught.value.exit_code == 3 + assert f"{SEED_VARIABLE} is not set" in caught.value.stderr + + +def test_exit_3_a_malformed_seed(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv(SEED_VARIABLE, "not a seed") + with pytest.raises(SeedError) as caught: + typedstandards.withdraw( + { + "targetNodeId": "a" * 64, + "reason": "r", + "signer": {"bindingTier": "pseudonymous", "displayName": "Example"}, + } + ) + assert caught.value.exit_code == 3 + + +def test_exit_4_an_internal_error(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + stub = write_stub(tmp_path, "node", "v24.21.0", exit_code=4, stderr="typedstandards sign: internal error: stub") + monkeypatch.setenv(NODE_OVERRIDE, str(stub)) + with pytest.raises(InternalError) as caught: + typedstandards.sign(self_certifying_input()) + assert caught.value.exit_code == 4 + assert caught.value.stderr == "typedstandards sign: internal error: stub\n" + + +def test_an_unmapped_exit_code_raises_the_base(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + stub = write_stub(tmp_path, "node", "v24.21.0", exit_code=9, stderr="unexpected") + monkeypatch.setenv(NODE_OVERRIDE, str(stub)) + with pytest.raises(CliError) as caught: + typedstandards.sign(self_certifying_input()) + assert type(caught.value) is CliError + assert caught.value.exit_code == 9 diff --git a/tests/test_golden.py b/tests/test_golden.py new file mode 100644 index 0000000..934c5cb --- /dev/null +++ b/tests/test_golden.py @@ -0,0 +1,86 @@ +"""Acceptance 1: the golden's 9 envelope cases and its withdraws case replay through the +wrapper's sign and withdraw, and match serializedJson, contentHashSha256 and envelopeHash +(nodeId for the withdrawal). + +The fixture is a verbatim copy of typedstandards' reference golden (fixtures/README.md gives its +provenance); the first test pins its SHA-256. The seed does not enter the envelope hash, so each +case is signed with a fresh test seed, except v01-self-certified-signer: its signer is the did:key +of RFC 8032 §7.1 TEST 1, and sign verifies its own result, so only that test vector's seed signs it. +""" + +from __future__ import annotations + +import hashlib # test code only: the fixture's provenance check +import json +from typing import Any + +import pytest +from support import GOLDEN_PATH, SEED_VARIABLE, fresh_seed_b64, load_golden, rfc8032_test_1_b64 + +import typedstandards + +GOLDEN_SHA256 = "d2bcfc2bc017b07502b3b00c3aa16de402df134128a374b4582650b79fb501c1" +SELF_CERTIFIED = "v01-self-certified-signer" +GOLDEN = load_golden() + + +def serialized(value: Any) -> str: + # JSON.stringify's form: no whitespace, non-ASCII kept. + return json.dumps(value, separators=(",", ":"), ensure_ascii=False) + + +def test_fixture_is_the_pinned_copy() -> None: + assert hashlib.sha256(GOLDEN_PATH.read_bytes()).hexdigest() == GOLDEN_SHA256 + + +def test_fixture_holds_the_replayed_cases() -> None: + assert len(GOLDEN["envelopeCases"]) == 9 + assert len(GOLDEN["attestationCases"]) == 6 + assert any(c["name"] == SELF_CERTIFIED for c in GOLDEN["envelopeCases"]) + assert any(c["name"] == "withdraws" for c in GOLDEN["attestationCases"]) + + +@pytest.mark.parametrize("case", GOLDEN["envelopeCases"], ids=lambda c: c["name"]) +def test_sign_replays_the_golden(case: dict[str, Any], monkeypatch: pytest.MonkeyPatch) -> None: + seed = rfc8032_test_1_b64() if case["name"] == SELF_CERTIFIED else fresh_seed_b64() + monkeypatch.setenv(SEED_VARIABLE, seed) + printed = typedstandards.sign(case["input"]) + expected = case["expected"] + assert serialized(printed["package"]) == expected["serializedJson"], "serialized JSON diverged" + if expected["contentHashSha256"] is None: + assert "contentHash" not in printed["package"], "a legacy case carries no contentHash" + else: + assert printed["package"]["contentHash"]["sha256"] == expected["contentHashSha256"] + assert printed["envelopeHash"] == expected["envelopeHash"], "envelope hash diverged" + + +def test_withdraw_replays_the_golden(monkeypatch: pytest.MonkeyPatch) -> None: + case = next(c for c in GOLDEN["attestationCases"] if c["name"] == "withdraws") + monkeypatch.setenv(SEED_VARIABLE, fresh_seed_b64()) + printed = typedstandards.withdraw(case["input"]) + expected = case["expected"] + assert serialized(printed["node"]) == expected["serializedJson"], "serialized JSON diverged" + assert printed["node"]["contentHash"]["sha256"] == expected["contentHashSha256"] + assert printed["nodeId"] == expected["nodeId"], "node id diverged" + + +def test_sign_replays_from_a_path(tmp_path: Any, monkeypatch: pytest.MonkeyPatch) -> None: + """The same replay with the input given as a file path rather than a mapping.""" + case = next(c for c in GOLDEN["envelopeCases"] if c["name"] == "v01-default") + path = tmp_path / "input.json" + path.write_text(json.dumps(case["input"]), encoding="utf-8") + monkeypatch.setenv(SEED_VARIABLE, fresh_seed_b64()) + printed = typedstandards.sign(path) + assert serialized(printed["package"]) == case["expected"]["serializedJson"] + assert printed["envelopeHash"] == case["expected"]["envelopeHash"] + + +def test_self_certified_case_needs_the_test_vector_seed(monkeypatch: pytest.MonkeyPatch) -> None: + """Another seed cannot sign the self-certified case: sign's own check fails (exit 1), + and nothing is printed on stdout.""" + case = next(c for c in GOLDEN["envelopeCases"] if c["name"] == SELF_CERTIFIED) + monkeypatch.setenv(SEED_VARIABLE, fresh_seed_b64()) + with pytest.raises(typedstandards.VerificationError) as caught: + typedstandards.sign(case["input"]) + assert caught.value.exit_code == 1 + assert caught.value.document is None diff --git a/tests/test_guards.py b/tests/test_guards.py new file mode 100644 index 0000000..0b77ed2 --- /dev/null +++ b/tests/test_guards.py @@ -0,0 +1,193 @@ +"""Acceptance 2: the wrapper reads no seed, passes its child no environment but the inherited +one, and imports no digest module. + +Each rule is checked statically (guards.py scans the package's source) and the first two at run +time as well (the wrapper's child processes and its environment reads are recorded while it +drives the real CLI). Each scanner is also driven over a tree of offending modules, so a guard +that can no longer fail is itself a failure. +""" + +from __future__ import annotations + +import os +from collections.abc import Iterator, MutableMapping +from pathlib import Path +from typing import Any + +import pytest +from guards import env_overrides, hash_imports, seed_references +from support import SEED_VARIABLE, self_certifying_input + +import typedstandards + +PACKAGE = Path(typedstandards.__file__).parent + + +# --- statically, over the package ------------------------------------------------------------- + + +def test_package_names_no_seed_variable() -> None: + assert seed_references(PACKAGE) == [] + + +def test_package_passes_no_environment() -> None: + assert env_overrides(PACKAGE) == [] + + +def test_package_imports_no_digest_module() -> None: + assert hash_imports(PACKAGE) == [] + + +def test_package_runs_no_global_cli() -> None: + """The wrapper runs the vendored entry file, never npx or a typedstandards on PATH.""" + for path in PACKAGE.rglob("*.py"): + if "_vendor" in path.relative_to(PACKAGE).parts: + continue + text = path.read_text(encoding="utf-8") + assert "npx" not in text, path + assert 'which("typedstandards")' not in text, path + + +# --- each scanner fails on offenders ------------------------------------------------------------ + +OFFENDERS = { + "reads_seed.py": "import os\nseed = os.environ.get('TYPEDSTANDARDS_SIGNING_SEED_B64')\n", + "joined_seed.py": "import os\nseed = os.environ['TYPEDSTANDARDS_SIGNING_' 'SEED_B64']\n", + "comment_seed.py": "# the CLI reads TYPEDSTANDARDS_SIGNING_SEED_B64\n", + "env_kw.py": "import subprocess\nsubprocess.run(['node'], env={'A': 'b'})\n", + "env_star.py": "import subprocess\nsubprocess.run(['node'], **{'env': {}})\n", + "env_set.py": "import os\nos.environ['X'] = 'y'\n", + "env_update.py": "import os\nos.environ.update(X='y')\n", + "env_putenv.py": "import os\nos.putenv('X', 'y')\n", + "env_execve.py": "import os\nos.execve('/bin/true', ['true'], {})\n", + "hash_import.py": "import hashlib\n", + "hash_from.py": "from hashlib import sha256\n", + "hash_dynamic.py": "import importlib\nh = importlib.import_module('hashlib')\n", + "hash_hmac.py": "import hmac\n", + "pin.py": "import hashlib\n", +} + + +@pytest.fixture +def offenders(tmp_path: Path) -> Path: + for name, text in OFFENDERS.items(): + (tmp_path / name).write_text(text, encoding="utf-8") + return tmp_path + + +def _files(found: list[str]) -> set[str]: + return {line.split(":", 1)[0] for line in found} + + +def test_seed_scanner_fails_on_offenders(offenders: Path) -> None: + assert _files(seed_references(offenders)) == {"reads_seed.py", "joined_seed.py", "comment_seed.py"} + + +def test_env_scanner_fails_on_offenders(offenders: Path) -> None: + assert _files(env_overrides(offenders)) == { + "env_kw.py", + "env_star.py", + "env_set.py", + "env_update.py", + "env_putenv.py", + "env_execve.py", + } + + +def test_hash_scanner_fails_on_offenders_and_allows_only_pin(offenders: Path) -> None: + assert _files(hash_imports(offenders)) == {"hash_import.py", "hash_from.py", "hash_dynamic.py", "hash_hmac.py"} + assert "pin.py" in _files(hash_imports(offenders, allow=frozenset())) + + +# --- at run time, against the real CLI ----------------------------------------------------------- + + +def _drive_every_command() -> None: + signed = typedstandards.sign(self_certifying_input()) + withdrawal = typedstandards.withdraw( + {"targetNodeId": signed["envelopeHash"], "reason": "a test withdrawal", "signer": signed["package"]["signer"]} + ) + typedstandards.attest( + { + "type": "attestation/corroborates/v1", + "targetNodeId": signed["envelopeHash"], + "scope": "the whole record", + "signer": {"bindingTier": "pseudonymous", "displayName": "Example corroborator"}, + } + ) + bundle = typedstandards.view(signed, visibility="public", attestations=[withdrawal]) + typedstandards.verify(bundle) + typedstandards.cli_version() + + +def test_every_child_inherits_the_environment(seed: str, spawned: list[dict[str, Any]]) -> None: + before = dict(os.environ) + _drive_every_command() + entry = str(typedstandards.cli_entry()) + commands = {call["args"][2] for call in spawned if len(call["args"]) > 2 and call["args"][1] == entry} + assert commands == {"sign", "withdraw", "attest", "view", "verify", "--version"} + # Failures name keys and argv only: a failing assertion's repr would otherwise print the + # environment, which in a developer's shell or a CI runner can hold real secrets. + for call in spawned: + if "env" in call["kwargs"]: + pytest.fail(f"env= passed to {call['args']}") + if call["environ"] != before: + pytest.fail(f"the environment changed before {call['args']}: {_changed_keys(before, call['environ'])}") + if seed in " ".join(call["args"]): + pytest.fail("the seed reached an argument") + if call["stdin"] is not None and seed.encode() in call["stdin"]: + pytest.fail("the seed reached stdin") + if dict(os.environ) != before: + pytest.fail(f"the environment changed: {_changed_keys(before, dict(os.environ))}") + + +def _changed_keys(before: dict[str, str], after: dict[str, str]) -> list[str]: + return sorted(k for k in before.keys() | after.keys() if before.get(k) != after.get(k)) + + +class RecordingEnviron(MutableMapping[str, str]): + """os.environ, recording every key read and any read of the whole mapping.""" + + def __init__(self, real: MutableMapping[str, str]) -> None: + self.real = real + self.keys_read: list[Any] = [] + self.read_all = False + + def __getitem__(self, key: Any) -> str: + self.keys_read.append(key) + return self.real[key] + + def get(self, key: Any, default: Any = None) -> Any: + self.keys_read.append(key) + return self.real.get(key, default) + + def __contains__(self, key: object) -> bool: + self.keys_read.append(key) + return key in self.real + + def __iter__(self) -> Iterator[str]: + self.read_all = True + return iter(self.real) + + def __len__(self) -> int: + return len(self.real) + + def __setitem__(self, key: str, value: str) -> None: + self.real[key] = value + + def __delitem__(self, key: str) -> None: + del self.real[key] + + def copy(self) -> dict[str, str]: + self.read_all = True + return dict(self.real) + + +def test_wrapper_never_reads_the_seed_variable(seed: str, monkeypatch: pytest.MonkeyPatch) -> None: + recorder = RecordingEnviron(os.environ) + monkeypatch.setattr(os, "environ", recorder) + _drive_every_command() + if SEED_VARIABLE in recorder.keys_read: + pytest.fail(f"the wrapper read {SEED_VARIABLE}") + if recorder.read_all: + pytest.fail("the wrapper read the whole environment") diff --git a/tests/test_node.py b/tests/test_node.py new file mode 100644 index 0000000..fa7aa96 --- /dev/null +++ b/tests/test_node.py @@ -0,0 +1,112 @@ +"""Acceptance 3: the Node locator. + +The floor is 20.19.0, @typedstandards/cli 0.2.0's engines.node. TYPEDSTANDARDS_NODE names a Node +binary and is tried first; then ``node`` on PATH. With no Node, or one below the floor, the +wrapper raises NodeLocatorError naming 20.19 and the override before the CLI runs. +""" + +from __future__ import annotations + +import shutil +from pathlib import Path + +import pytest +from support import self_certifying_input, write_stub + +import typedstandards +from typedstandards import NODE_OVERRIDE, NodeLocatorError + + +def _names_floor_and_override(err: NodeLocatorError) -> None: + message = str(err) + assert "20.19" in message + assert NODE_OVERRIDE in message + + +def test_override_variable_is_named() -> None: + assert NODE_OVERRIDE == "TYPEDSTANDARDS_NODE" + assert typedstandards.NODE_FLOOR == (20, 19, 0) + + +def test_no_node_on_path_and_no_override(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.delenv(NODE_OVERRIDE, raising=False) + monkeypatch.setenv("PATH", str(tmp_path)) + with pytest.raises(NodeLocatorError) as caught: + typedstandards.sign(self_certifying_input()) + _names_floor_and_override(caught.value) + + +def test_node_below_the_floor_on_path(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + write_stub(tmp_path, "node", "v20.18.0") + monkeypatch.delenv(NODE_OVERRIDE, raising=False) + monkeypatch.setenv("PATH", str(tmp_path)) + with pytest.raises(NodeLocatorError) as caught: + typedstandards.sign(self_certifying_input()) + _names_floor_and_override(caught.value) + assert "20.18.0" in str(caught.value) + + +def test_node_below_the_floor_by_override(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + """The override is tried before PATH, so an old override fails even with a good node on PATH.""" + stub = write_stub(tmp_path, "old-node", "v20.18.0") + monkeypatch.setenv(NODE_OVERRIDE, str(stub)) + with pytest.raises(NodeLocatorError) as caught: + typedstandards.sign(self_certifying_input()) + _names_floor_and_override(caught.value) + + +def test_node_at_the_floor_passes(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + stub = write_stub(tmp_path, "node", "v20.19.0") + monkeypatch.delenv(NODE_OVERRIDE, raising=False) + monkeypatch.setenv("PATH", str(tmp_path)) + assert typedstandards.locate_node() == str(stub) + + +def test_override_comes_before_path(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + stub = write_stub(tmp_path, "pinned-node", "v24.0.0") + monkeypatch.setenv(NODE_OVERRIDE, str(stub)) + assert shutil.which("node") != str(stub) + assert typedstandards.locate_node() == str(stub) + + +def test_override_that_is_not_a_binary(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv(NODE_OVERRIDE, str(tmp_path / "missing-node")) + with pytest.raises(NodeLocatorError) as caught: + typedstandards.locate_node() + _names_floor_and_override(caught.value) + assert "missing-node" in str(caught.value) + + +@pytest.mark.parametrize("printed", ["", "node", "20.19.0", "v20"]) +def test_unreadable_version(printed: str, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + stub = write_stub(tmp_path, "odd-node", printed or "''") + monkeypatch.setenv(NODE_OVERRIDE, str(stub)) + with pytest.raises(NodeLocatorError) as caught: + typedstandards.locate_node() + _names_floor_and_override(caught.value) + + +@pytest.mark.parametrize( + ("printed", "ok"), + [ + ("v20.18.9", False), + ("v19.99.0", False), + ("v20.19.0", True), + ("v20.20.1", True), + ("v22.0.0", True), + ("v100.0.0", True), + ], +) +def test_floor_comparison(printed: str, ok: bool, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + stub = write_stub(tmp_path, "node-under-test", printed) + monkeypatch.setenv(NODE_OVERRIDE, str(stub)) + if ok: + assert typedstandards.locate_node() == str(stub) + else: + with pytest.raises(NodeLocatorError): + typedstandards.locate_node() + + +def test_the_test_runners_node_passes(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.delenv(NODE_OVERRIDE, raising=False) + assert typedstandards.locate_node() == shutil.which("node") diff --git a/tests/test_version.py b/tests/test_version.py new file mode 100644 index 0000000..c90e41c --- /dev/null +++ b/tests/test_version.py @@ -0,0 +1,41 @@ +"""Acceptance 5, first half: CLI_VERSION is the version of the CLI the wrapper runs. + +The vendored CLI's ``--version`` prints JSON (packages/cli/src/run.ts:61-63 in typedstandards), +not a bare string; its ``version`` must equal CLI_VERSION, as must the vendored package.json and +the pin in this repository's package.json and package-lock.json. +""" + +from __future__ import annotations + +import json +from pathlib import Path + +import typedstandards +from typedstandards._cli import run + +ROOT = Path(__file__).parent.parent + + +def test_cli_version_is_the_pin() -> None: + assert typedstandards.CLI_VERSION == "0.2.0" + + +def test_vendored_cli_prints_cli_version() -> None: + printed = run("--version") + assert printed == {"name": "@typedstandards/cli", "version": typedstandards.CLI_VERSION} + assert typedstandards.cli_version() == typedstandards.CLI_VERSION + + +def test_vendored_package_json_is_cli_version() -> None: + manifest = typedstandards.cli_entry().parents[2] / "package.json" + data = json.loads(manifest.read_text(encoding="utf-8")) + assert data["name"] == "@typedstandards/cli" + assert data["version"] == typedstandards.CLI_VERSION + + +def test_repository_pin_is_cli_version() -> None: + """The committed package.json pins the CLI exactly, and the lock resolves that version.""" + package = json.loads((ROOT / "package.json").read_text(encoding="utf-8")) + lock = json.loads((ROOT / "package-lock.json").read_text(encoding="utf-8")) + assert package["dependencies"] == {"@typedstandards/cli": typedstandards.CLI_VERSION} + assert lock["packages"]["node_modules/@typedstandards/cli"]["version"] == typedstandards.CLI_VERSION diff --git a/uv.lock b/uv.lock new file mode 100644 index 0000000..8cef329 --- /dev/null +++ b/uv.lock @@ -0,0 +1,247 @@ +version = 1 +revision = 3 +requires-python = ">=3.11" + +[[package]] +name = "anyio" +version = "4.15.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "idna" }, + { name = "typing-extensions", marker = "python_full_version < '3.15'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a9/d2/f4d173e22df740bc37b1db102b386ba719b66e95b0f0d751f556b387e6d2/anyio-4.15.1.tar.gz", hash = "sha256:9f28306018cbd6d329e64a36d58256edff76dd996fe423bc957326e578b82a94", size = 276966, upload-time = "2026-09-05T10:42:39.44Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/12/b8/4bd346e22b28902df4d651910f5242c28d84e4a5c2435ca5c3f797ed7e2e/anyio-4.15.1-py3-none-any.whl", hash = "sha256:6152fdbbf9a77fdec97731721bebf7c4c44f7c29b424b0065826173efc7ed101", size = 132079, upload-time = "2026-09-05T10:42:37.923Z" }, +] + +[[package]] +name = "certifi" +version = "2026.7.22" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a3/c2/24167ea9858356b47a87a50d39908bfdb72ceeefe0041586e704e5376b3a/certifi-2026.7.22.tar.gz", hash = "sha256:741e2c3b351ddf169a738da9f2c048608ff7f2c5cc02f1ebc6b118bb090d5d55", size = 138112, upload-time = "2026-07-22T03:35:12.644Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0b/a7/71ac2cff56fec219ed242bb11b8efb69fcc4bec75db06fb7bfe35de520e6/certifi-2026.7.22-py3-none-any.whl", hash = "sha256:62f22742b58a1a33014a2b6b706588a8d7e2a88ae7bd1a6ebe8c992928483775", size = 136983, upload-time = "2026-07-22T03:35:11.276Z" }, +] + +[[package]] +name = "colorama" +version = "0.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, +] + +[[package]] +name = "h11" +version = "0.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250, upload-time = "2025-04-24T03:35:25.427Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" }, +] + +[[package]] +name = "httpcore" +version = "1.0.9" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "h11" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484, upload-time = "2025-04-24T22:06:22.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" }, +] + +[[package]] +name = "httpx" +version = "0.28.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "certifi" }, + { name = "httpcore" }, + { name = "idna" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406, upload-time = "2024-12-06T15:37:23.222Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" }, +] + +[[package]] +name = "idna" +version = "3.20" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f5/08/8eea9d4b8302028f3abb2c0813953f7aec26d33b7a8960ed760e65ff29fa/idna-3.20.tar.gz", hash = "sha256:a7db850025b95ded1eae8a46181a1a6c56c92c96f0e2b005d9ff8dc0210cab44", size = 216463, upload-time = "2026-09-17T14:11:04.752Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/58/a2/bb081bab032533a855d44de1d56f8e8426114ff1ba5d1f07a438a0a654f8/idna-3.20-py3-none-any.whl", hash = "sha256:ab7ae7122974553370f0bdb919e1a960b2cd1bc1ef0276416d896db81c14582c", size = 69583, upload-time = "2026-09-17T14:11:03.168Z" }, +] + +[[package]] +name = "iniconfig" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, +] + +[[package]] +name = "packaging" +version = "26.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7d/fa/3944b40b07da9ce895c0e6303a5ab7d53da063554f534556b134a54d6093/packaging-26.3.tar.gz", hash = "sha256:94edc256424af38762eb31306eed28beb9f0efc50a8837492c9d6fd6004aed79", size = 313412, upload-time = "2026-08-04T18:15:28.737Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/63/34/ba1c580383c9eada3711951fef0795c80b829a078d72188184bcab9dd527/packaging-26.3-py3-none-any.whl", hash = "sha256:d7193f7c8e4e93f444fde0262bf90af30e16fa0ad0ad44cb553c87339b23cd1c", size = 129956, upload-time = "2026-08-04T18:15:27.159Z" }, +] + +[[package]] +name = "pluggy" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, +] + +[[package]] +name = "pygments" +version = "2.21.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/49/2e/ced460408999b33da6b31b0021b0f37d329e202d4169aeb164493778f25b/pygments-2.21.0.tar.gz", hash = "sha256:610ca751c9bc2492b38eb9a38a7fbc93edbbb2d7182edaf34e66ae493dee5c8c", size = 5005329, upload-time = "2026-08-17T08:02:48.824Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/71/46/17f022dd3e953bf20a04a028a21ec746d942f8d2af30fa0f124fa0e6a684/pygments-2.21.0-py3-none-any.whl", hash = "sha256:2363c69b61c4a97c838da3b130dcd6468f4848992b21a82f2a63ec34377137d9", size = 1250147, upload-time = "2026-08-17T08:02:44.912Z" }, +] + +[[package]] +name = "pytest" +version = "9.1.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e4/47/b9efed96c114afcfa3c9d3fe98a76a1d14c74a9e266d397cf6eb64be5e01/pytest-9.1.1.tar.gz", hash = "sha256:1088fbde8f2b49d95a549a195707afa7a76a3ce9bcadc26b6d71f0ffda5fe313", size = 1636369, upload-time = "2026-06-19T10:58:32.857Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/24/25/1de2678b631f5a49215c6c96fff41ba892b0a34df68d6d80292b1b48aa7f/pytest-9.1.1-py3-none-any.whl", hash = "sha256:37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c", size = 386536, upload-time = "2026-06-19T10:58:31.347Z" }, +] + +[[package]] +name = "pyyaml" +version = "6.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960, upload-time = "2025-09-25T21:33:16.546Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6d/16/a95b6757765b7b031c9374925bb718d55e0a9ba8a1b6a12d25962ea44347/pyyaml-6.0.3-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:44edc647873928551a01e7a563d7452ccdebee747728c1080d881d68af7b997e", size = 185826, upload-time = "2025-09-25T21:31:58.655Z" }, + { url = "https://files.pythonhosted.org/packages/16/19/13de8e4377ed53079ee996e1ab0a9c33ec2faf808a4647b7b4c0d46dd239/pyyaml-6.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:652cb6edd41e718550aad172851962662ff2681490a8a711af6a4d288dd96824", size = 175577, upload-time = "2025-09-25T21:32:00.088Z" }, + { url = "https://files.pythonhosted.org/packages/0c/62/d2eb46264d4b157dae1275b573017abec435397aa59cbcdab6fc978a8af4/pyyaml-6.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:10892704fc220243f5305762e276552a0395f7beb4dbf9b14ec8fd43b57f126c", size = 775556, upload-time = "2025-09-25T21:32:01.31Z" }, + { url = "https://files.pythonhosted.org/packages/10/cb/16c3f2cf3266edd25aaa00d6c4350381c8b012ed6f5276675b9eba8d9ff4/pyyaml-6.0.3-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:850774a7879607d3a6f50d36d04f00ee69e7fc816450e5f7e58d7f17f1ae5c00", size = 882114, upload-time = "2025-09-25T21:32:03.376Z" }, + { url = "https://files.pythonhosted.org/packages/71/60/917329f640924b18ff085ab889a11c763e0b573da888e8404ff486657602/pyyaml-6.0.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b8bb0864c5a28024fac8a632c443c87c5aa6f215c0b126c449ae1a150412f31d", size = 806638, upload-time = "2025-09-25T21:32:04.553Z" }, + { url = "https://files.pythonhosted.org/packages/dd/6f/529b0f316a9fd167281a6c3826b5583e6192dba792dd55e3203d3f8e655a/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1d37d57ad971609cf3c53ba6a7e365e40660e3be0e5175fa9f2365a379d6095a", size = 767463, upload-time = "2025-09-25T21:32:06.152Z" }, + { url = "https://files.pythonhosted.org/packages/f2/6a/b627b4e0c1dd03718543519ffb2f1deea4a1e6d42fbab8021936a4d22589/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:37503bfbfc9d2c40b344d06b2199cf0e96e97957ab1c1b546fd4f87e53e5d3e4", size = 794986, upload-time = "2025-09-25T21:32:07.367Z" }, + { url = "https://files.pythonhosted.org/packages/45/91/47a6e1c42d9ee337c4839208f30d9f09caa9f720ec7582917b264defc875/pyyaml-6.0.3-cp311-cp311-win32.whl", hash = "sha256:8098f252adfa6c80ab48096053f512f2321f0b998f98150cea9bd23d83e1467b", size = 142543, upload-time = "2025-09-25T21:32:08.95Z" }, + { url = "https://files.pythonhosted.org/packages/da/e3/ea007450a105ae919a72393cb06f122f288ef60bba2dc64b26e2646fa315/pyyaml-6.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:9f3bfb4965eb874431221a3ff3fdcddc7e74e3b07799e0e84ca4a0f867d449bf", size = 158763, upload-time = "2025-09-25T21:32:09.96Z" }, + { url = "https://files.pythonhosted.org/packages/d1/33/422b98d2195232ca1826284a76852ad5a86fe23e31b009c9886b2d0fb8b2/pyyaml-6.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7f047e29dcae44602496db43be01ad42fc6f1cc0d8cd6c83d342306c32270196", size = 182063, upload-time = "2025-09-25T21:32:11.445Z" }, + { url = "https://files.pythonhosted.org/packages/89/a0/6cf41a19a1f2f3feab0e9c0b74134aa2ce6849093d5517a0c550fe37a648/pyyaml-6.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0", size = 173973, upload-time = "2025-09-25T21:32:12.492Z" }, + { url = "https://files.pythonhosted.org/packages/ed/23/7a778b6bd0b9a8039df8b1b1d80e2e2ad78aa04171592c8a5c43a56a6af4/pyyaml-6.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9149cad251584d5fb4981be1ecde53a1ca46c891a79788c0df828d2f166bda28", size = 775116, upload-time = "2025-09-25T21:32:13.652Z" }, + { url = "https://files.pythonhosted.org/packages/65/30/d7353c338e12baef4ecc1b09e877c1970bd3382789c159b4f89d6a70dc09/pyyaml-6.0.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5fdec68f91a0c6739b380c83b951e2c72ac0197ace422360e6d5a959d8d97b2c", size = 844011, upload-time = "2025-09-25T21:32:15.21Z" }, + { url = "https://files.pythonhosted.org/packages/8b/9d/b3589d3877982d4f2329302ef98a8026e7f4443c765c46cfecc8858c6b4b/pyyaml-6.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc", size = 807870, upload-time = "2025-09-25T21:32:16.431Z" }, + { url = "https://files.pythonhosted.org/packages/05/c0/b3be26a015601b822b97d9149ff8cb5ead58c66f981e04fedf4e762f4bd4/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8dc52c23056b9ddd46818a57b78404882310fb473d63f17b07d5c40421e47f8e", size = 761089, upload-time = "2025-09-25T21:32:17.56Z" }, + { url = "https://files.pythonhosted.org/packages/be/8e/98435a21d1d4b46590d5459a22d88128103f8da4c2d4cb8f14f2a96504e1/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:41715c910c881bc081f1e8872880d3c650acf13dfa8214bad49ed4cede7c34ea", size = 790181, upload-time = "2025-09-25T21:32:18.834Z" }, + { url = "https://files.pythonhosted.org/packages/74/93/7baea19427dcfbe1e5a372d81473250b379f04b1bd3c4c5ff825e2327202/pyyaml-6.0.3-cp312-cp312-win32.whl", hash = "sha256:96b533f0e99f6579b3d4d4995707cf36df9100d67e0c8303a0c55b27b5f99bc5", size = 137658, upload-time = "2025-09-25T21:32:20.209Z" }, + { url = "https://files.pythonhosted.org/packages/86/bf/899e81e4cce32febab4fb42bb97dcdf66bc135272882d1987881a4b519e9/pyyaml-6.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:5fcd34e47f6e0b794d17de1b4ff496c00986e1c83f7ab2fb8fcfe9616ff7477b", size = 154003, upload-time = "2025-09-25T21:32:21.167Z" }, + { url = "https://files.pythonhosted.org/packages/1a/08/67bd04656199bbb51dbed1439b7f27601dfb576fb864099c7ef0c3e55531/pyyaml-6.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd", size = 140344, upload-time = "2025-09-25T21:32:22.617Z" }, + { url = "https://files.pythonhosted.org/packages/d1/11/0fd08f8192109f7169db964b5707a2f1e8b745d4e239b784a5a1dd80d1db/pyyaml-6.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8", size = 181669, upload-time = "2025-09-25T21:32:23.673Z" }, + { url = "https://files.pythonhosted.org/packages/b1/16/95309993f1d3748cd644e02e38b75d50cbc0d9561d21f390a76242ce073f/pyyaml-6.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1", size = 173252, upload-time = "2025-09-25T21:32:25.149Z" }, + { url = "https://files.pythonhosted.org/packages/50/31/b20f376d3f810b9b2371e72ef5adb33879b25edb7a6d072cb7ca0c486398/pyyaml-6.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c", size = 767081, upload-time = "2025-09-25T21:32:26.575Z" }, + { url = "https://files.pythonhosted.org/packages/49/1e/a55ca81e949270d5d4432fbbd19dfea5321eda7c41a849d443dc92fd1ff7/pyyaml-6.0.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5", size = 841159, upload-time = "2025-09-25T21:32:27.727Z" }, + { url = "https://files.pythonhosted.org/packages/74/27/e5b8f34d02d9995b80abcef563ea1f8b56d20134d8f4e5e81733b1feceb2/pyyaml-6.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6", size = 801626, upload-time = "2025-09-25T21:32:28.878Z" }, + { url = "https://files.pythonhosted.org/packages/f9/11/ba845c23988798f40e52ba45f34849aa8a1f2d4af4b798588010792ebad6/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6", size = 753613, upload-time = "2025-09-25T21:32:30.178Z" }, + { url = "https://files.pythonhosted.org/packages/3d/e0/7966e1a7bfc0a45bf0a7fb6b98ea03fc9b8d84fa7f2229e9659680b69ee3/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be", size = 794115, upload-time = "2025-09-25T21:32:31.353Z" }, + { url = "https://files.pythonhosted.org/packages/de/94/980b50a6531b3019e45ddeada0626d45fa85cbe22300844a7983285bed3b/pyyaml-6.0.3-cp313-cp313-win32.whl", hash = "sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26", size = 137427, upload-time = "2025-09-25T21:32:32.58Z" }, + { url = "https://files.pythonhosted.org/packages/97/c9/39d5b874e8b28845e4ec2202b5da735d0199dbe5b8fb85f91398814a9a46/pyyaml-6.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c", size = 154090, upload-time = "2025-09-25T21:32:33.659Z" }, + { url = "https://files.pythonhosted.org/packages/73/e8/2bdf3ca2090f68bb3d75b44da7bbc71843b19c9f2b9cb9b0f4ab7a5a4329/pyyaml-6.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb", size = 140246, upload-time = "2025-09-25T21:32:34.663Z" }, + { url = "https://files.pythonhosted.org/packages/9d/8c/f4bd7f6465179953d3ac9bc44ac1a8a3e6122cf8ada906b4f96c60172d43/pyyaml-6.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac", size = 181814, upload-time = "2025-09-25T21:32:35.712Z" }, + { url = "https://files.pythonhosted.org/packages/bd/9c/4d95bb87eb2063d20db7b60faa3840c1b18025517ae857371c4dd55a6b3a/pyyaml-6.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310", size = 173809, upload-time = "2025-09-25T21:32:36.789Z" }, + { url = "https://files.pythonhosted.org/packages/92/b5/47e807c2623074914e29dabd16cbbdd4bf5e9b2db9f8090fa64411fc5382/pyyaml-6.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7", size = 766454, upload-time = "2025-09-25T21:32:37.966Z" }, + { url = "https://files.pythonhosted.org/packages/02/9e/e5e9b168be58564121efb3de6859c452fccde0ab093d8438905899a3a483/pyyaml-6.0.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788", size = 836355, upload-time = "2025-09-25T21:32:39.178Z" }, + { url = "https://files.pythonhosted.org/packages/88/f9/16491d7ed2a919954993e48aa941b200f38040928474c9e85ea9e64222c3/pyyaml-6.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5", size = 794175, upload-time = "2025-09-25T21:32:40.865Z" }, + { url = "https://files.pythonhosted.org/packages/dd/3f/5989debef34dc6397317802b527dbbafb2b4760878a53d4166579111411e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764", size = 755228, upload-time = "2025-09-25T21:32:42.084Z" }, + { url = "https://files.pythonhosted.org/packages/d7/ce/af88a49043cd2e265be63d083fc75b27b6ed062f5f9fd6cdc223ad62f03e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35", size = 789194, upload-time = "2025-09-25T21:32:43.362Z" }, + { url = "https://files.pythonhosted.org/packages/23/20/bb6982b26a40bb43951265ba29d4c246ef0ff59c9fdcdf0ed04e0687de4d/pyyaml-6.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac", size = 156429, upload-time = "2025-09-25T21:32:57.844Z" }, + { url = "https://files.pythonhosted.org/packages/f4/f4/a4541072bb9422c8a883ab55255f918fa378ecf083f5b85e87fc2b4eda1b/pyyaml-6.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3", size = 143912, upload-time = "2025-09-25T21:32:59.247Z" }, + { url = "https://files.pythonhosted.org/packages/7c/f9/07dd09ae774e4616edf6cda684ee78f97777bdd15847253637a6f052a62f/pyyaml-6.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3", size = 189108, upload-time = "2025-09-25T21:32:44.377Z" }, + { url = "https://files.pythonhosted.org/packages/4e/78/8d08c9fb7ce09ad8c38ad533c1191cf27f7ae1effe5bb9400a46d9437fcf/pyyaml-6.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba", size = 183641, upload-time = "2025-09-25T21:32:45.407Z" }, + { url = "https://files.pythonhosted.org/packages/7b/5b/3babb19104a46945cf816d047db2788bcaf8c94527a805610b0289a01c6b/pyyaml-6.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c", size = 831901, upload-time = "2025-09-25T21:32:48.83Z" }, + { url = "https://files.pythonhosted.org/packages/8b/cc/dff0684d8dc44da4d22a13f35f073d558c268780ce3c6ba1b87055bb0b87/pyyaml-6.0.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702", size = 861132, upload-time = "2025-09-25T21:32:50.149Z" }, + { url = "https://files.pythonhosted.org/packages/b1/5e/f77dc6b9036943e285ba76b49e118d9ea929885becb0a29ba8a7c75e29fe/pyyaml-6.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c", size = 839261, upload-time = "2025-09-25T21:32:51.808Z" }, + { url = "https://files.pythonhosted.org/packages/ce/88/a9db1376aa2a228197c58b37302f284b5617f56a5d959fd1763fb1675ce6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065", size = 805272, upload-time = "2025-09-25T21:32:52.941Z" }, + { url = "https://files.pythonhosted.org/packages/da/92/1446574745d74df0c92e6aa4a7b0b3130706a4142b2d1a5869f2eaa423c6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65", size = 829923, upload-time = "2025-09-25T21:32:54.537Z" }, + { url = "https://files.pythonhosted.org/packages/f0/7a/1c7270340330e575b92f397352af856a8c06f230aa3e76f86b39d01b416a/pyyaml-6.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9", size = 174062, upload-time = "2025-09-25T21:32:55.767Z" }, + { url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" }, +] + +[[package]] +name = "ruff" +version = "0.16.10" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c4/49/23802c45f093eb14bde54b141d2b2f058edfa63a06db7beed047308cc08f/ruff-0.16.10.tar.gz", hash = "sha256:eff4728c4eaae93f0955cd264d24b2ab348e74bf59986ccf282ba6dc16b3b017", size = 4958724, upload-time = "2026-10-01T18:03:21.697Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2f/21/ebce22e1d90cdb2cd691026b9c6e9bec6499f0b396089481755b5d49efff/ruff-0.16.10-py3-none-linux_armv6l.whl", hash = "sha256:488b0fe3f3574210e5cf80d9f59b9e3ab17a127a8155de3f307b392589cfb511", size = 10095557, upload-time = "2026-10-01T18:02:36.072Z" }, + { url = "https://files.pythonhosted.org/packages/cb/98/a54de85876a8b2612bfa0d84c7b9abfb39c6a3354aee7800b09c1649b9e3/ruff-0.16.10-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:e748ff95c934c4e978783b8e687bc174e7bd84e8ad24e3243e1ecfcda5e0282d", size = 10340577, upload-time = "2026-10-01T18:02:39.258Z" }, + { url = "https://files.pythonhosted.org/packages/9f/16/1a5a4a2657effe4806110f2b907313802f1367fcdbb29e8122766e407fab/ruff-0.16.10-py3-none-macosx_11_0_arm64.whl", hash = "sha256:3031a4a2e8e7b8a46f70be45f198c35a11ece509a94b80334d8d397a33c67550", size = 9774282, upload-time = "2026-10-01T18:02:41.79Z" }, + { url = "https://files.pythonhosted.org/packages/6e/fb/470085af734da396e68cd80fb0a3e7c109a459716ae59588c0f6fab8a17d/ruff-0.16.10-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:494401c86df4c4c25f69b9419605d944467ee98c42fb6ad405ef4fa40b8fb67d", size = 9920895, upload-time = "2026-10-01T18:02:44.458Z" }, + { url = "https://files.pythonhosted.org/packages/57/de/f10cffe4f88a37ea6615ec460f01bf76f0bc1477737a35d9ee61a630a78b/ruff-0.16.10-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:d203abc0ff2b773ee33d00ab8df0bb67046fbc7c07b119332f08b9b345cf8221", size = 9902707, upload-time = "2026-10-01T18:02:47.041Z" }, + { url = "https://files.pythonhosted.org/packages/ff/44/3fdcedf83ae60ef837dd239170e480dce606a9142cfa2db911afdf855fd9/ruff-0.16.10-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:bd83d1235a5258d318477bc5b576303974cbdef5df0c01a1bff14efcc23bd12a", size = 10619287, upload-time = "2026-10-01T18:02:49.485Z" }, + { url = "https://files.pythonhosted.org/packages/c1/62/02e76a5574002153618eafbb70e159468addc72a5d2c488e65cbb4639d2d/ruff-0.16.10-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:bc2610fb269fa56dd8a68669ae470fa6272902668c0fc2ebc3aa112b2633d5b8", size = 11339942, upload-time = "2026-10-01T18:02:52.008Z" }, + { url = "https://files.pythonhosted.org/packages/6b/c4/cde27d47ad8d4126c587e608c67c46e7feac11763f606d261a1bc8a489e6/ruff-0.16.10-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3e70175e29cc94c26ea296c80e470180b744b7419026898e58f520c6ab32578e", size = 10934316, upload-time = "2026-10-01T18:02:54.811Z" }, + { url = "https://files.pythonhosted.org/packages/e4/03/17234145f302a645a123e8c3bb2411ecf4669fbc350de3b0d803230a1729/ruff-0.16.10-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f33f43a864a8483eebd160e713336c8bab02c934feaff0a33cf5ccb41546d09a", size = 10387968, upload-time = "2026-10-01T18:02:57.497Z" }, + { url = "https://files.pythonhosted.org/packages/71/29/2493af60240ee7644b38a4b821f5f9c3b5a4fa3178770fe1d0c685217395/ruff-0.16.10-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:1dfc6f0088149fb6a362c1c446bcbb3fd2157b3852fe2fa68409276eab9ad9b3", size = 10537906, upload-time = "2026-10-01T18:03:00.006Z" }, + { url = "https://files.pythonhosted.org/packages/2e/9a/f56b28f3b143bb9e518c34fb89ab191b626bca8b70dbf8af0d2e2d473572/ruff-0.16.10-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:6553498afc35f580f036030795810b9e6bcea31604b0fd9e8d352795473042e3", size = 10012938, upload-time = "2026-10-01T18:03:03.006Z" }, + { url = "https://files.pythonhosted.org/packages/c2/c4/fca37362848ea4d4d80712df13632e645e7c7cfb1bdedf140699ac7a090b/ruff-0.16.10-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:a3b8471dea115d37f123882be852bed13403746d3a76c11de4a19ec5f9ff5a03", size = 9897945, upload-time = "2026-10-01T18:03:05.818Z" }, + { url = "https://files.pythonhosted.org/packages/7d/c7/e0bc57664d6e0c61fd22f620af260fe9662d7e1ddb320ea7f160e76ef165/ruff-0.16.10-py3-none-musllinux_1_2_i686.whl", hash = "sha256:92e59a70bcbd9d3a5483656da906ec28edfdacfce00afd99edb8b4e9d15644be", size = 10333134, upload-time = "2026-10-01T18:03:08.25Z" }, + { url = "https://files.pythonhosted.org/packages/21/aa/5c9f3b68737c0e4a33a1db5dfab44f7b786d96ca91a7233112ddab1e6dc2/ruff-0.16.10-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:7ae7375f803b5520dc9f546bed7e3a0acb70b91812e9bb4b19927de22f25b77d", size = 10741702, upload-time = "2026-10-01T18:03:10.775Z" }, + { url = "https://files.pythonhosted.org/packages/78/fa/0f9c2020dc316c53d983be011720b8157cc052b97e3f4dfa7db6d0f880a6/ruff-0.16.10-py3-none-win32.whl", hash = "sha256:2a12e01cb9156c10c466f63b46eaae5ecea28dfbd21b5836353ae498e7d1349a", size = 10139176, upload-time = "2026-10-01T18:03:13.267Z" }, + { url = "https://files.pythonhosted.org/packages/99/29/cfb0df9448d4d4ad48c2de029ada9ebd71baa6da983a6c77ee6c6cd0fe82/ruff-0.16.10-py3-none-win_amd64.whl", hash = "sha256:97f2015c92aa97105b0eab19eb5d224884399281cfc5da86a92db4ab5e7fb2ca", size = 10584734, upload-time = "2026-10-01T18:03:16.006Z" }, + { url = "https://files.pythonhosted.org/packages/fc/05/c16957eb287c3fc062e032619a25868d93d408a844b725bcf514f7a378ff/ruff-0.16.10-py3-none-win_arm64.whl", hash = "sha256:25a65fe998c4e6861ec079ada5826a2fc605e6cbccbe9dcd7fac1f54e791621b", size = 10366440, upload-time = "2026-10-01T18:03:19.04Z" }, +] + +[[package]] +name = "typedstandards" +source = { editable = "." } +dependencies = [ + { name = "httpx" }, + { name = "pyyaml" }, +] + +[package.dev-dependencies] +dev = [ + { name = "pytest" }, + { name = "ruff" }, +] + +[package.metadata] +requires-dist = [ + { name = "httpx", specifier = ">=0.28" }, + { name = "pyyaml", specifier = ">=6.0" }, +] + +[package.metadata.requires-dev] +dev = [ + { name = "pytest", specifier = ">=8.4" }, + { name = "ruff", specifier = ">=0.14" }, +] + +[[package]] +name = "typing-extensions" +version = "4.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f6/cc/6253133b5bb138fc3306cebfbda2c520f545d36b5be2c7255cc528bb45d6/typing_extensions-4.16.0.tar.gz", hash = "sha256:dc983d19a509c94dba722ee6abd33940f7c05a89e243c47e907eb4db6f1a43e5", size = 113555, upload-time = "2026-07-02T08:40:05.92Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/49/d3/b8441a820a491ddfc024b0b0cf0393375b75ea13866d9c66727e54c2fc80/typing_extensions-4.16.0-py3-none-any.whl", hash = "sha256:481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8", size = 45571, upload-time = "2026-07-02T08:40:04.659Z" }, +]