From ff3a57392a5905a8f1ca4d59a48a8ff84c29d518 Mon Sep 17 00:00:00 2001 From: Nathan Storey Date: Sat, 3 Oct 2026 11:12:11 -0400 Subject: [PATCH 1/6] Scaffold, vendored CLI 0.2.0, and the pass-throughs pyproject.toml (hatchling, requires-python >=3.11, httpx and PyYAML declared for P2), uv.lock, and package.json with package-lock.json pinning @typedstandards/cli 0.2.0 exactly. hatch_build.py runs npm ci --omit=dev --ignore-scripts at every wheel build, standard or editable, and ships the tree with each package's licence inside the wheel. The package runs the vendored entry file with node on PATH and returns the CLI's stdout parsed as JSON: sign, withdraw, attest, view and verify. Not yet: the Node floor and override, the exit-code mapping (every non-zero exit raises the base CliError), and verify's trustRegistry drop (D9). Fixtures: verbatim copies of typedstandards' reference golden (116882a) and the host template's served bundle (70bfd18); tests/fixtures/README.md gives their provenance. .gitleaks.toml is the template's did:key allow rule. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Ru4PYga7Zf8HANgotZKs4u Signed-off-by: Nathan Storey --- .gitignore | 10 + .gitleaks.toml | 7 + .python-version | 1 + CHANGELOG.md | 5 + README.md | 3 + hatch_build.py | 62 ++ package-lock.json | 96 +++ package.json | 9 + pyproject.toml | 84 ++ src/typedstandards/__init__.py | 55 ++ src/typedstandards/_cli.py | 47 + src/typedstandards/_commands.py | 134 +++ src/typedstandards/_node.py | 16 + src/typedstandards/errors.py | 58 ++ tests/fixtures/README.md | 21 + tests/fixtures/first-note.bundle.json | 78 ++ tests/fixtures/reference-golden.json | 1151 +++++++++++++++++++++++++ uv.lock | 247 ++++++ 18 files changed, 2084 insertions(+) create mode 100644 .gitignore create mode 100644 .gitleaks.toml create mode 100644 .python-version create mode 100644 CHANGELOG.md create mode 100644 README.md create mode 100644 hatch_build.py create mode 100644 package-lock.json create mode 100644 package.json create mode 100644 pyproject.toml create mode 100644 src/typedstandards/__init__.py create mode 100644 src/typedstandards/_cli.py create mode 100644 src/typedstandards/_commands.py create mode 100644 src/typedstandards/_node.py create mode 100644 src/typedstandards/errors.py create mode 100644 tests/fixtures/README.md create mode 100644 tests/fixtures/first-note.bundle.json create mode 100644 tests/fixtures/reference-golden.json create mode 100644 uv.lock 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..a555a5f --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,5 @@ +# Changelog + +## Unreleased + +- The wrapper's core. diff --git a/README.md b/README.md new file mode 100644 index 0000000..644a309 --- /dev/null +++ b/README.md @@ -0,0 +1,3 @@ +# typedstandards + +Typed Standards records from Python, through `@typedstandards/cli` as a child process. 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..e5b585c --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,84 @@ +[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", + "/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/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..650afdd --- /dev/null +++ b/src/typedstandards/_cli.py @@ -0,0 +1,47 @@ +"""Run the vendored CLI as a child process and read its result.""" + +from __future__ import annotations + +import json +import subprocess +import sys +from collections.abc import Sequence +from pathlib import Path +from typing import Any + +from ._node import locate_node +from .errors import CliError, CliNotVendoredError + +#: 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" + + +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 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. + """ + 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") + if proc.returncode == 0: + if stderr: + sys.stderr.write(stderr) + try: + return json.loads(proc.stdout.decode("utf-8")) + except ValueError as err: + raise CliError(0, f"stdout is not JSON ({err}); stderr: {stderr}", command) from err + raise CliError(proc.returncode, stderr, command) diff --git a/src/typedstandards/_commands.py b/src/typedstandards/_commands.py new file mode 100644 index 0000000..3270d00 --- /dev/null +++ b/src/typedstandards/_commands.py @@ -0,0 +1,134 @@ +"""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 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. + """ + if isinstance(blobs, (str, os.PathLike)): + raise TypeError("blobs takes a list of paths, not one") + args, stdin = _input_args(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..faf482d --- /dev/null +++ b/src/typedstandards/_node.py @@ -0,0 +1,16 @@ +"""Locate the Node binary that runs the vendored CLI.""" + +from __future__ import annotations + +import shutil + +#: 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) + + +def locate_node() -> str: + """Return the Node binary to run the CLI with.""" + return shutil.which("node") or "node" 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/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/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" }, +] From 9744147ad9eb9f093ef6fb600890d847a87b0d76 Mon Sep 17 00:00:00 2001 From: Nathan Storey Date: Sat, 3 Oct 2026 11:12:20 -0400 Subject: [PATCH 2/6] Tests for the wrapper's core (red: floor, exit mapping, D9) Golden replay of the 9 envelope cases and the withdraws case; the static and run-time guards (no seed variable, no env=, no digest module outside P2's pin); the Node locator; exits 1-4; CLI_VERSION; D9's trustRegistry drop; the five pass-throughs end to end. Red at this commit, by design: the Node floor and override, the exit-code mapping and the D9 drop are not implemented, so their tests fail at their assertions. Every module imports and every test collects. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Ru4PYga7Zf8HANgotZKs4u Signed-off-by: Nathan Storey --- tests/conftest.py | 40 +++++++++ tests/guards.py | 143 +++++++++++++++++++++++++++++++ tests/support.py | 68 +++++++++++++++ tests/test_commands.py | 163 +++++++++++++++++++++++++++++++++++ tests/test_d9.py | 89 +++++++++++++++++++ tests/test_exit_codes.py | 89 +++++++++++++++++++ tests/test_golden.py | 86 +++++++++++++++++++ tests/test_guards.py | 180 +++++++++++++++++++++++++++++++++++++++ tests/test_node.py | 112 ++++++++++++++++++++++++ tests/test_version.py | 41 +++++++++ 10 files changed, 1011 insertions(+) create mode 100644 tests/conftest.py create mode 100644 tests/guards.py create mode 100644 tests/support.py create mode 100644 tests/test_commands.py create mode 100644 tests/test_d9.py create mode 100644 tests/test_exit_codes.py create mode 100644 tests/test_golden.py create mode 100644 tests/test_guards.py create mode 100644 tests/test_node.py create mode 100644 tests/test_version.py 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/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..9129cf1 --- /dev/null +++ b/tests/test_commands.py @@ -0,0 +1,163 @@ +"""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 +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: + with caplog.at_level(logging.INFO, logger="typedstandards"): + typedstandards.sign(self_certifying_input()) + assert "registry_unavailable (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() 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..37ebe80 --- /dev/null +++ b/tests/test_exit_codes.py @@ -0,0 +1,89 @@ +"""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"}}) + 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..068714d --- /dev/null +++ b/tests/test_guards.py @@ -0,0 +1,180 @@ +"""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"} + for call in spawned: + assert "env" not in call["kwargs"], f"env= passed to {call['args']}" + assert call["environ"] == before, f"the environment changed before {call['args']}" + assert seed not in " ".join(call["args"]), "the seed reached an argument" + assert call["stdin"] is None or seed.encode() not in call["stdin"], "the seed reached stdin" + assert dict(os.environ) == before + + +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() + assert SEED_VARIABLE not in recorder.keys_read + assert not recorder.read_all, "the whole environment was read" 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 From 85a4b9955055f88509d6e2bc169e1b3847889e17 Mon Sep 17 00:00:00 2001 From: Nathan Storey Date: Sat, 3 Oct 2026 11:13:40 -0400 Subject: [PATCH 3/6] Node floor and override, exit-code mapping, D9 drop (green) The locator tries TYPEDSTANDARDS_NODE, then node on PATH, reads node --version, and raises NodeLocatorError naming 20.19 and the override when no Node is found or the one found is below 20.19.0. Exits 1-4 raise VerificationError, UsageError, SeedError and InternalError, which share the base CliError and carry the exit code and the CLI's stderr; VerificationError also carries the verdict verify prints before exiting 1. An unmapped code raises the base. On exit 0, the CLI's stderr (attention readings) is logged at INFO on the typedstandards logger. verify drops a top-level trustRegistry from a bundle and changes nothing else (G0 D9 = A, typedstandards#136); the workaround goes when the wrapper pins a CLI that accepts the key. Two tests are corrected here: the malformed-seed case now sends a valid withdraw input (the CLI checks the input before the seed), and the logging case drives attest, whose key_unbound reading is printed with exit 0 (a self-certifying sign prints nothing on stderr). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Ru4PYga7Zf8HANgotZKs4u Signed-off-by: Nathan Storey --- src/typedstandards/_cli.py | 45 ++++++++++++++++++++----- src/typedstandards/_commands.py | 26 +++++++++++++-- src/typedstandards/_node.py | 58 +++++++++++++++++++++++++++++++-- tests/test_commands.py | 13 ++++++-- tests/test_exit_codes.py | 8 ++++- 5 files changed, 133 insertions(+), 17 deletions(-) diff --git a/src/typedstandards/_cli.py b/src/typedstandards/_cli.py index 650afdd..d85fb6c 100644 --- a/src/typedstandards/_cli.py +++ b/src/typedstandards/_cli.py @@ -3,18 +3,31 @@ from __future__ import annotations import json +import logging import subprocess -import sys from collections.abc import Sequence from pathlib import Path from typing import Any from ._node import locate_node -from .errors import CliError, CliNotVendoredError +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.""" @@ -26,22 +39,36 @@ def cli_entry() -> Path: 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. + 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") - if proc.returncode == 0: - if stderr: - sys.stderr.write(stderr) + code = proc.returncode + if code == 0: + for line in stderr.splitlines(): + _log.info("%s", line) try: - return json.loads(proc.stdout.decode("utf-8")) + return _parse(proc.stdout) except ValueError as err: raise CliError(0, f"stdout is not JSON ({err}); stderr: {stderr}", command) from err - raise CliError(proc.returncode, stderr, command) + 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 index 3270d00..2fd56e9 100644 --- a/src/typedstandards/_commands.py +++ b/src/typedstandards/_commands.py @@ -110,6 +110,27 @@ def view( 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, *, @@ -122,11 +143,12 @@ def verify( (``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. + 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(input, "--input") + args, stdin = _input_args(_without_trust_registry(input), "--input") for blob in blobs: args += ["--blob", os.fspath(blob)] if full: diff --git a/src/typedstandards/_node.py b/src/typedstandards/_node.py index faf482d..37941c6 100644 --- a/src/typedstandards/_node.py +++ b/src/typedstandards/_node.py @@ -1,8 +1,17 @@ -"""Locate the Node binary that runs the vendored CLI.""" +"""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" @@ -10,7 +19,50 @@ #: ``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 Node binary to run the CLI with.""" - return shutil.which("node") or "node" + """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/tests/test_commands.py b/tests/test_commands.py index 9129cf1..c7d5052 100644 --- a/tests/test_commands.py +++ b/tests/test_commands.py @@ -128,9 +128,18 @@ def test_mapping_input_goes_on_stdin(seed: str, spawned: list[dict[str, Any]]) - 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.sign(self_certifying_input()) - assert "registry_unavailable (attention)" in caplog.text + 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"]]) diff --git a/tests/test_exit_codes.py b/tests/test_exit_codes.py index 37ebe80..3bf909a 100644 --- a/tests/test_exit_codes.py +++ b/tests/test_exit_codes.py @@ -67,7 +67,13 @@ def test_exit_3_an_unset_seed(monkeypatch: pytest.MonkeyPatch) -> None: 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"}}) + typedstandards.withdraw( + { + "targetNodeId": "a" * 64, + "reason": "r", + "signer": {"bindingTier": "pseudonymous", "displayName": "Example"}, + } + ) assert caught.value.exit_code == 3 From 5ce9f8f5d4b33122891c4406df24aa1dd875feb3 Mon Sep 17 00:00:00 2001 From: Nathan Storey Date: Sat, 3 Oct 2026 11:15:46 -0400 Subject: [PATCH 4/6] Run-time guards report keys, never environment values A failing assertion's repr printed the recorded environment, which in a developer's shell or on a CI runner can hold real secrets. The run-time guards now fail with pytest.fail and name only argv and the keys that changed. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Ru4PYga7Zf8HANgotZKs4u Signed-off-by: Nathan Storey --- tests/test_guards.py | 27 ++++++++++++++++++++------- 1 file changed, 20 insertions(+), 7 deletions(-) diff --git a/tests/test_guards.py b/tests/test_guards.py index 068714d..0b77ed2 100644 --- a/tests/test_guards.py +++ b/tests/test_guards.py @@ -126,12 +126,23 @@ def test_every_child_inherits_the_environment(seed: str, spawned: list[dict[str, 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: - assert "env" not in call["kwargs"], f"env= passed to {call['args']}" - assert call["environ"] == before, f"the environment changed before {call['args']}" - assert seed not in " ".join(call["args"]), "the seed reached an argument" - assert call["stdin"] is None or seed.encode() not in call["stdin"], "the seed reached stdin" - assert dict(os.environ) == before + 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]): @@ -176,5 +187,7 @@ def test_wrapper_never_reads_the_seed_variable(seed: str, monkeypatch: pytest.Mo recorder = RecordingEnviron(os.environ) monkeypatch.setattr(os, "environ", recorder) _drive_every_command() - assert SEED_VARIABLE not in recorder.keys_read - assert not recorder.read_all, "the whole environment was read" + 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") From b7e0b26247d3747a091d4ba24e48efa62ea10b68 Mon Sep 17 00:00:00 2001 From: Nathan Storey Date: Sat, 3 Oct 2026 11:27:59 -0400 Subject: [PATCH 5/6] CI matrix, wheel smoke check, README, CLAUDE.md, agent files ci.yml: test on Python 3.11, 3.12 and 3.14 x ubuntu-24.04 and macos-15 on Node 24, plus ubuntu-24.04, 3.12, Node 22; lint; and a wheel job that builds the sdist and the wheel from it, installs the wheel into a fresh environment and runs scripts/smoke_wheel.py there. Actions pinned to commit SHAs measured with git ls-remote; permissions: contents: read. README: install and use, both Node floors, the key path, the D9 behaviour with typedstandards#136, Windows untested. CLAUDE.md: this repository's rules. .claude/agents/impl.md and cold-read.md adapted from typedstandards', each pinning effort: high and naming this repository's checks. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Ru4PYga7Zf8HANgotZKs4u Signed-off-by: Nathan Storey --- .claude/agents/cold-read.md | 57 ++++++++++++++++ .claude/agents/impl.md | 55 ++++++++++++++++ .github/workflows/ci.yml | 96 +++++++++++++++++++++++++++ CHANGELOG.md | 9 ++- CLAUDE.md | 97 +++++++++++++++++++++++++++ README.md | 127 +++++++++++++++++++++++++++++++++++- pyproject.toml | 1 + scripts/smoke_wheel.py | 69 ++++++++++++++++++++ 8 files changed, 509 insertions(+), 2 deletions(-) create mode 100644 .claude/agents/cold-read.md create mode 100644 .claude/agents/impl.md create mode 100644 .github/workflows/ci.yml create mode 100644 CLAUDE.md create mode 100644 scripts/smoke_wheel.py 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/CHANGELOG.md b/CHANGELOG.md index a555a5f..fda57f9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,4 +2,11 @@ ## Unreleased -- The wrapper's core. +- `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 index 644a309..34c35d3 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,128 @@ # typedstandards -Typed Standards records from Python, through `@typedstandards/cli` as a child process. +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/pyproject.toml b/pyproject.toml index e5b585c..705e9ee 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -46,6 +46,7 @@ path = "src/typedstandards/__init__.py" include = [ "/src/typedstandards", "/tests", + "/scripts", "/hatch_build.py", "/package.json", "/package-lock.json", 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()) From bb8c37d1e7f860e46b8fac888e6a78adb60ddccb Mon Sep 17 00:00:00 2001 From: Nathan Storey Date: Sat, 3 Oct 2026 11:33:34 -0400 Subject: [PATCH 6/6] Test that importing the package loads neither httpx nor PyYAML Both are declared for P2's helpers; P1 imports neither at module load. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Ru4PYga7Zf8HANgotZKs4u Signed-off-by: Nathan Storey --- tests/test_commands.py | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/tests/test_commands.py b/tests/test_commands.py index c7d5052..4ce7b34 100644 --- a/tests/test_commands.py +++ b/tests/test_commands.py @@ -5,6 +5,8 @@ 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 @@ -170,3 +172,10 @@ def test_vendored_tree_carries_each_licence() -> None: 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() == "[]"